Vertical RTL Book Reading Support

Added a variety of functions to viewport to allow working based on block and inline direction rather than X and Y.

Changed paged mode and flow mode to be based on block and inline directions, making them agnostic to writing direction.

Added jump_to_cfi function to flow mode code, and use this in iframe. This fixes some issues with CFI jumping in flow mode.

Use self.jump_to_cfi in iframe so that it is based on the current mode.

Removed a redundant self.onscroll call that was causing CFIs to be queried twice on load, doubling the time they took.

Fixed some bugs related to scrolling in flow mode related to a decorator not working with keyword arguments, by removing the keyword arguments.

Fixed some bugs with flow mode visibility anchor function.

CFI changes:
 Renamed some functions and variables to clarify meaning.

 Remove use of exceptions, since they were causing CFI calculation to run about six times more slowly in benchmarks. (Worst-case went from ~6 to ~1 second.)

 Remove forward flag and allow text offset to be equal to node length, and correct for this by adding a flag to the decoded CFI to indicate this.

 Added comments clarifying the results of decoding a CFI.

 Fix bugs in the point (now decode_with_range) function and simplified it.

 Added new functions that get CFI position from decoded CFIs, to replace places where this was being done in slightly different ways.

 Fixed some issues with cfi.pyj's scroll_to that were exposed by vertical writing support, mainly caching and using span's parent to create the start and end ranges, which causes re-creating the range to succeed. Also, don't store the span's bounding rect, since it's not needed anymore.

 Rewrote at_current to do a simplified scan of the viewport in the inline and then block direction.
This commit is contained in:
Mark W. Gabby-Li 2020-08-11 22:36:58 -07:00 committed by Kovid Goyal
parent c5b9677809
commit 8b39fea8f8
No known key found for this signature in database
GPG Key ID: 06BC317B515ACE7C
7 changed files with 796 additions and 355 deletions

View File

@ -57,6 +57,9 @@ class Parser(object):
def parse_epubcfi(self, raw): def parse_epubcfi(self, raw):
' Parse a full epubcfi of the form epubcfi(path [ , path , path ]) ' ' Parse a full epubcfi of the form epubcfi(path [ , path , path ]) '
null = {}, {}, {}, raw null = {}, {}, {}, raw
if not raw:
return null
if not raw.startswith('epubcfi('): if not raw.startswith('epubcfi('):
return null return null
raw = raw[len('epubcfi('):] raw = raw[len('epubcfi('):]

View File

@ -136,11 +136,15 @@ absolute_font_sizes = {
'medium': '1rem', 'medium': '1rem',
'large': '1.125rem', 'x-large': '1.5rem', 'xx-large': '2rem', 'xxx-large': '2.55rem' 'large': '1.125rem', 'x-large': '1.5rem', 'xx-large': '2rem', 'xxx-large': '2.55rem'
} }
nonstandard_writing_mode_property_names = ('-webkit-writing-mode', '-epub-writing-mode')
def transform_declaration(decl): def transform_declaration(decl):
decl = StyleDeclaration(decl) decl = StyleDeclaration(decl)
changed = False changed = False
nonstandard_writing_mode_props = {}
standard_writing_mode_props = {}
for prop, parent_prop in tuple(decl): for prop, parent_prop in tuple(decl):
if prop.name in page_break_properties: if prop.name in page_break_properties:
changed = True changed = True
@ -162,6 +166,18 @@ def transform_declaration(decl):
changed = True changed = True
l = convert_fontsize(l, unit) l = convert_fontsize(l, unit)
decl.change_property(prop, parent_prop, unicode_type(l) + 'rem') decl.change_property(prop, parent_prop, unicode_type(l) + 'rem')
elif prop.name in nonstandard_writing_mode_property_names:
nonstandard_writing_mode_props[prop.value] = prop.priority
elif prop.name == 'writing-mode':
standard_writing_mode_props[prop.value] = True
# Add standard writing-mode properties if they don't exist so that
# all of the browsers supported by the viewer work in vertical modes
for value, priority in nonstandard_writing_mode_props.items():
if value not in standard_writing_mode_props:
decl.set_property('writing-mode', value, priority)
changed = True
return changed return changed

View File

@ -21,7 +21,7 @@ from __python__ import hash_literals
# scroll_to(cfi): which scrolls the browser to a point corresponding to the # scroll_to(cfi): which scrolls the browser to a point corresponding to the
# given cfi, and returns the x and y co-ordinates of the point. # given cfi, and returns the x and y co-ordinates of the point.
from read_book.viewport import scroll_viewport from read_book.viewport import scroll_viewport, rem_size
# CFI escaping {{{ # CFI escaping {{{
escape_pat = /[\[\],^();~@!-]/g escape_pat = /[\[\],^();~@!-]/g
@ -52,11 +52,6 @@ def get_current_time(target): # {{{
return fstr(target.currentTime or 0) return fstr(target.currentTime or 0)
# }}} # }}}
def window_scroll_pos(w): # {{{
w = w or window
return w.pageXOffset, w.pageYOffset
# }}}
# Convert point to character offset {{{ # Convert point to character offset {{{
def range_has_point(range_, x, y): def range_has_point(range_, x, y):
rects = range_.getClientRects() rects = range_.getClientRects()
@ -102,7 +97,7 @@ def find_offset_for_point(x, y, node, cdoc):
# The point must be after the last bit of text/in the padding/border, we dont know # The point must be after the last bit of text/in the padding/border, we dont know
# how to get a good point in this case # how to get a good point in this case
raise ValueError(str.format("Point ({}, {}) is in the padding/border of the node, so cannot calculate offset", x, y)) return None, None
# }}} # }}}
@ -279,7 +274,7 @@ def node_for_path_step(parent, target, assertion):
return node_at_index(parent.childNodes, target, 0, not is_element)[0] return node_at_index(parent.childNodes, target, 0, not is_element)[0]
def node_for_text_offset(nodes, offset, forward, first_node): def node_for_text_offset(nodes, offset, first_node):
last_text_node = None last_text_node = None
seen_first = False seen_first = False
for i in range(nodes.length): for i in range(nodes.length):
@ -291,17 +286,30 @@ def node_for_text_offset(nodes, offset, forward, first_node):
continue continue
if is_text_node(node): if is_text_node(node):
l = node.nodeValue.length l = node.nodeValue.length
if offset < l or (not forward and offset is l): if offset <= l:
return node, offset, True return node, offset, True
last_text_node = node last_text_node = node
offset -= l offset -= l
elif node.nodeType is Node.ELEMENT_NODE and node.dataset.calibreRangeWrapper: elif node.nodeType is Node.ELEMENT_NODE and node.dataset.calibreRangeWrapper:
qn, offset, ok = node_for_text_offset(unwrapped_nodes(node), offset, forward) qn, offset, ok = node_for_text_offset(unwrapped_nodes(node), offset)
if ok: if ok:
return qn, offset, True return qn, offset, True
return last_text_node, offset, False return last_text_node, offset, False
# Based on a CFI string, returns a decoded CFI, with members:
#
# node: The node to which the CFI refers.
# time: If the CFI refers to a video or sound, this is the time within such to which it refers.
# x, y: If the CFI defines a spacial offset (technically only valid for images and videos),
# these are the X and Y percentages from the top-left of the image or video.
# Note that Calibre has a fallback to set CFIs with spacial offset on the HTML document,
# and interprets them as a position within the Calibre-rendered document.
# forward: This is set to True if the CFI had a side bias of 'a' (meaning 'after').
# offset: When the CFI refers to a text node, this is the offset (zero-based index) of the character
# the CFI refers to. The position is defined as being before the specified character,
# i.e. an offset of 0 is before the first character and an offset equal to number of characters
# in the text is after the last character.
# error: If there was a problem decoding the CFI, this is set to a string describing the error.
def decode(cfi, doc): def decode(cfi, doc):
doc = doc or window.document doc = doc or window.document
simple_node_regex = /// simple_node_regex = ///
@ -341,7 +349,7 @@ def decode(cfi, doc):
print(error) print(error)
return None return None
point = {} decoded = {}
error = None error = None
offset = None offset = None
@ -354,14 +362,14 @@ def decode(cfi, doc):
r = cfi.match(/^~(-?\d+(\.\d+)?)/) r = cfi.match(/^~(-?\d+(\.\d+)?)/)
if r: if r:
# Temporal offset # Temporal offset
point.time = r[1] - 0 # Coerce to number decoded.time = r[1] - 0 # Coerce to number
cfi = cfi.substr(r[0].length) cfi = cfi.substr(r[0].length)
r = cfi.match(/^@(-?\d+(\.\d+)?):(-?\d+(\.\d+)?)/) r = cfi.match(/^@(-?\d+(\.\d+)?):(-?\d+(\.\d+)?)/)
if r: if r:
# Spatial offset # Spatial offset
point.x = r[1] - 0 # Coerce to number decoded.x = r[1] - 0 # Coerce to number
point.y = r[3] - 0 # Coerce to number decoded.y = r[3] - 0 # Coerce to number
cfi = cfi.substr(r[0].length) cfi = cfi.substr(r[0].length)
r = cfi.match(/^\[([^\]]+)\]/) r = cfi.match(/^\[([^\]]+)\]/)
@ -372,7 +380,7 @@ def decode(cfi, doc):
if r: if r:
if r.index > 0 and assertion[r.index - 1] is not '^': if r.index > 0 and assertion[r.index - 1] is not '^':
assertion = assertion.substr(0, r.index) assertion = assertion.substr(0, r.index)
point.forward = (r[1] is 'a') decoded.forward = (r[1] is 'a')
assertion = unescape_from_cfi(assertion) assertion = unescape_from_cfi(assertion)
# TODO: Handle text assertion # TODO: Handle text assertion
@ -381,21 +389,21 @@ def decode(cfi, doc):
orig_offset = offset orig_offset = offset
if node.parentNode?.nodeType is Node.ELEMENT_NODE and node.parentNode.dataset.calibreRangeWrapper: if node.parentNode?.nodeType is Node.ELEMENT_NODE and node.parentNode.dataset.calibreRangeWrapper:
node = node.parentNode node = node.parentNode
node, offset, ok = node_for_text_offset(node.parentNode.childNodes, offset, point.forward, node) node, offset, ok = node_for_text_offset(node.parentNode.childNodes, offset, node)
if not ok: if not ok:
error = "Offset out of range: " + orig_offset error = "Offset out of range: " + orig_offset
point.offset = offset decoded.offset = offset
point.node = node decoded.node = node
if error: if error:
point.error = error decoded.error = error
else if cfi.length > 0: else if cfi.length > 0:
point.error = "Undecoded CFI: " + cfi decoded.error = "Undecoded CFI: " + cfi
if point.error: if decoded.error:
print(point.error) print(decoded.error)
return point return decoded
# }}} # }}}
def cfi_sort_key(cfi): # {{{ def cfi_sort_key(cfi): # {{{
@ -536,85 +544,169 @@ def at(x, y, doc): # {{{
# caretRangeFromPoint does weird things when the point falls in the # caretRangeFromPoint does weird things when the point falls in the
# padding of the element # padding of the element
target, offset = find_offset_for_point(x, y, target, cdoc) target, offset = find_offset_for_point(x, y, target, cdoc)
if target is None:
return None
return encode(doc, target, offset, tail) return encode(doc, target, offset, tail)
# }}} # }}}
def point(cfi, doc): # {{{ # Like decode(), but tries to construct a range from the CFI's character offset and include it in
# the return value.
#
# If the CFI defines a character offset, there are three cases:
# Case 1. If the offset is 0 and the text node's length is zero,
# the range is set to None. This is a failure case, but
# later code will try to use the node's bounding box.
# Case 2. Otherwise, if the offset is equal to the length of the range,
# then a range from the previous to the last character is created,
# and use_range_end_pos is set. This is the special case.
# Case 3. Otherwise, the range is set start at the offset and end at one character past the offset.
#
# In cases 2 and 3, the range is then checked to verify that bounding information can be obtained.
# If not, no range is returned.
#
# If the CFI does not define a character offset, then the spacial offset is set in the return value.
#
# Includes everything that the decode() function does, in addition to:
# range: A text range, as desribed above.
# use_range_end_pos: If this is True, a position calculated from the range should
# use the position after the last character in the range.
# (This is set if the offset is equal to the length of the text in the node.)
def decode_with_range(cfi, doc): # {{{
doc = doc or window.document doc = doc or window.document
r = decode(cfi, doc) decoded = decode(cfi, doc)
if not r: if not decoded:
return None return None
node = r.node node = decoded.node
ndoc = node.ownerDocument ndoc = node.ownerDocument
if not ndoc: if not ndoc:
print(str.format("CFI node has no owner document: {} {}", cfi, node)) print(str.format("CFI node has no owner document: {} {}", cfi, node))
return None return None
x = None
y = None
range_ = None range_ = None
position_at_end_of_range = None
if jstype(r.offset) is "number": if jstype(decoded.offset) is "number":
# Character offset # We can only create a meaningful range if the node length is
range_ = ndoc.createRange() # positive and nonzero.
if r.forward:
try_list = [{'start':0, 'end':0, 'a':0.5}, {'start':0, 'end':1, 'a':1}, {'start':-1, 'end':0, 'a':0}]
else:
try_list = [{'start':0, 'end':0, 'a':0.5}, {'start':-1, 'end':0, 'a':0}, {'start':0, 'end':1, 'a':1}]
a = None
node_len = node.nodeValue.length if node.nodeValue else 0 node_len = node.nodeValue.length if node.nodeValue else 0
offset = r.offset if node_len:
if not offset: range_ = ndoc.createRange()
range_.setStart(node, 0)
range_.setEnd(node, 0)
else:
rects = v'[]'
for v'var i = 0; i < 2; i++':
# Try reducing the offset by 1 if we get no match as if it refers to the position after the
# last character we wont get a match with getClientRects
offset = r.offset - i
if offset < 0:
offset = 0
k = 0
while not rects?.length and k < try_list.length:
t = try_list[k]
k += 1
start_offset = offset + t.start
end_offset = offset + t.end
a = t.a
if start_offset < 0 or end_offset >= node_len:
continue
range_.setStart(node, start_offset)
range_.setEnd(node, end_offset)
rects = range_.getClientRects()
if not rects?.length: # Check for special case: End of range offset, after the last character
print(str.format("Could not find caret position for {} : rects: {} offset: {}", cfi, rects, r.offset)) offset = decoded.offset
return None position_at_end_of_range = False
if offset == node_len:
offset -= 1
position_at_end_of_range = True
else: range_.setStart(node, offset)
x, y = r.x, r.y range_.setEnd(node, offset + 1)
rect = range_.getBoundingClientRect()
return {'x':x, 'y':y, 'node':r.node, 'time':r.time, 'range':range_, 'a':a} if not rect:
print(str.format("Could not find caret position for {} (offset: {})", cfi, decoded.offset))
range_ = None
# Augment decoded with range, if found
decoded.range = range_
decoded.use_range_end_pos = position_at_end_of_range
return decoded
# }}} # }}}
def scroll_to(cfi, callback, doc): # {{{ # It's only valid to call this if you have a decoded CFI with a range included.
doc = doc or window.doc # Call decoded_to_document_position if you're not sure.
point_ = point(cfi, doc) def decoded_range_to_document_position(decoded):
if not point_: # Character offset
print("No point found for cfi: " + cfi) # Get the bounding rect of the range, in (real) viewport space
return rect = decoded.range.getBoundingClientRect()
if jstype(point_.time) is 'number':
set_current_time(point_.node, point_.time)
if point_.range is not None: # Now, get the viewport-space position (vs_pos) we want to scroll to
# This is a little complicated.
# The box we are using is a character's bounding box.
# First, the inline direction.
# We want to use the beginning of the box per the ePub CFI spec,
# which states character offset CFIs refer to the beginning of the
# character, which would be in the inline direction.
inline_vs_pos = scroll_viewport.rect_inline_start(rect)
# Unless the flag is set to use the end of the range:
# (because ranges indices may not exceed the range,
# but CFI offets are allowed to be one past the end.)
if decoded.use_range_end_pos:
inline_vs_pos = scroll_viewport.rect_inline_end(rect)
# Next, the block direction.
# If the CFI specifies a side bias, we should respect that.
# Otherwise, we want to use the block start.
if decoded.forward:
block_vs_pos = scroll_viewport.rect_block_end(rect)
else:
block_vs_pos = scroll_viewport.rect_block_start(rect)
# Now, we need to convert these to document X and Y coordinates.
return scroll_viewport.viewport_to_document_inline_block(inline_vs_pos, block_vs_pos, decoded.node.ownerDocument)
# This will work on a decoded CFI that refers to a node or node with spacial offset.
# It will ignore any ranges, so call decoded_to_document_position unless you're sure the decoded CFI has no range.
def decoded_node_or_spacial_offset_to_document_position(decoded):
node = decoded.node
rect = node.getBoundingClientRect()
percentx, percenty = decoded.x, decoded.y
# If we have a spacial offset, base the CFI position on the offset into the object
if jstype(percentx) is 'number' and node.offsetWidth and jstype(percenty) is 'number' and node.offsetHeight:
viewx = rect.left + (percentx*node.offsetWidth)/100
viewy = rect.top + (percenty*node.offsetHeight)/100
doc_left, doc_top = scroll_viewport.viewport_to_document(viewx, viewy, node.ownerDocument)
return doc_left, doc_top
# Otherwise, base it on the inline and block start and end, along with side bias:
else:
if decoded.forward:
block_vs_pos = scroll_viewport.rect_block_end(rect)
else:
block_vs_pos = scroll_viewport.rect_block_start(rect)
inline_vs_pos = scroll_viewport.rect_inline_start(rect)
return scroll_viewport.viewport_to_document_inline_block(inline_vs_pos, block_vs_pos, node.ownerDocument)
# This will take a decoded CFI and return a document position.
#
def decoded_to_document_position(decoded):
node = decoded.node
if decoded.range is not None:
return decoded_range_to_document_position(decoded)
else if node is not None and node.getBoundingClientRect:
return decoded_node_or_spacial_offset_to_document_position(decoded)
# No range, so we can't use that, and no node, so any spacial offset is meaningless
else:
return None, None
def scroll_to(cfi, callback, doc): # {{{
decoded = decode_with_range(cfi, doc)
if not decoded:
print("No information found for cfi: " + cfi)
return
if jstype(decoded.time) is 'number':
set_current_time(decoded.node, decoded.time)
if decoded.range is not None:
# Character offset # Character offset
r = point_.range r = decoded.range
so, eo, sc, ec = r.startOffset, r.endOffset, r.startContainer, r.endContainer so, eo = r.startOffset, r.endOffset
node = r.startContainer original_node = r.startContainer
ndoc = node.ownerDocument
# Save the node index and its parent so we can get the node back
# after removing the span and normalizing the parent.
node_parent = original_node.parentNode
original_node_index = 0
for child_node in node_parent.childNodes:
if child_node is original_node:
break
else:
++original_node_index
ndoc = original_node.ownerDocument
span = ndoc.createElement('span') span = ndoc.createElement('span')
span.setAttribute('style', 'border-width: 0; padding: 0; margin: 0') span.setAttribute('style', 'border-width: 0; padding: 0; margin: 0')
r.surroundContents(span) r.surroundContents(span)
@ -622,15 +714,6 @@ def scroll_to(cfi, callback, doc): # {{{
fn = def(): fn = def():
# Remove the span and get the new position now that scrolling # Remove the span and get the new position now that scrolling
# has (hopefully) completed # has (hopefully) completed
#
# In WebKit, the boundingrect of the span is wrong in some
# situations, whereas in IE resetting the range causes it to
# loose bounding info. So we use the range's rects unless they
# are absent, in which case we use the span's rect
#
rect = span.getBoundingClientRect()
# Remove the span we inserted
p = span.parentNode p = span.parentNode
for node in span.childNodes: for node in span.childNodes:
span.removeChild(node) span.removeChild(node)
@ -642,7 +725,7 @@ def scroll_to(cfi, callback, doc): # {{{
offset = so offset = so
while offset > -1: while offset > -1:
try: try:
r.setStart(sc, offset) decoded.range.setStart(node_parent.childNodes[original_node_index], offset)
break break
except: except:
offset -= 1 offset -= 1
@ -650,36 +733,49 @@ def scroll_to(cfi, callback, doc): # {{{
offset = eo offset = eo
while offset > -1: while offset > -1:
try: try:
r.setEnd(ec, offset) decoded.range.setEnd(node_parent.childNodes[original_node_index], offset)
break break
except: except:
offset -= 1 offset -= 1
rects = r.getClientRects() doc_x, doc_y = decoded_range_to_document_position(decoded)
if rects.length > 0:
rect = rects[0] # Abort if CFI position is invalid
if doc_x is None or doc_y is None:
return
# Since this position will be used for the upper-left of the viewport,
# in RTL mode, we need to offset it by the viewport width so the
# position will be on the right side of the viewport,
# from where we start reading.
# (Note that adding moves left in RTL mode because of the viewport X
# coordinate reversal in RTL mode.)
if scroll_viewport.rtl:
doc_x += scroll_viewport.width()
x = (point_.a*rect.left + (1-point_.a)*rect.right)
y = (rect.top + rect.bottom)/2
x, y = scroll_viewport.viewport_to_document(x, y, ndoc)
if callback: if callback:
callback(x, y) callback(doc_x, doc_y)
else: else:
node = point_.node node = decoded.node
scroll_viewport.scroll_into_view(node) scroll_viewport.scroll_into_view(node)
fn = def(): fn = def():
r = node.getBoundingClientRect() doc_x, doc_y = decoded_node_or_spacial_offset_to_document_position(decoded)
# Start of element is right side in RTL, so be sure to get that side in RTL mode
x, y = scroll_viewport.viewport_to_document( # Abort if CFI position is invalid
r.left if scroll_viewport.ltr else r.right, r.top, node.ownerDocument) if doc_x is None or doc_y is None:
if jstype(point_.x) is 'number' and node.offsetWidth: return
x += (point_.x*node.offsetWidth)/100
if jstype(point_.y) is 'number' and node.offsetHeight: # Since this position will be used for the upper-left of the viewport,
y += (point_.y*node.offsetHeight)/100 # in RTL mode, we need to offset it by the viewport width so the
scroll_viewport.scroll_to(x, y) # position will be on the right side of the viewport,
# from where we start reading.
# (Note that adding moves left in RTL mode because of the viewport X
# coordinate reversal in RTL mode.)
if scroll_viewport.rtl:
doc_x += scroll_viewport.width()
if callback: if callback:
callback(x, y) callback(doc_x, doc_y)
setTimeout(fn, 10) setTimeout(fn, 10)
@ -694,74 +790,108 @@ def at_point(ox, oy): # {{{
def dist(p1, p2): def dist(p1, p2):
Math.sqrt(Math.pow(p1[0]-p2[0], 2), Math.pow(p1[1]-p2[1], 2)) Math.sqrt(Math.pow(p1[0]-p2[0], 2), Math.pow(p1[1]-p2[1], 2))
try: cfi = at(ox, oy)
cfi = at(ox, oy) if cfi is None:
p = point(cfi) return None
except Exception:
cfi = None
if not p: decoded = decode_with_range(cfi)
if not decoded:
return None return None
if cfi: if cfi:
if p.range is not None: cfix, cfiy = decoded_to_document_position(decoded)
r = p.range if cfix is None or cfiy is None or dist(scroll_viewport.viewport_to_document(ox, oy), v'[cfix, cfiy]') > 50:
rect = r.getClientRects()[0]
x = (p.a*rect.left + (1-p.a)*rect.right)
y = (rect.top + rect.bottom)/2
x, y = scroll_viewport.viewport_to_document(x, y, r.startContainer.ownerDocument)
else:
node = p.node
r = node.getBoundingClientRect()
# Start of element is right side in RTL, so be sure to get that side in RTL mode
x, y = scroll_viewport.viewport_to_document(
r.left if scroll_viewport.ltr else r.right, r.top, node.ownerDocument)
if jstype(p.x) is 'number' and node.offsetWidth:
x += (p.x*node.offsetWidth)/100
if jstype(p.y) is 'number' and node.offsetHeight:
y += (p.y*node.offsetHeight)/100
if dist(scroll_viewport.viewport_to_document(ox, oy), v'[x, y]') > 50:
cfi = None cfi = None
return cfi return cfi
# }}} # }}}
def at_current(): # {{{ def at_current(): # {{{
winx, winy = window_scroll_pos() wini, winb = scroll_viewport.inline_size(), scroll_viewport.block_size()
winw, winh = scroll_viewport.width(), scroll_viewport.height()
winw = max(winw, 400)
winh = max(winh, 600)
deltay = Math.floor(winh/50)
deltax = Math.floor(winw/25)
miny = max(-winy, -winh)
maxy = winh
minx = max(-winx, -winw)
maxx = winw
def x_loop(cury): # We subtract one because the the actual position query for CFI elements is relative to the
for direction in v'[-1, 1]': # viewport, and the viewport coordinates actually go from 0 to the size - 1.
delta = deltax * direction # If we don't do this, the first line of queries will always fail in those cases where we
curx = 0 # start at the right of the viewport because they'll be outside the viewport
while not ((direction < 0 and curx < minx) or (direction > 0 and curx > maxx)): wini -= 1
cfi = at_point(curx, cury) winb -= 1
if cfi:
return cfi
curx += delta
for direction in v'[-1, 1]': # Don't let the deltas go below 10 or above 30, to prevent perverse cases where
delta = deltay * direction # we don't loop at all, loop too many times, or skip elements because we're
cury = 0 # looping too fast.
while not( (direction < 0 and cury < miny) or (direction > 0 and cury > maxy) ): deltai = min(max(Math.ceil(rem_size() / 2), 5), 30)
cfi = x_loop(cury, -1) deltab = min(max(Math.ceil(rem_size() / 2), 5), 30)
# i.e. English, Hebrew:
if scroll_viewport.horizontal_writing_mode:
# Horizontal languages always start at the top
startb = 0
endb = winb
if scroll_viewport.ltr:
# i.e. English: we scan from the left margin to the right margin
starti = 0
endi = wini
else:
# i.e. Hebrew: we scan from the right margin to the left margin
starti = wini
endi = 0
deltai = -deltai
# i.e. Japanese, Mongolian script, traditional Chinese
else:
# These languages are only top-to-bottom
starti = 0
endi = winb
# i.e. Japanese: To the next line is from the right margin to the left margin
if scroll_viewport.rtl:
startb = winb
endb = 0
deltab = -deltab
# i.e. Mongolian: To the next line is from the left margin to the right margin
else:
startb = 0
endb = winb
# print(f'{starti} {deltai} {endi} {startb} {deltab} {endb}')
# Find the bounds
up_boundb = max(startb, endb)
up_boundi = max(starti, endi)
low_boundb = min(startb, endb)
low_boundi = min(starti, endi)
# In horizontal mode, X is the inline and Y is the block,
# but it's reversed for vertical modes, so reverse the lookup.
def at_point_vertical_mode(i, b):
return at_point(b, i)
at_point_conditional = at_point
if scroll_viewport.vertical_writing_mode:
at_point_conditional = at_point_vertical_mode
def i_loop(curb):
curi = starti
# The value will be either heading toward the lower or the upper bound,
# so just check for exceeding either bound
while low_boundi <= curi <= up_boundi:
cfi = at_point_conditional(curi, curb)
if cfi: if cfi:
# print(f'found CFI at {curi}, {curb} {cfi}\n\t{starti} {deltai} {endi} {startb} {deltab} {endb}')
return cfi return cfi
cury += delta curi += deltai
curb = startb
while low_boundb <= curb <= up_boundb:
cfi = i_loop(curb)
if cfi:
break
curb += deltab
if cfi:
return cfi
# Use a spatial offset on the html element, since we could not find a # Use a spatial offset on the html element, since we could not find a
# normal CFI # normal CFI
x, y = window_scroll_pos() x, y = scroll_viewport.x() + (0 if scroll_viewport.ltr else scroll_viewport.width()), scroll_viewport.y()
de = document.documentElement de = document.documentElement
rect = de.getBoundingClientRect() rect = de.getBoundingClientRect()
px = (x*100)/rect.width px = (x*100)/rect.width

View File

@ -2,15 +2,33 @@
# License: GPL v3 Copyright: 2016, Kovid Goyal <kovid at kovidgoyal.net> # License: GPL v3 Copyright: 2016, Kovid Goyal <kovid at kovidgoyal.net>
from __python__ import bound_methods, hash_literals from __python__ import bound_methods, hash_literals
# Notes about flow mode scrolling:
# All the math in flow mode is based on the block and inline directions.
# Inline is "the direction lines of text go."
# In horizontal scripts such as English and Hebrew, the inline is horizontal
# and the block is vertical.
# In vertical languages such as Japanese and Mongolian, the inline is vertical
# and block is horizontal.
# Regardless of language, flow mode scrolls in the block direction.
#
# In vertical RTL books, the block position scrolls from right to left. |<------|
# This means that the viewport positions become negative as you scroll.
# This is hidden from flow mode by the viewport, which transparently
# negates any inline coordinates sent in to the viewport_to_document* functions
# and the various scroll_to/scroll_by functions, as well as the reported X position.
#
# The result of all this is that flow mode's math can safely pretend that
# things scroll in the positive block direction.
from dom import set_css from dom import set_css
from read_book.cfi import scroll_to as cfi_scroll_to
from read_book.globals import current_spine_item, get_boss, rtl_page_progression, ltr_page_progression from read_book.globals import current_spine_item, get_boss, rtl_page_progression, ltr_page_progression
from read_book.settings import opts from read_book.settings import opts
from read_book.viewport import line_height, scroll_viewport from read_book.viewport import line_height, rem_size, scroll_viewport
from utils import document_height
def flow_to_scroll_fraction(frac, on_initial_load): def flow_to_scroll_fraction(frac, on_initial_load):
scroll_viewport.scroll_to(0, document_height() * frac) scroll_viewport.scroll_to_in_block_direction(scroll_viewport.document_block_size() * frac)
small_scroll_events = v'[]' small_scroll_events = v'[]'
@ -31,7 +49,7 @@ def dispatch_small_scrolls():
for x in small_scroll_events: for x in small_scroll_events:
amt += x.amt amt += x.amt
clear_small_scrolls() clear_small_scrolls()
get_boss().report_human_scroll(amt / document_height()) get_boss().report_human_scroll(amt / scroll_viewport.document_block_size())
def add_small_scroll(amt): def add_small_scroll(amt):
@ -45,7 +63,7 @@ def report_human_scroll(amt):
if amt > 0: if amt > 0:
if is_large_scroll: if is_large_scroll:
clear_small_scrolls() clear_small_scrolls()
get_boss().report_human_scroll(amt / document_height()) get_boss().report_human_scroll(amt / scroll_viewport.document_block_size())
else: else:
add_small_scroll(amt) add_small_scroll(amt)
elif amt is 0 or is_large_scroll: elif amt is 0 or is_large_scroll:
@ -56,13 +74,13 @@ last_change_spine_item_request = {}
def _check_for_scroll_end(func, obj, args, report): def _check_for_scroll_end(func, obj, args, report):
before = window.pageYOffset before = scroll_viewport.block_pos()
should_flip_progression_direction = func.apply(obj, args) should_flip_progression_direction = func.apply(obj, args)
now = window.performance.now() now = window.performance.now()
scroll_animator.sync(now) scroll_animator.sync(now)
if window.pageYOffset is before: if scroll_viewport.block_pos() is before:
csi = current_spine_item() csi = current_spine_item()
if last_change_spine_item_request.name is csi.name and now - last_change_spine_item_request.at < 2000: if last_change_spine_item_request.name is csi.name and now - last_change_spine_item_request.at < 2000:
return False return False
@ -74,7 +92,7 @@ def _check_for_scroll_end(func, obj, args, report):
get_boss().send_message('next_spine_item', previous=go_to_previous_page) get_boss().send_message('next_spine_item', previous=go_to_previous_page)
return False return False
if report: if report:
report_human_scroll(window.pageYOffset - before) report_human_scroll(scroll_viewport.block_pos() - before)
return True return True
@ -89,45 +107,63 @@ def check_for_scroll_end_and_report(func):
@check_for_scroll_end_and_report @check_for_scroll_end_and_report
def scroll_by(y): def scroll_by_and_check_next_page(y):
window.scrollBy(0, y) scroll_viewport.scroll_by_in_block_direction(y)
# This indicates to check_for_scroll_end_and_report that it should not # This indicates to check_for_scroll_end_and_report that it should not
# flip the page progression direction. # flip the page progression direction.
return False return False
def flow_onwheel(evt): def flow_onwheel(evt):
dx = dy = 0 di = db = 0
WheelEvent = window.WheelEvent WheelEvent = window.WheelEvent
# Y deltas always scroll in the previous and next page direction,
# regardless of writing direction, since doing otherwise would
# make mouse wheels mostly useless for scrolling in books written
# vertically.
if evt.deltaY: if evt.deltaY:
if evt.deltaMode is WheelEvent.DOM_DELTA_PIXEL: if evt.deltaMode is WheelEvent.DOM_DELTA_PIXEL:
dy = evt.deltaY db = evt.deltaY
elif evt.deltaMode is WheelEvent.DOM_DELTA_LINE: elif evt.deltaMode is WheelEvent.DOM_DELTA_LINE:
dy = line_height() * evt.deltaY db = line_height() * evt.deltaY
if evt.deltaMode is WheelEvent.DOM_DELTA_PAGE: if evt.deltaMode is WheelEvent.DOM_DELTA_PAGE:
dy = (scroll_viewport.height() - 30) * evt.deltaY db = (scroll_viewport.block_size() - 30) * evt.deltaY
# X deltas scroll horizontally in both horizontal and vertical books.
# It's more natural in both cases.
if evt.deltaX: if evt.deltaX:
if evt.deltaMode is WheelEvent.DOM_DELTA_PIXEL: if evt.deltaMode is WheelEvent.DOM_DELTA_PIXEL:
dx = evt.deltaX dx = evt.deltaX
elif evt.deltaMode is WheelEvent.DOM_DELTA_LINE: elif evt.deltaMode is WheelEvent.DOM_DELTA_LINE:
dx = 15 * evt.deltaX dx = line_height() * evt.deltaX
else: else:
dx = (scroll_viewport.width() - 30) * evt.deltaX dx = (scroll_viewport.block_size() - 30) * evt.deltaX
if dx:
window.scrollBy(dx, 0) if scroll_viewport.horizontal_writing_mode:
elif Math.abs(dy) >= 1: di = dx
scroll_by(dy) else:
# Left goes forward, so make sure left is positive and right is negative,
# which is the opposite of what the wheel sends.
if scroll_viewport.rtl:
db = -dx
# Right goes forward, so the sign is correct.
else:
db = dx
if di:
scroll_viewport.scroll_by_in_inline_direction(di)
elif Math.abs(db) >= 1:
scroll_by_and_check_next_page(db)
@check_for_scroll_end @check_for_scroll_end
def goto_boundary(dir): def goto_boundary(dir):
scroll_viewport.scroll_to(scroll_viewport.x(), 0 if dir is DIRECTION.Up else document_height()) position = 0 if dir is DIRECTION.Up else scroll_viewport.document_block_size()
scroll_viewport.scroll_to_in_block_direction(position)
get_boss().report_human_scroll() get_boss().report_human_scroll()
return False return False
@check_for_scroll_end_and_report @check_for_scroll_end_and_report
def scroll_by_page(direction, flip_if_rtl_page_progression): def scroll_by_page(direction, flip_if_rtl_page_progression):
h = scroll_viewport.height() - 10 b = scroll_viewport.block_size() - 10
window.scrollBy(0, h * direction) scroll_viewport.scroll_by_in_block_direction(b * direction)
# Let check_for_scroll_end_and_report know whether or not it should flip # Let check_for_scroll_end_and_report know whether or not it should flip
# the progression direction. # the progression direction.
@ -179,10 +215,10 @@ def handle_shortcut(sc_name, evt):
goto_boundary(DIRECTION.Down) goto_boundary(DIRECTION.Down)
return True return True
if sc_name is 'left': if sc_name is 'left':
window.scrollBy(-15 if ltr_page_progression() else 15, 0) scroll_by_and_check_next_page(-15 if ltr_page_progression() else 15, 0)
return True return True
if sc_name is 'right': if sc_name is 'right':
window.scrollBy(15 if ltr_page_progression() else -15, 0) scroll_by_and_check_next_page(15 if ltr_page_progression() else -15, 0)
return True return True
if sc_name is 'start_of_book': if sc_name is 'start_of_book':
get_boss().send_message('goto_doc_boundary', start=True) get_boss().send_message('goto_doc_boundary', start=True)
@ -191,10 +227,10 @@ def handle_shortcut(sc_name, evt):
get_boss().send_message('goto_doc_boundary', start=False) get_boss().send_message('goto_doc_boundary', start=False)
return True return True
if sc_name is 'pageup': if sc_name is 'pageup':
scroll_by_page(-1, flip_if_rtl_page_progression=False) scroll_by_page(-1, False)
return True return True
if sc_name is 'pagedown': if sc_name is 'pagedown':
scroll_by_page(1, flip_if_rtl_page_progression=False) scroll_by_page(1, False)
return True return True
if sc_name is 'toggle_autoscroll': if sc_name is 'toggle_autoscroll':
toggle_autoscroll() toggle_autoscroll()
@ -208,10 +244,16 @@ def handle_shortcut(sc_name, evt):
def layout(is_single_page): def layout(is_single_page):
line_height(True) line_height(True)
rem_size(True)
set_css(document.body, margin='0', border_width='0', padding='0') set_css(document.body, margin='0', border_width='0', padding='0')
# flow mode does not care about RTL vs LTR body_style = window.getComputedStyle(document.body)
scroll_viewport.initialize_on_layout({'direction': 'ltr'}) # scroll viewport needs to know if we're in vertical mode,
# since that will cause scrolling to happen left and right
scroll_viewport.initialize_on_layout(body_style)
document.documentElement.style.overflow = 'hidden'
if scroll_viewport.vertical_writing_mode:
document.documentElement.style.overflow = 'visible'
def auto_scroll_resume(): def auto_scroll_resume():
scroll_animator.wait = False scroll_animator.wait = False
@ -232,7 +274,7 @@ def cancel_scroll():
def is_scroll_end(pos): def is_scroll_end(pos):
return not (0 <= pos <= document_height() - window.innerHeight) return not (0 <= pos <= scroll_viewport.document_block_size() - scroll_viewport.block_size())
DIRECTION = {'Up': -1, 'up': -1, 'Down': 1, 'down': 1, 'UP': -1, 'DOWN': 1} DIRECTION = {'Up': -1, 'up': -1, 'Down': 1, 'down': 1, 'UP': -1, 'DOWN': 1}
@ -269,7 +311,7 @@ class ScrollAnimator:
self.auto = auto self.auto = auto
self.direction = direction self.direction = direction
self.start_time = now self.start_time = now
self.start_offset = window.pageYOffset self.start_offset = scroll_viewport.block_pos()
self.csi_idx = current_spine_item().index self.csi_idx = current_spine_item().index
self.animation_id = window.requestAnimationFrame(self.auto_scroll if auto else self.smooth_scroll) self.animation_id = window.requestAnimationFrame(self.auto_scroll if auto else self.smooth_scroll)
@ -282,8 +324,8 @@ class ScrollAnimator:
scroll_target = self.start_offset scroll_target = self.start_offset
scroll_target += Math.trunc(self.direction * progress * duration * line_height() * opts.lines_per_sec_smooth) / 1000 scroll_target += Math.trunc(self.direction * progress * duration * line_height() * opts.lines_per_sec_smooth) / 1000
window.scrollTo(0, scroll_target) scroll_viewport.scroll_to_in_block_direction(scroll_target)
amt = window.pageYOffset - self.start_offset amt = scroll_viewport.block_pos() - self.start_offset
if is_scroll_end(scroll_target) and (not opts.scroll_stop_boundaries or (abs(amt) < 3 and duration is self.DURATION)): if is_scroll_end(scroll_target) and (not opts.scroll_stop_boundaries or (abs(amt) < 3 and duration is self.DURATION)):
# "Turn the page" if stop at boundaries option is false or # "Turn the page" if stop at boundaries option is false or
@ -305,7 +347,7 @@ class ScrollAnimator:
scroll_target = self.start_offset scroll_target = self.start_offset
scroll_target += Math.trunc(self.direction * elapsed * line_height() * opts.lines_per_sec_auto) / 1000 scroll_target += Math.trunc(self.direction * elapsed * line_height() * opts.lines_per_sec_auto) / 1000
window.scrollTo(0, scroll_target) scroll_viewport.scroll_to_in_block_direction(scroll_target)
scroll_finished = is_scroll_end(scroll_target) scroll_finished = is_scroll_end(scroll_target)
# report every second # report every second
@ -324,7 +366,7 @@ class ScrollAnimator:
get_boss().send_message('next_spine_item', previous=self.direction is DIRECTION.Up) get_boss().send_message('next_spine_item', previous=self.direction is DIRECTION.Up)
def report(self): def report(self):
amt = window.pageYOffset - self.start_offset amt = scroll_viewport.block_pos() - self.start_offset
if abs(amt) > 0 and self.csi_idx is current_spine_item().index: if abs(amt) > 0 and self.csi_idx is current_spine_item().index:
report_human_scroll(amt) report_human_scroll(amt)
@ -333,7 +375,7 @@ class ScrollAnimator:
self.report() self.report()
self.csi_idx = current_spine_item().index self.csi_idx = current_spine_item().index
self.start_time = ts or window.performance.now() self.start_time = ts or window.performance.now()
self.start_offset = window.pageYOffset self.start_offset = scroll_viewport.block_pos()
else: else:
self.resume() self.resume()
@ -440,7 +482,7 @@ class DragScroller:
progress = max(0, min(1, (ts - self.start_time) / duration)) # max/min to account for jitter progress = max(0, min(1, (ts - self.start_time) / duration)) # max/min to account for jitter
scroll_target = self.start_offset scroll_target = self.start_offset
scroll_target += Math.trunc(self.direction * progress * duration * line_height() * opts.lines_per_sec_smooth * self.speed_factor) / 1000 scroll_target += Math.trunc(self.direction * progress * duration * line_height() * opts.lines_per_sec_smooth * self.speed_factor) / 1000
window.scrollTo(0, scroll_target) scroll_viewport.scroll_to_in_block_direction(scroll_target)
if progress < 1: if progress < 1:
self.animation_id = window.requestAnimationFrame(self.smooth_scroll) self.animation_id = window.requestAnimationFrame(self.smooth_scroll)
@ -456,7 +498,7 @@ class DragScroller:
self.direction = direction self.direction = direction
self.speed_factor = speed_factor self.speed_factor = speed_factor
self.start_time = now self.start_time = now
self.start_offset = window.pageYOffset self.start_offset = scroll_viewport.block_pos()
self.animation_id = window.requestAnimationFrame(self.smooth_scroll) self.animation_id = window.requestAnimationFrame(self.smooth_scroll)
def stop(self): def stop(self):
@ -487,15 +529,31 @@ def handle_gesture(gesture):
delta = gesture.points[-2] - gesture.points[-1] delta = gesture.points[-2] - gesture.points[-1]
if Math.abs(delta) >= 1: if Math.abs(delta) >= 1:
if gesture.axis is 'vertical': if gesture.axis is 'vertical':
scroll_by(delta) # Vertical writing scrolls left and right,
# so doing a vertical flick shouldn't change pages.
if scroll_viewport.vertical_writing_mode:
scroll_viewport.scroll_by(delta, 0)
# However, it might change pages in horizontal writing
else:
scroll_by_and_check_next_page(delta)
else: else:
window.scrollBy(delta, 0) # A horizontal flick should check for new pages in
# vertical modes, since they flow left and right.
if scroll_viewport.vertical_writing_mode:
scroll_by_and_check_next_page(delta)
# In horizontal modes, just move by the delta.
else:
scroll_viewport.scroll_by(delta, 0)
if not gesture.active and not gesture.is_held: if not gesture.active and not gesture.is_held:
flick_animator.start(gesture) flick_animator.start(gesture)
elif gesture.type is 'prev-page': elif gesture.type is 'prev-page':
scroll_by_page(-1, flip_if_rtl_page_progression=False) # should flip = False - previous is previous whether RTL or LTR.
# flipping of this command is handled higher up
scroll_by_page(-1, False)
elif gesture.type is 'next-page': elif gesture.type is 'next-page':
scroll_by_page(1, flip_if_rtl_page_progression=False) # should flip = False - next is next whether RTL or LTR.
# flipping of this command is handled higher up
scroll_by_page(1, False)
anchor_funcs = { anchor_funcs = {
@ -503,22 +561,28 @@ anchor_funcs = {
if not elem: if not elem:
return 0, 0 return 0, 0
br = elem.getBoundingClientRect() br = elem.getBoundingClientRect()
# Elements start on the right side in RTL mode,
# so be sure to return that side if in RTL. # Start of object in the scrolling direction
x, y = scroll_viewport.viewport_to_document( return scroll_viewport.viewport_to_document_block(
br.left if scroll_viewport.ltr else br.right, scroll_viewport.rect_block_start(br), elem.ownerDocument)
br.top, elem.ownerDocument)
return y, x
, ,
'visibility': def visibility(pos): 'visibility': def visibility(pos):
y, x = pos x, y = pos
if y < window.pageYOffset:
if jstype(x) is 'number':
pos = x
if jstype(y) is 'number' and scroll_viewport.horizontal_writing_mode:
pos = y
# Have to negate X if in RTL for the math to be correct,
# as the value that the scroll viewport returns is negated
if scroll_viewport.vertical_writing_mode and scroll_viewport.rtl:
pos = -pos
if pos < scroll_viewport.block_pos():
return -1 return -1
if y < window.pageYOffset + scroll_viewport.height(): if pos <= scroll_viewport.block_pos() + scroll_viewport.block_size():
if x < window.pageXOffset: return 0
return -1
if x < window.pageXOffset + scroll_viewport.width():
return 0
return 1 return 1
, ,
'cmp': def cmp(a, b): 'cmp': def cmp(a, b):
@ -551,3 +615,15 @@ def ensure_selection_visible():
p.scrollIntoView() p.scrollIntoView()
return return
p = p.parentNode p = p.parentNode
def jump_to_cfi(cfi):
# Jump to the position indicated by the specified conformal fragment
# indicator.
cfi_scroll_to(cfi, def(x, y):
# block is vertical if text is horizontal
if scroll_viewport.horizontal_writing_mode:
scroll_viewport.scroll_to_in_block_direction(y)
# block is horizontal if text is vertical
else:
scroll_viewport.scroll_to_in_block_direction(x)
)

View File

@ -24,7 +24,8 @@ from read_book.flow_mode import (
handle_shortcut as flow_handle_shortcut, layout as flow_layout, handle_shortcut as flow_handle_shortcut, layout as flow_layout,
scroll_by_page as flow_scroll_by_page, scroll_by_page as flow_scroll_by_page,
scroll_to_extend_annotation as flow_annotation_scroll, scroll_to_extend_annotation as flow_annotation_scroll,
start_drag_scroll as start_drag_scroll_flow start_drag_scroll as start_drag_scroll_flow,
jump_to_cfi as flow_jump_to_cfi
) )
from read_book.footnotes import is_footnote_link from read_book.footnotes import is_footnote_link
from read_book.globals import ( from read_book.globals import (
@ -237,7 +238,7 @@ class IframeBoss:
self.handle_navigation_shortcut = flow_handle_shortcut self.handle_navigation_shortcut = flow_handle_shortcut
self._handle_gesture = flow_handle_gesture self._handle_gesture = flow_handle_gesture
self.to_scroll_fraction = flow_to_scroll_fraction self.to_scroll_fraction = flow_to_scroll_fraction
self.jump_to_cfi = scroll_to_cfi self.jump_to_cfi = flow_jump_to_cfi
self.anchor_funcs = flow_anchor_funcs self.anchor_funcs = flow_anchor_funcs
self.auto_scroll_action = flow_auto_scroll_action self.auto_scroll_action = flow_auto_scroll_action
self.scroll_to_extend_annotation = flow_annotation_scroll self.scroll_to_extend_annotation = flow_annotation_scroll
@ -361,7 +362,7 @@ class IframeBoss:
if self.last_cfi: if self.last_cfi:
cfi = self.last_cfi[len('epubcfi(/'):-1].partition('/')[2] cfi = self.last_cfi[len('epubcfi(/'):-1].partition('/')[2]
if cfi: if cfi:
paged_jump_to_cfi('/' + cfi) self.jump_to_cfi('/' + cfi)
self.update_cfi() self.update_cfi()
self.update_toc_position() self.update_toc_position()
@ -442,7 +443,6 @@ class IframeBoss:
si = spine[i] si = spine[i]
if si: if si:
self.length_before += files[si]?.length or 0 self.length_before += files[si]?.length or 0
self.onscroll()
self.send_message('content_loaded', progress_frac=self.calculate_progress_frac(), file_progress_frac=progress_frac()) self.send_message('content_loaded', progress_frac=self.calculate_progress_frac(), file_progress_frac=progress_frac())
self.last_cfi = None self.last_cfi = None
self.auto_scroll_action('resume') self.auto_scroll_action('resume')
@ -547,7 +547,7 @@ class IframeBoss:
if self.last_cfi: if self.last_cfi:
cfi = self.last_cfi[len('epubcfi(/'):-1].partition('/')[2] cfi = self.last_cfi[len('epubcfi(/'):-1].partition('/')[2]
if cfi: if cfi:
paged_jump_to_cfi('/' + cfi) self.jump_to_cfi('/' + cfi)
if current_layout_mode() is not 'flow': if current_layout_mode() is not 'flow':
paged_resize_done() paged_resize_done()
self.update_cfi() self.update_cfi()

View File

@ -1,5 +1,26 @@
# vim:fileencoding=utf-8 # vim:fileencoding=utf-8
# License: GPL v3 Copyright: 2016, Kovid Goyal <kovid at kovidgoyal.net> # License: GPL v3 Copyright: 2016, Kovid Goyal <kovid at kovidgoyal.net>
# Notes of paged mode scrolling:
# All the math in paged mode is based on the block and inline directions.
# Inline is "the direction lines of text go."
# In horizontal scripts such as English and Hebrew, the inline is horizontal
# and the block is vertical.
# In vertical languages such as Japanese and Mongolian, the inline is vertical
# and block is horizontal.
# Regardless of language, paged mode scrolls by column in the inline direction,
# because by the CSS spec, columns are laid out in the inline direction.
#
# In horizontal RTL books, such as Hebrew, the inline direction goes right to left.
# |<------|
# This means that the column positions become negative as you scroll.
# This is hidden from paged mode by the viewport, which transparently
# negates any inline coordinates sent in to the viewport_to_document* functions
# and the various scroll_to/scroll_by functions, as well as the reported X position.
#
# The result of all this is that paged mode's math can safely pretend that
# things scroll in the positive inline direction.
from __python__ import hash_literals from __python__ import hash_literals
import traceback import traceback
@ -13,9 +34,9 @@ from read_book.cfi import (
) )
from read_book.globals import current_spine_item, get_boss, rtl_page_progression from read_book.globals import current_spine_item, get_boss, rtl_page_progression
from read_book.settings import opts from read_book.settings import opts
from read_book.viewport import scroll_viewport, line_height from read_book.viewport import scroll_viewport, line_height, rem_size
from utils import ( from utils import (
document_height, document_width, get_elem_data, set_elem_data get_elem_data, set_elem_data
) )
@ -54,65 +75,76 @@ def in_paged_mode():
return _in_paged_mode return _in_paged_mode
col_width = screen_width = screen_height = cols_per_screen = gap = col_and_gap = number_of_cols = last_scrolled_to_column = 0 col_size = screen_inline = screen_block = cols_per_screen = gap = col_and_gap = number_of_cols = last_scrolled_to_column = 0
is_full_screen_layout = False is_full_screen_layout = False
def reset_paged_mode_globals(): 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, last_scrolled_to_column nonlocal _in_paged_mode, col_size, col_and_gap, screen_block, gap, screen_inline, is_full_screen_layout, cols_per_screen, number_of_cols, last_scrolled_to_column
scroll_viewport.reset_globals() scroll_viewport.reset_globals()
col_width = screen_width = screen_height = cols_per_screen = gap = col_and_gap = number_of_cols = last_scrolled_to_column = 0 col_size = screen_inline = screen_block = cols_per_screen = gap = col_and_gap = number_of_cols = last_scrolled_to_column = 0
is_full_screen_layout = _in_paged_mode = False is_full_screen_layout = _in_paged_mode = False
resize_manager.reset() resize_manager.reset()
def column_at(xpos): def column_at(pos):
# Return the (zero-based) number of the column that contains xpos # Return the (zero-based) number of the column that contains pos
sw = scroll_viewport.paged_content_width() si = scroll_viewport.paged_content_inline_size()
if xpos >= sw - col_and_gap: if pos >= si - col_and_gap:
xpos = sw - col_width + 10 pos = si - col_size + 10
return (xpos + gap) // col_and_gap return (pos + gap) // col_and_gap
def fit_images(): def fit_images():
# Ensure no images are wider than the available width in a column. Note # Ensure no images are wider than the available size of a column. Note
# that this method use getBoundingClientRect() which means it will # that this method use getBoundingClientRect() which means it will
# force a relayout if the render tree is dirty. # force a relayout if the render tree is dirty.
images = v'[]' inline_limited_images = v'[]'
vimages = v'[]' block_limited_images = v'[]'
img_tags = document.getElementsByTagName('img') img_tags = document.getElementsByTagName('img')
bounding_rects = v'[]' bounding_rects = v'[]'
for img_tag in img_tags: for img_tag in img_tags:
bounding_rects.push(img_tag.getBoundingClientRect()) bounding_rects.push(img_tag.getBoundingClientRect())
maxh = screen_height maxb = screen_block
for i in range(img_tags.length): for i in range(img_tags.length):
img = img_tags[i] img = img_tags[i]
br = bounding_rects[i] br = bounding_rects[i]
previously_limited = get_elem_data(img, 'width-limited', False) previously_limited = get_elem_data(img, 'inline-limited', False)
data = get_elem_data(img, 'img-data', None) data = get_elem_data(img, 'img-data', None)
if data is None: if data is None:
data = {'left':br.left, 'right':br.right, 'height':br.height, 'display': img.style.display} data = {'left':br.left, 'right':br.right, 'height':br.height, 'display': img.style.display}
set_elem_data(img, 'img-data', data) set_elem_data(img, 'img-data', data)
left = scroll_viewport.viewport_to_document(br.left, 0, img.ownerDocument)[0]
col = column_at(left) * col_and_gap # Get start of image bounding box in the column direction (inline)
rleft = left - col image_start = scroll_viewport.viewport_to_document_inline(scroll_viewport.rect_inline_start(br), img.ownerDocument)
width = br.right - br.left col_start = column_at(image_start) * col_and_gap
rright = rleft + width # Get inline distance from the start of the column to the start of the image bounding box
if previously_limited or rright > col_width: column_start_to_image_start = image_start - col_start
images.push(v'[img, col_width - rleft]') image_block_size = scroll_viewport.rect_block_size(br)
previously_limited = get_elem_data(img, 'height-limited', False) image_inline_size = scroll_viewport.rect_inline_size(br)
if previously_limited or br.height > maxh or (br.height is maxh and br.width > col_width): # Get the inline distance from the start of the column to the end of the image
vimages.push(img) image_inline_end = column_start_to_image_start + image_inline_size
# If the end of the image goes past the column, add it to the list of inline_limited_images
if previously_limited or image_inline_end > col_size:
inline_limited_images.push(v'[img, col_size - column_start_to_image_start]')
previously_limited = get_elem_data(img, 'block-limited', False)
if previously_limited or image_block_size > maxb or (image_block_size is maxb and image_inline_size > col_size):
block_limited_images.push(img)
if previously_limited: if previously_limited:
set_css(img, break_before='auto', display=data.display) set_css(img, break_before='auto', display=data.display)
set_css(img, break_inside='avoid') set_css(img, break_inside='avoid')
for img_tag, max_width in images: for img_tag, max_inline_size in inline_limited_images:
img_tag.style.setProperty('max-width', max_width+'px') if scroll_viewport.vertical_writing_mode:
set_elem_data(img_tag, 'width-limited', True) img_tag.style.setProperty('max-height', max_inline_size+'px')
else:
img_tag.style.setProperty('max-width', max_inline_size+'px')
set_elem_data(img_tag, 'inline-limited', True)
for img_tag in vimages: for img_tag in block_limited_images:
data = get_elem_data(img_tag, 'img-data', None) if scroll_viewport.vertical_writing_mode:
set_css(img_tag, break_before='always', max_height='100vh') set_css(img_tag, break_before='always', max_width='100vw')
set_elem_data(img, 'height-limited', True) else:
set_css(img_tag, break_before='always', max_height='100vh')
set_elem_data(img_tag, 'block-limited', True)
def cps_by_em_size(): def cps_by_em_size():
@ -143,7 +175,7 @@ def calc_columns_per_screen():
except: except:
cps = 0 cps = 0
if not cps: if not cps:
cps = int(Math.floor(scroll_viewport.width() / (35 * cps_by_em_size()))) cps = int(Math.floor(scroll_viewport.inline_size() / (35 * cps_by_em_size())))
cps = max(1, min(cps or 1, 20)) cps = max(1, min(cps or 1, 20))
return cps return cps
@ -157,13 +189,10 @@ def will_columns_per_screen_change():
return calc_columns_per_screen() != cols_per_screen return calc_columns_per_screen() != cols_per_screen
def current_page_width():
return col_width
def layout(is_single_page, on_resize): def layout(is_single_page, on_resize):
nonlocal _in_paged_mode, col_width, col_and_gap, screen_height, gap, screen_width, is_full_screen_layout, cols_per_screen, number_of_cols nonlocal _in_paged_mode, col_size, col_and_gap, screen_block, gap, screen_inline, is_full_screen_layout, cols_per_screen, number_of_cols
line_height(True) line_height(True)
rem_size(True)
body_style = window.getComputedStyle(document.body) body_style = window.getComputedStyle(document.body)
scroll_viewport.initialize_on_layout(body_style) scroll_viewport.initialize_on_layout(body_style)
first_layout = not _in_paged_mode first_layout = not _in_paged_mode
@ -172,7 +201,8 @@ def layout(is_single_page, on_resize):
handle_rtl_body(body_style) handle_rtl_body(body_style)
# Check if the current document is a full screen layout like # Check if the current document is a full screen layout like
# cover, if so we treat it specially. # cover, if so we treat it specially.
single_screen = (document_height() < scroll_viewport.height() + 75) # (The inline size is the column direction, so it's the appropriate thing to check to make sure we don't have multiple columns)
single_screen = (scroll_viewport.document_inline_size() < scroll_viewport.inline_size() + 75)
first_layout = True first_layout = True
svgs = document.getElementsByTagName('svg') svgs = document.getElementsByTagName('svg')
has_svg = svgs.length > 0 has_svg = svgs.length > 0
@ -196,24 +226,24 @@ def layout(is_single_page, on_resize):
create_page_div() create_page_div()
n = cols_per_screen = cps n = cols_per_screen = cps
# Calculate the column width so that cols_per_screen columns fit exactly in # Calculate the column size so that cols_per_screen columns fit exactly in
# the window width, with their separator margins # the window inline dimension, with their separator margins
ww = col_width = screen_width = scroll_viewport.width() wi = col_size = screen_inline = scroll_viewport.inline_size()
sm = opts.margin_left + opts.margin_right margin_size = opts.margin_left + opts.margin_right if scroll_viewport.horizontal_writing_mode else opts.margin_top + opts.margin_bottom
gap = sm gap = margin_size
if n > 1: if n > 1:
# Adjust the side margin so that the window width satisfies # Adjust the margin so that the window inline dimension satisfies
# col_width * n + (n-1) * 2 * side_margin = window_width # col_size * n + (n-1) * 2 * margin = window_inline
gap += ((ww + sm) % n) # Ensure ww + gap is a multiple of n gap += ((wi + margin_size) % n) # Ensure wi + gap is a multiple of n
col_width = ((ww + gap) // n) - gap col_size = ((wi + gap) // n) - gap
screen_height = scroll_viewport.height() screen_block = scroll_viewport.block_size()
col_and_gap = col_width + gap col_and_gap = col_size + gap
set_css(document.body, column_gap=gap + 'px', column_width=col_width + 'px', column_rule='0px inset blue', set_css(document.body, column_gap=gap + 'px', column_width=col_size + 'px', column_rule='0px inset blue',
min_width='0', max_width='none', min_height='0', max_height='100vh', column_fill='auto', 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', margin='0', border_width='0', padding='0', box_sizing='content-box',
width=screen_width + 'px', height=screen_height + 'px', overflow_wrap='break-word' width=scroll_viewport.width() + 'px', height=scroll_viewport.height() + 'px', overflow_wrap='break-word'
) )
# Without this, webkit bleeds the margin of the first block(s) of body # 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 # above the columns, which causes them to effectively be added to the
@ -240,67 +270,68 @@ def layout(is_single_page, on_resize):
# with height=100% overflow the first column # with height=100% overflow the first column
is_full_screen_layout = is_single_page is_full_screen_layout = is_single_page
if not is_full_screen_layout: if not is_full_screen_layout:
has_no_more_than_two_columns = (scroll_viewport.paged_content_width() < 2*ww + 10) has_no_more_than_two_columns = (scroll_viewport.paged_content_inline_size() < 2*wi + 10)
if has_no_more_than_two_columns and single_screen: if has_no_more_than_two_columns and single_screen:
if only_img and imgs.length and imgs[0].getBoundingClientRect().left < ww: if only_img and imgs.length and imgs[0].getBoundingClientRect().left < wi:
is_full_screen_layout = True is_full_screen_layout = True
if has_svg and svgs.length == 1 and svgs[0].getBoundingClientRect().left < ww: if has_svg and svgs.length == 1 and svgs[0].getBoundingClientRect().left < wi:
is_full_screen_layout = True is_full_screen_layout = True
# Some browser engine, WebKit at least, adjust column widths to please # Some browser engine, WebKit at least, adjust column sizes to please
# themselves, unless the container width is an exact multiple, so we check # themselves, unless the container size is an exact multiple, so we check
# for that and manually set the container widths. # for that and manually set the container sizes.
def check_column_widths(): def check_column_sizes():
nonlocal number_of_cols nonlocal number_of_cols
ncols = number_of_cols = (scroll_viewport.paged_content_width() + gap) / col_and_gap ncols = number_of_cols = (scroll_viewport.paged_content_inline_size() + gap) / col_and_gap
if ncols is not Math.floor(ncols): if ncols is not Math.floor(ncols):
n = number_of_cols = Math.floor(ncols) n = number_of_cols = Math.floor(ncols)
dw = n*col_width + (n-1)*gap dw = n*col_size + (n-1)*gap
data = {'col_width':col_width, 'gap':gap, 'scrollWidth':scroll_viewport.paged_content_width(), 'ncols':ncols, 'desired_width':dw} data = {'col_size':col_size, 'gap':gap, 'scrollWidth':scroll_viewport.paged_content_inline_size(), 'ncols':ncols, 'desired_inline_size':dis}
return data return data
data = check_column_widths() data = check_column_sizes()
if data: if data:
dw = data.desired_width dis = data.desired_inline_size
for elem in document.documentElement, document.body: for elem in document.documentElement, document.body:
set_css(elem, max_width=dw + 'px', min_width=dw + 'px') set_css(elem, max_width=dis + 'px', min_width=dis + 'px')
data = check_column_widths() if scroll_viewport.vertical_writing_mode:
set_css(elem, max_height=dis + 'px', min_height=dis + 'px')
data = check_column_sizes()
if data: 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) print('WARNING: column layout broken, probably because there is some non-reflowable content in the book whose inline size is greater than the column size', data)
_in_paged_mode = True _in_paged_mode = True
fit_images() fit_images()
return gap return gap
def current_scroll_offset(): def current_scroll_offset():
return scroll_viewport.x() return scroll_viewport.inline_pos()
def scroll_to_offset(x): def scroll_to_offset(offset):
scroll_viewport.scroll_to(x, 0) scroll_viewport.scroll_to_in_inline_direction(offset)
def scroll_to_column(number, notify=False, duration=1000): def scroll_to_column(number, notify=False, duration=1000):
nonlocal last_scrolled_to_column nonlocal last_scrolled_to_column
last_scrolled_to_column = number last_scrolled_to_column = number
pos = number * col_and_gap pos = number * col_and_gap
limit = scroll_viewport.paged_content_width() - screen_width limit = scroll_viewport.paged_content_inline_size() - scroll_viewport.inline_size()
pos = min(pos, limit) pos = min(pos, limit)
scroll_to_offset(pos) scroll_to_offset(pos)
def scroll_to_xpos(xpos, notify=False, duration=1000): def scroll_to_pos(pos, notify=False, duration=1000):
nonlocal last_scrolled_to_column nonlocal last_scrolled_to_column
# Scroll so that the column containing xpos is the left most column in # Scroll to the column containing pos
# the viewport if jstype(pos) is not 'number':
if jstype(xpos) is not 'number': print(pos, 'is not a number, cannot scroll to it!')
print(xpos, 'is not a number, cannot scroll to it!')
return return
if is_full_screen_layout: if is_full_screen_layout:
scroll_to_offset(0) scroll_to_offset(0)
last_scrolled_to_column = 0 last_scrolled_to_column = 0
return return
scroll_to_column(column_at(xpos), notify=notify, duration=duration) scroll_to_column(column_at(pos), notify=notify, duration=duration)
def scroll_to_previous_position(): def scroll_to_previous_position():
@ -315,8 +346,8 @@ def scroll_to_fraction(frac, on_initial_load):
# Scroll to the position represented by frac (number between 0 and 1) # Scroll to the position represented by frac (number between 0 and 1)
if on_initial_load and frac is 1 and is_return() and scroll_to_previous_position(): if on_initial_load and frac is 1 and is_return() and scroll_to_previous_position():
return return
xpos = Math.floor(scroll_viewport.paged_content_width() * frac) pos = Math.floor(scroll_viewport.paged_content_inline_size() * frac)
scroll_to_xpos(xpos) scroll_to_pos(pos)
def column_boundaries(): def column_boundaries():
@ -326,7 +357,7 @@ def column_boundaries():
return l, l + cols_per_screen return l, l + cols_per_screen
def current_column_location(): def current_column_location():
# The location of the left edge of the left most column currently # The location of the starting edge of the first column currently
# visible in the viewport # visible in the viewport
if is_full_screen_layout: if is_full_screen_layout:
return 0 return 0
@ -346,10 +377,10 @@ def next_screen_location():
if is_full_screen_layout: if is_full_screen_layout:
return -1 return -1
cc = current_column_location() cc = current_column_location()
ans = cc + screen_width ans = cc + screen_inline
if cols_per_screen > 1 and 0 < number_of_cols_left() < cols_per_screen: if cols_per_screen > 1 and 0 < number_of_cols_left() < cols_per_screen:
return -1 # Only blank, dummy pages left return -1 # Only blank, dummy pages left
limit = scroll_viewport.paged_content_width() - scroll_viewport.width() limit = scroll_viewport.paged_content_inline_size() - scroll_viewport.inline_size()
if limit < col_and_gap: if limit < col_and_gap:
return -1 return -1
if ans > limit: if ans > limit:
@ -363,7 +394,7 @@ def previous_screen_location():
if is_full_screen_layout: if is_full_screen_layout:
return -1 return -1
cc = current_column_location() cc = current_column_location()
ans = cc - screen_width ans = cc - screen_inline
if ans < 0: if ans < 0:
# We ignore small scrolls (less than 15px) when going to previous # We ignore small scrolls (less than 15px) when going to previous
# screen # screen
@ -379,8 +410,8 @@ def next_col_location():
return -1 return -1
cc = current_column_location() cc = current_column_location()
ans = cc + col_and_gap ans = cc + col_and_gap
limit = scroll_viewport.paged_content_width() - scroll_viewport.width() limit = scroll_viewport.paged_content_inline_size() - scroll_viewport.inline_size()
# print(f'cc={cc} col_and_gap={col_and_gap} ans={ans} limit={limit} content_width={scroll_viewport.paged_content_width()} vw={scroll_viewport.width()} current_scroll_offset={current_scroll_offset()}') # print(f'cc={cc} col_and_gap={col_and_gap} ans={ans} limit={limit} content_inline_size={scroll_viewport.paged_content_inline_size()} inline={scroll_viewport.inline_size()} current_scroll_offset={current_scroll_offset()}')
if ans > limit: if ans > limit:
ans = limit if Math.ceil(current_scroll_offset()) < limit else -1 ans = limit if Math.ceil(current_scroll_offset()) < limit else -1
return ans return ans
@ -400,9 +431,7 @@ def previous_col_location():
def jump_to_anchor(name): def jump_to_anchor(name):
# Jump to the element identified by anchor name. Ensures that the left # Jump to the element identified by anchor name.
# 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) elem = document.getElementById(name)
if not elem: if not elem:
elems = document.getElementsByName(name) elems = document.getElementsByName(name)
@ -429,13 +458,19 @@ def scroll_to_elem(elem):
# elem.scrollIntoView(). However, in some cases it gives # elem.scrollIntoView(). However, in some cases it gives
# inaccurate results, so we prefer the bounding client rect, # inaccurate results, so we prefer the bounding client rect,
# when possible. # when possible.
# Columns start on the right side in RTL mode, so get that instead here...
pos = elem.scrollLeft if scroll_viewport.ltr else elem.scrollRight # In horizontal writing, the inline start position depends on the direction
if scroll_viewport.horizontal_writing_mode:
inline_start = elem.scrollLeft if scroll_viewport.ltr else elem.scrollRight
# In vertical writing, the inline start position is always the top since
# vertical text only flows top-to-bottom
else:
inline_start = elem.scrollTop
else: else:
# and here. # If we can use the rect, just use the simpler viewport helper function
pos = br.left if scroll_viewport.ltr else br.right inline_start = scroll_viewport.rect_inline_start(br)
scroll_to_xpos(scroll_viewport.viewport_to_document(
pos+2, elem.scrollTop, elem.ownerDocument)[0]) scroll_to_pos(scroll_viewport.viewport_to_document_inline(inline_start+2, elem.ownerDocument))
def snap_to_selection(): def snap_to_selection():
# Ensure that the viewport is positioned at the start of the column # Ensure that the viewport is positioned at the start of the column
@ -444,25 +479,22 @@ def snap_to_selection():
sel = window.getSelection() sel = window.getSelection()
r = sel.getRangeAt(0).getBoundingClientRect() r = sel.getRangeAt(0).getBoundingClientRect()
node = sel.anchorNode node = sel.anchorNode
# In RTL mode, the "start" of selection is on the right side. # Columns are in the inline direction, so get the beginning of the element in the inline
pos = scroll_viewport.viewport_to_document( pos = scroll_viewport.viewport_to_document_inline(
r.left if scroll_viewport.ltr else r.right, scroll_viewport.rect_inline_start(r), doc=node.ownerDocument)
r.top, doc=node.ownerDocument)[0]
# Ensure we are scrolled to the column containing the start of the # Ensure we are scrolled to the column containing the start of the
# selection # selection
scroll_to_xpos(pos+5) scroll_to_pos(pos+5)
def jump_to_cfi(cfi): def jump_to_cfi(cfi):
# Jump to the position indicated by the specified conformal fragment # Jump to the position indicated by the specified conformal fragment
# indicator. When in paged mode, the # indicator.
# 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): cfi_scroll_to(cfi, def(x, y):
if in_paged_mode(): if scroll_viewport.horizontal_writing_mode:
scroll_to_xpos(x) scroll_to_pos(x)
else: else:
scroll_viewport.scroll_to(0, y) scroll_to_pos(y)
) )
def current_cfi(): def current_cfi():
@ -472,7 +504,7 @@ def current_cfi():
if in_paged_mode(): if in_paged_mode():
for cnum in range(cols_per_screen): for cnum in range(cols_per_screen):
left = cnum * (col_and_gap + gap) left = cnum * (col_and_gap + gap)
right = left + col_width right = left + col_size
top, bottom = 0, scroll_viewport.height() top, bottom = 0, scroll_viewport.height()
midx = (right - left) // 2 midx = (right - left) // 2
deltax = (right - left) // 24 deltax = (right - left) // 24
@ -510,14 +542,15 @@ def current_cfi():
def progress_frac(frac): def progress_frac(frac):
# The current scroll position as a fraction between 0 and 1 # The current scroll position as a fraction between 0 and 1
if in_paged_mode(): if in_paged_mode():
limit = scroll_viewport.paged_content_width() - scroll_viewport.width() limit = scroll_viewport.paged_content_inline_size() - scroll_viewport.inline_size()
if limit <= 0: if limit <= 0:
return 0.0 return 0.0
return current_scroll_offset() / limit return current_scroll_offset() / limit
limit = document_height() - scroll_viewport.height() # In flow mode, we scroll in the block direction, so use that
limit = scroll_viewport.document_block_size() - scroll_viewport.block_size()
if limit <= 0: if limit <= 0:
return 0.0 return 0.0
return Math.max(0, Math.min(window.pageYOffset / limit, 1)) return Math.max(0, Math.min(scroll_viewport.block_pos() / limit, 1))
def next_spine_item(backward): def next_spine_item(backward):
@ -570,13 +603,13 @@ class HandleWheel:
def do_scroll(self, backward): def do_scroll(self, backward):
self.reset() self.reset()
if opts.paged_wheel_scrolls_by_screen: if opts.paged_wheel_scrolls_by_screen:
x = previous_screen_location() if backward else next_screen_location() pos = previous_screen_location() if backward else next_screen_location()
else: else:
x = previous_col_location() if backward else next_col_location() pos = previous_col_location() if backward else next_col_location()
if x is -1: if pos is -1:
next_spine_item(backward) next_spine_item(backward)
else: else:
scroll_to_xpos(x) scroll_to_pos(pos)
wheel_handler = HandleWheel() wheel_handler = HandleWheel()
@ -603,14 +636,14 @@ def scroll_by_page(backward, by_screen, flip_if_rtl_page_progression):
get_boss().report_human_scroll(scrolled_frac) get_boss().report_human_scroll(scrolled_frac)
else: else:
get_boss().report_human_scroll() get_boss().report_human_scroll()
scroll_to_xpos(pos) scroll_to_pos(pos)
def scroll_to_extend_annotation(backward): def scroll_to_extend_annotation(backward):
pos = previous_col_location() if backward else next_col_location() pos = previous_col_location() if backward else next_col_location()
if pos is -1: if pos is -1:
return False return False
scroll_to_xpos(pos) scroll_to_pos(pos)
return True return True
@ -627,7 +660,7 @@ def handle_shortcut(sc_name, evt):
return True return True
if sc_name is 'end_of_file': if sc_name is 'end_of_file':
get_boss().report_human_scroll() get_boss().report_human_scroll()
scroll_to_offset(document_width()) scroll_to_offset(scroll_viewport.document_inline_size())
return True return True
if sc_name is 'left': if sc_name is 'left':
scroll_by_page(backward=True, by_screen=False, flip_if_rtl_page_progression=True) scroll_by_page(backward=True, by_screen=False, flip_if_rtl_page_progression=True)
@ -676,11 +709,9 @@ anchor_funcs = {
if not elem: if not elem:
return 0 return 0
br = elem.getBoundingClientRect() br = elem.getBoundingClientRect()
# In RTL mode, the start of something is on the right side. pos = scroll_viewport.viewport_to_document_inline(
x = scroll_viewport.viewport_to_document( scroll_viewport.rect_inline_start(br))
br.left if scroll_viewport.ltr else br.right, return column_at(pos)
br.top, elem.ownerDocument)[0]
return column_at(x)
, ,
'visibility': def visibility(pos): 'visibility': def visibility(pos):
first = column_at(current_scroll_offset() + 10) first = column_at(current_scroll_offset() + 10)
@ -716,7 +747,7 @@ class ResizeManager:
'width': scroll_viewport.width(), 'height': scroll_viewport.height(), 'column': last_scrolled_to_column}} 'width': scroll_viewport.width(), 'height': scroll_viewport.height(), 'column': last_scrolled_to_column}}
if self.is_inverse_transition(transition): if self.is_inverse_transition(transition):
if transition.after.column is not self.last_transition.before.column: if transition.after.column is not self.last_transition.before.column:
self.scroll_to_column(transition.after.column) scroll_to_column(transition.after.column)
transition.after.column = last_scrolled_to_column transition.after.column = last_scrolled_to_column
self.last_transition = transition self.last_transition = transition
@ -766,7 +797,7 @@ class DragScroller:
def do_one_page_turn(self): def do_one_page_turn(self):
pos = previous_col_location() if self.backward else next_col_location() pos = previous_col_location() if self.backward else next_col_location()
if pos >= 0: if pos >= 0:
scroll_to_xpos(pos) scroll_to_pos(pos)
self.timer_id = window.setTimeout(self.do_one_page_turn.bind(self), self.INTERVAL * 2) self.timer_id = window.setTimeout(self.do_one_page_turn.bind(self), self.INTERVAL * 2)
else: else:
self.stop() self.stop()

View File

@ -5,7 +5,7 @@ from __python__ import bound_methods, hash_literals
FUNCTIONS = 'x y scroll_to scroll_into_view reset_globals __reset_transforms'.split(' ') FUNCTIONS = 'x y scroll_to scroll_into_view reset_globals __reset_transforms'.split(' ')
from read_book.globals import get_boss, viewport_mode_changer from read_book.globals import get_boss, viewport_mode_changer
from utils import is_ios from utils import document_height, document_width, is_ios
class ScrollViewport: class ScrollViewport:
@ -17,6 +17,8 @@ class ScrollViewport:
# code into thinking that it's always scrolling in positive X. # code into thinking that it's always scrolling in positive X.
self.rtl = False self.rtl = False
self.ltr = True self.ltr = True
self.vertical_writing_mode = False
self.horizontal_writing_mode = True
def set_mode(self, mode): def set_mode(self, mode):
prefix = ('flow' if mode is 'flow' else 'paged') + '_' prefix = ('flow' if mode is 'flow' else 'paged') + '_'
@ -24,12 +26,27 @@ class ScrollViewport:
self[attr] = self[prefix + attr] self[attr] = self[prefix + attr]
def initialize_on_layout(self, body_style): def initialize_on_layout(self, body_style):
self.horizontal_writing_mode = True
self.vertical_writing_mode = False
self.ltr = True
self.rtl = False
if body_style.direction is "rtl": if body_style.direction is "rtl":
self.rtl = True self.rtl = True
self.ltr = False self.ltr = False
css_vertical_rl = body_style.getPropertyValue("writing-mode") is "vertical-rl"
if css_vertical_rl:
self.vertical_writing_mode = True
self.horizontal_writing_mode = False
self.rtl = True
self.ltr = False
else: else:
self.rtl = False css_vertical_lr = body_style.getPropertyValue("writing-mode") is "vertical-lr"
self.ltr = True if css_vertical_lr:
self.vertical_writing_mode = True
self.horizontal_writing_mode = False
self.ltr = True
self.rtl = False
def flow_x(self): def flow_x(self):
if self.rtl: if self.rtl:
@ -39,8 +56,15 @@ class ScrollViewport:
def flow_y(self): def flow_y(self):
return window.pageYOffset return window.pageYOffset
def paged_y(self): def inline_pos(self):
return 0 if self.vertical_writing_mode:
return self.y()
return self.x()
def block_pos(self):
if self.horizontal_writing_mode:
return self.y()
return self.x()
def flow_scroll_to(self, x, y): def flow_scroll_to(self, x, y):
if self.rtl: if self.rtl:
@ -48,18 +72,71 @@ class ScrollViewport:
else: else:
window.scrollTo(x, y) window.scrollTo(x, y)
def scroll_to_in_inline_direction(self, pos):
# Lines flow vertically, so inline is vertical.
if self.vertical_writing_mode:
self.scroll_to(0, pos)
else:
self.scroll_to(pos, 0)
def scroll_to_in_block_direction(self, pos):
# In horizontal modes, the block direction is vertical.
if self.horizontal_writing_mode:
self.scroll_to(0, pos)
# In vertical modes, the block direction is horizontal.
else:
self.scroll_to(pos, 0)
def flow_scroll_into_view(self, elem): def flow_scroll_into_view(self, elem):
elem.scrollIntoView() elem.scrollIntoView()
def scroll_by(self, x, y):
if self.ltr:
window.scrollBy(x, y)
# Swap inline direction if in RTL mode.
else:
window.scrollBy(-x,y)
def scroll_by_in_inline_direction(self, offset):
# Same logic as scroll_to_in_inline_direction
if self.vertical_writing_mode:
self.scroll_by(0, offset)
else:
self.scroll_by(offset, 0)
def scroll_by_in_block_direction(self, offset):
# Same logic as scroll_to_in_block_direction
if self.horizontal_writing_mode:
self.scroll_by(0, offset)
else:
self.scroll_by(offset, 0)
def flow_reset_globals(self): def flow_reset_globals(self):
pass pass
def flow___reset_transforms(self): def flow___reset_transforms(self):
pass pass
def paged_content_width(self): def paged_content_inline_size(self):
if self.horizontal_writing_mode:
return document.documentElement.scrollWidth
return document.documentElement.scrollHeight
def paged_content_block_size(self):
if self.horizontal_writing_mode:
return document.documentElement.scrollHeight
return document.documentElement.scrollWidth return document.documentElement.scrollWidth
def inline_size(self):
if self.horizontal_writing_mode:
return self.width()
return self.height()
def block_size(self):
if self.horizontal_writing_mode:
return self.height()
return self.width()
def update_window_size(self, w, h): def update_window_size(self, w, h):
self.window_width_from_parent = w self.window_width_from_parent = w
self.window_height_from_parent = h self.window_height_from_parent = h
@ -70,6 +147,16 @@ class ScrollViewport:
def height(self): def height(self):
return window.innerHeight return window.innerHeight
def document_inline_size(self):
if self.horizontal_writing_mode:
return document_width()
return document_height()
def document_block_size(self):
if self.horizontal_writing_mode:
return document_height()
return document_width()
# Assure that the viewport position returned is corrected for the RTL # Assure that the viewport position returned is corrected for the RTL
# mode of ScrollViewport. # mode of ScrollViewport.
def viewport_to_document(self, x, y, doc): def viewport_to_document(self, x, y, doc):
@ -94,6 +181,72 @@ class ScrollViewport:
return -x, y return -x, y
return x, y return x, y
def rect_inline_start(self, rect):
# Lines start on the left in LTR mode, right in RTL mode
if self.horizontal_writing_mode:
return rect.left if self.ltr else rect.right
# Only top-to-bottom vertical writing is supported
return rect.top
def rect_inline_end(self, rect):
# Lines end on the right in LTR mode, left in RTL mode
if self.horizontal_writing_mode:
return rect.right if self.ltr else rect.left
# In top-to-bottom (the only vertical mode supported), bottom:
return rect.bottom
def rect_block_start(self, rect):
# Block flows top to bottom in horizontal modes
if self.horizontal_writing_mode:
return rect.top
# Block flows either left or right in vertical modes
return rect.left if self.ltr else rect.right
def rect_block_end(self, rect):
# Block flows top to bottom in horizontal modes
if self.horizontal_writing_mode:
return rect.bottom
# Block flows either left or right in vertical modes
return rect.right if self.ltr else rect.left
def rect_inline_size(self, rect):
# Lines go horizontally in horizontal writing, so use width
if self.horizontal_writing_mode:
return rect.width
return rect.height
def rect_block_size(self, rect):
# The block is vertical in horizontal writing, so use height
if self.horizontal_writing_mode:
return rect.height
return rect.width
# Returns document inline coordinate (1 value) at viewport inline coordinate
def viewport_to_document_inline(self, pos, doc):
# Lines flow horizontally in horizontal mode
if self.horizontal_writing_mode:
return self.viewport_to_document(pos, 0, doc)[0]
# Inline is vertical in vertical mode
return self.viewport_to_document(0, pos, doc)[1]
# Returns document block coordinate (1 value) at viewport block coordinate
def viewport_to_document_block(self, pos, doc):
# Block is vertical in horizontal mode
if self.horizontal_writing_mode:
return self.viewport_to_document(0, pos, doc)[1]
# Horizontal in vertical mode
return self.viewport_to_document(pos, 0, doc)[0]
def viewport_to_document_inline_block(self, inline, block):
if self.horizontal_writing_mode:
return self.viewport_to_document(inline, block)
return self.viewport_to_document(block, inline)
def element_from_point(self, doc, x, y):
if self.rtl:
return doc.elementFromPoint(-x, y)
return doc.elementFromPoint(x, y)
class IOSScrollViewport(ScrollViewport): class IOSScrollViewport(ScrollViewport):
def width(self): def width(self):
@ -102,15 +255,19 @@ class IOSScrollViewport(ScrollViewport):
def height(self): def height(self):
return self.window_height_from_parent or window.innerHeight return self.window_height_from_parent or window.innerHeight
def _scroll_implementation(self, x): def _scroll_implementation(self, x, y):
if x is 0: if x is 0 and y is 0:
document.documentElement.style.transform = 'none' document.documentElement.style.transform = 'none'
else: else:
x *= -1 x *= -1
document.documentElement.style.transform = f'translateX({x}px)' y *= -1
document.documentElement.style.transform = f'translateX({x}px) translateY({y}px)'
def paged_scroll_to(self, x, y): def paged_scroll_to(self, x, y):
self._scroll_implementation(x) if self.ltr:
self._scroll_implementation(x, y)
else:
self._scroll_implementation(-x, y)
boss = get_boss() boss = get_boss()
if boss: if boss:
boss.onscroll() boss.onscroll()
@ -127,6 +284,17 @@ class IOSScrollViewport(ScrollViewport):
ans *= -1 ans *= -1
return ans return ans
def paged_y(self):
raw = document.documentElement.style.transform
if not raw or raw is 'none':
return 0
raw = raw[raw.lastIndexOf('(') + 1:]
ans = parseInt(raw)
if isNaN(ans):
return 0
ans *= -1
return ans
def paged_scroll_into_view(self, elem): def paged_scroll_into_view(self, elem):
left = elem.offsetLeft left = elem.offsetLeft
if left is None: if left is None:
@ -158,6 +326,23 @@ for attr in FUNCTIONS:
scroll_viewport['paged_' + attr] = scroll_viewport[attr] scroll_viewport['paged_' + attr] = scroll_viewport[attr]
viewport_mode_changer(scroll_viewport.set_mode) viewport_mode_changer(scroll_viewport.set_mode)
def rem_size(reset):
if reset:
rem_size.ans = None
return
if not rem_size.ans:
d = document.createElement('span')
d.style.position = 'absolute'
d.style.visibility = 'hidden'
d.style.width = '1rem'
d.style.fontSize = '1rem'
d.style.paddingTop = d.style.paddingBottom = d.style.paddingLeft = d.style.paddingRight = '0'
d.style.marginTop = d.style.marginBottom = d.style.marginLeft = d.style.marginRight = '0'
d.style.borderStyle = 'none'
document.body.appendChild(d)
rem_size.ans = d.clientWidth
document.body.removeChild(d)
return rem_size.ans
def line_height(reset): def line_height(reset):
if reset: if reset: