# vim:fileencoding=utf-8 # License: GPL v3 Copyright: 2016, Kovid Goyal from __python__ import hash_literals from read_book.viewport import scroll_viewport, rem_size # CFI escaping {{{ escape_pat = /[\[\],^();~@!]/g unescape_pat = /[\^](.)/g def escape_for_cfi(raw): return (raw or '').replace(escape_pat, '^$&') def unescape_from_cfi(raw): return (raw or '').replace(unescape_pat, '$1') # }}} def fstr(d): # {{{ # Convert a timestamp floating point number to a string ans = '' if d < 0: ans = '-' d = -d n = Math.floor(d) ans += n n = Math.round((d - n) * 100) if n is not 0: ans += '.' + (n / 10 if (n % 10 is 0) else n) return ans # }}} def get_current_time(target): # {{{ return fstr(target.currentTime or 0) # }}} def id_is_unique(idval): # {{{ try: multiples = document.querySelectorAll('[id=' + window.CSS.escape(idval) + ']') except: return False if multiples and multiples.length < 2: return True return False # }}} # Convert point to character offset {{{ def range_has_point(range_, x, y): rects = range_.getClientRects() for v'var i = 0; i < rects.length; i++': rect = rects[i] if (rect.left <= x <= rect.right) and (rect.top <= y <= rect.bottom): return True return False def offset_in_text_node(node, range_, x, y): limits = [0, node.nodeValue.length] while limits[0] is not limits[1]: pivot = Math.floor((limits[0] + limits[1]) / 2) lr = [limits[0], pivot] rr = [pivot + 1, limits[1]] range_.setStart(node, pivot) range_.setEnd(node, pivot + 1) if range_has_point(range_, x, y): return pivot range_.setStart(node, rr[0]) range_.setEnd(node, rr[1]) if range_has_point(range_, x, y): limits = rr continue range_.setStart(node, lr[0]) range_.setEnd(node, lr[1]) if range_has_point(range_, x, y): limits = lr continue break return limits[0] def find_offset_for_point(x, y, node, cdoc): range_ = cdoc.createRange() child = node.firstChild while child: if Node.TEXT_NODE <= child.nodeType <= Node.ENTITY_NODE and child.nodeValue and child.nodeValue.length: range_.setStart(child, 0) range_.setEnd(child, child.nodeValue.length) if range_has_point(range_, x, y): return [child, offset_in_text_node(child, range_, x, y)] child = child.nextSibling # The point must be after the last bit of text/in the padding/border, we don't know # how to get a good point in this case return None, None # }}} def set_current_time(target, val): # {{{ if target.currentTime is undefined: return if target.readyState is 4 or target.readyState is 'complete': target.currentTime = val + 0 else: target.addEventListener('canplay', def (): target.currentTime=val;, False) # }}} # def encode(doc, node, offset, tail): {{{ def is_text_node(node): return node.nodeType is Node.TEXT_NODE or node.nodeType is Node.CDATA_SECTION_NODE def text_length_in_range_wrapper(node): p = node.firstChild ans = 0 while p: if is_text_node(p): ans += p.nodeValue.length elif p.nodeType is Node.ELEMENT_NODE and p.dataset?.calibreRangeWrapper: ans += text_length_in_range_wrapper(p) p = p.nextSibling return ans def adjust_node_for_text_offset(node): if node.parentNode and node.parentNode.dataset?.calibreRangeWrapper: node = node.parentNode offset = 0 adjusted = False while True: p = node.previousSibling if not p or p.nodeType > Node.COMMENT_NODE: break if is_text_node(p): offset += p.nodeValue.length elif p.nodeType is Node.ELEMENT_NODE and p.dataset?.calibreRangeWrapper: offset += text_length_in_range_wrapper(p) node = p adjusted = True if adjusted and node.nodeType is Node.ELEMENT_NODE and node.dataset?.calibreRangeWrapper: if not node.firstChild or node.firstChild.nodeType is not Node.TEXT_NODE: node.insertBefore(document.createTextNode(''), node.firstChild or None) node = node.firstChild return node, offset, adjusted def unwrapped_nodes(range_wrapper): ans = [] for child in range_wrapper.childNodes: if child.nodeType is Node.ELEMENT_NODE and child.dataset?.calibreRangeWrapper: ans = ans.concat(unwrapped_nodes(child)) else: ans.push(child) return ans def increment_index_for_child(child, index, sentinel): is_element = child.nodeType is Node.ELEMENT_NODE if is_element and child.dataset?.calibreRangeWrapper: nodes = unwrapped_nodes(child) index = increment_index_for_children(nodes, index, sentinel) else: index |= 1 # increment if even if is_element: index += 1 return index def increment_index_for_children(children, index, sentinel): for i in range(children.length): child = children[i] index = increment_index_for_child(child, index, sentinel) if child is sentinel: break return index def non_range_wrapper_parent(node): p = node.parentNode child = node while p.dataset?.calibreRangeWrapper: child = p p = p.parentNode return p, child def encode(doc, node, offset, tail): cfi = tail or '' # Handle the offset, if any if node.nodeType is Node.ELEMENT_NODE: if jstype(offset) is 'number': q = node.childNodes.item(offset) if q: if q.nodeType is Node.ELEMENT_NODE: node = q if node.dataset?.calibreRangeWrapper: if not node.firstChild: node.appendChild(document.createTextNode('')) node = node.firstChild elif node.dataset?.calibreRangeWrapper: if not node.firstChild: node.appendChild(document.createTextNode('')) node = node.firstChild if is_text_node(node): offset = offset or 0 adjusted_node, additional_offset, adjusted = adjust_node_for_text_offset(node) if adjusted: node = adjusted_node offset += additional_offset cfi = ':' + offset + cfi elif node.nodeType is not Node.ELEMENT_NODE: # Not handled print(f"Offsets for nodes of type {node.nodeType} are not handled") # Construct the path to node from root is_first = True while node is not doc: p, sentinel = non_range_wrapper_parent(node) if not p: if node.nodeType == Node.DOCUMENT_NODE: # Document node (iframe) win = node.defaultView if win.frameElement: node = win.frameElement cfi = '!' + cfi continue break # Find position of node in parent index = increment_index_for_children(p.childNodes, 0, sentinel) if is_first: is_first = False if node.nodeType is Node.ELEMENT_NODE and node.dataset?.calibreRangeWrapper: index -= 1 # Add id assertions for robustness where possible id = node.id idspec = '' if id and id_is_unique(id): idspec = ('[' + escape_for_cfi(id) + ']') cfi = '/' + index + idspec + cfi node = p return cfi # }}} # def decode(cfi, doc): {{{ def node_at_index(nodes, target, index, iter_text_nodes): for i in range(nodes.length): node = nodes[i] is_element = node.nodeType is Node.ELEMENT_NODE if is_element and node.dataset?.calibreRangeWrapper: q, index = node_at_index(unwrapped_nodes(node), target, index, iter_text_nodes) if q: return q, index continue if (iter_text_nodes and not is_text_node(node)) or (not iter_text_nodes and not is_element): continue if index is target: return node, index index += 1 return None, index def node_for_path_step(parent, target, assertion): if assertion: q = document.getElementById(assertion) if q and id_is_unique(assertion): return q is_element = target % 2 == 0 target //= 2 if is_element and target > 0: target -= 1 return node_at_index(parent.childNodes, target, 0, not is_element)[0] def node_for_text_offset(nodes, offset, first_node): last_text_node = None seen_first = False for i in range(nodes.length): node = nodes[i] if not seen_first: if not first_node or node.isSameNode(first_node): seen_first = True else: continue if is_text_node(node): l = node.nodeValue.length if offset <= l: return node, offset, True last_text_node = node offset -= l # mathml nodes don't have dataset elif node.nodeType is Node.ELEMENT_NODE and node.dataset?.calibreRangeWrapper: qn, offset, ok = node_for_text_offset(unwrapped_nodes(node), offset) if ok: return qn, offset, True 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 spatial 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 spatial 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): doc = doc or window.document simple_node_regex = /// ^/(\d+) # The node count (\[[^\]]*\])? # The optional id assertion /// error = None node = doc orig_cfi = cfi while cfi.length > 0 and not error: r = cfi.match(simple_node_regex) if r: # Path step target = parseInt(r[1]) assertion = r[2] if assertion: assertion = unescape_from_cfi(assertion.slice(1, assertion.length - 1)) q = node_for_path_step(node, target, assertion) if q: node = q cfi = cfi.substr(r[0].length) else: if target: error = f"No matching child found for CFI: {orig_cfi} leftover: {cfi}" break else: cfi = cfi.substr(r[0].length) else if cfi[0] is '!': # Indirection if node.contentDocument: node = node.contentDocument cfi = cfi.substr(1) else: error = 'Cannot reference ' + node.nodeName + "'s content: " + cfi else: break if error: print(error) return None decoded = {} error = None offset = None r = cfi.match(/^:(\d+)/) if r: # Character offset offset = parseInt(r[1]) cfi = cfi.substr(r[0].length) r = cfi.match(/^~(-?\d+(\.\d+)?)/) if r: # Temporal offset decoded.time = r[1] - 0 # Coerce to number cfi = cfi.substr(r[0].length) r = cfi.match(/^@(-?\d+(\.\d+)?):(-?\d+(\.\d+)?)/) if r: # Spatial offset decoded.x = r[1] - 0 # Coerce to number decoded.y = r[3] - 0 # Coerce to number cfi = cfi.substr(r[0].length) r = cfi.match(/^\[([^\]]+)\]/) if r: assertion = r[1] cfi = cfi.substr(r[0].length) r = assertion.match(/;s=([ab])$/) if r: if r.index > 0 and assertion[r.index - 1] is not '^': assertion = assertion.substr(0, r.index) decoded.forward = (r[1] is 'a') assertion = unescape_from_cfi(assertion) # TODO: Handle text assertion # Find the text node that contains the offset if offset is not None: orig_offset = offset if node.parentNode?.nodeType is Node.ELEMENT_NODE and node.parentNode.dataset?.calibreRangeWrapper: node = node.parentNode qnode, offset, ok = node_for_text_offset(node.parentNode.childNodes, offset, node) if not ok: error = 'Offset out of range: ' + orig_offset if qnode: node = qnode decoded.offset = offset decoded.node = node if error: decoded.error = error else if cfi.length > 0: decoded.error = 'Undecoded CFI: ' + cfi if decoded.error: print(decoded.error) return decoded # }}} def cfi_sort_key(cfi): # {{{ simple_node_regex = /// ^/(\d+) # The node count (\[[^\]]*\])? # The optional id assertion /// steps = [] while cfi.length > 0: r = cfi.match(simple_node_regex) if r: # Path step target = parseInt(r[1]) cfi = cfi.substr(r[0].length) steps.push(target) else if cfi[0] is '!': # Indirection cfi = cfi.substr(1) else: break ans = {'steps': steps, 'text_offset': 0, 'temporal_offset': 0, 'spatial_offset': [0, 0]} r = cfi.match(/^:(\d+)/) if r: # Character offset offset = parseInt(r[1]) ans['text_offset'] = offset cfi = cfi.substr(r[0].length) r = cfi.match(/^~(-?\d+(\.\d+)?)/) if r: # Temporal offset ans['temporal_offset'] = r[1] - 0 # Coerce to number cfi = cfi.substr(r[0].length) r = cfi.match(/^@(-?\d+(\.\d+)?):(-?\d+(\.\d+)?)/) if r: # Spatial offset ans['spatial_offset'] = [r[1] - 0, r[3] - 0] cfi = cfi.substr(r[0].length) return ans def create_cfi_cmp(key_map): if not key_map: key_map = {} def cfi_cmp(a_cfi, b_cfi): a = key_map[a_cfi] if not a: a = key_map[a_cfi] = cfi_sort_key(a_cfi) b = key_map[b_cfi] if not b: b = key_map[b_cfi] = cfi_sort_key(b_cfi) for i in range(min(a.steps.length, b.steps.length)): diff = a.steps[i] - b.steps[i] if diff is not 0: return diff diff = a.steps.length - b.steps.length if diff is not 0: return diff if a.temporal_offset is not b.temporal_offset: return a.temporal_offset - b.temporal_offset if a.spatial_offset[0] is not b.spatial_offset[0]: return a.spatial_offset[0] - b.spatial_offset[0] if a.spatial_offset[1] is not b.spatial_offset[1]: return a.spatial_offset[1] - b.spatial_offset[1] if a.text_offset is not b.text_offset: return a.text_offset - b.text_offset return 0 return cfi_cmp def sort_cfis(array_of_cfis): key_map = {cfi: cfi_sort_key(cfi) for cfi in array_of_cfis} Array.prototype.sort.call(array_of_cfis, create_cfi_cmp(key_map)) # }}} def at(x, y, doc): # {{{ # x, y are in viewport coordinates doc = doc or window.document cdoc = doc target = None tail = '' offset = None name = None # Drill down into iframes, etc. while True: target = cdoc.elementFromPoint(x, y) if not target or target is cdoc.documentElement or target is cdoc.body: # We ignore both html and body even though body could # have text nodes under it as performance is very poor if body # has large margins/padding (e.g. in fullscreen mode) # A possible solution for this is to wrap all text node # children of body in but that is seriously ugly and # might have side effects. Lets do this only if there are lots of # books in the wild that actually have text children of body, # and even in this case it might be better to change the input # plugin to prevent this from happening. # log("No element at (#{ x }, #{ y })") return None name = target.localName if name not in {'iframe', 'embed', 'object'}: break cd = target.contentDocument if not cd: break # We have an embedded document, transforms x, y into the co-prd # system of the embedded document's viewport rect = target.getBoundingClientRect() x -= rect.left y -= rect.top cdoc = cd (target.parentNode or target).normalize() if name in {'audio', 'video'}: tail = '~' + get_current_time(target) if name in {'img', 'video'}: rect = target.getBoundingClientRect() px = ((x - rect.left) * 100) / target.offsetWidth py = ((y - rect.top) * 100) / target.offsetHeight tail = str.format('{}@{}:{}', tail, fstr(px), fstr(py)) else if name is not 'audio': # Get the text offset # We use a custom function instead of caretRangeFromPoint as # caretRangeFromPoint does weird things when the point falls in the # padding of the element target, offset = find_offset_for_point(x, y, target, cdoc) if target is None: return None return encode(doc, target, offset, tail) # }}} # Wrapper for decode(), 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 >= 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 spatial offset is set in the return value. # # Includes everything that the decode() function does, in addition to: # range: A text range, as described 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 decoded = decode(cfi, doc) if not decoded or not decoded.node: return None node = decoded.node ndoc = node.ownerDocument if not ndoc: print(str.format('CFI node has no owner document: {} {}', cfi, node)) return None range_ = None position_at_end_of_range = None if jstype(decoded.offset) is 'number': # We can only create a meaningful range if the node length is # positive and nonzero. node_len = node.nodeValue.length if node.nodeValue else 0 if not node_len and node.nextSibling?.dataset?.calibreRangeWrapper and node.nextSibling.firstChild?.nodeType is Node.TEXT_NODE: node = node.nextSibling.firstChild node_len = node.nodeValue.length if node.nodeValue else 0 if node_len: range_ = ndoc.createRange() # Check for special case: End of range offset, after the last character offset = decoded.offset position_at_end_of_range = False if offset >= node_len: offset = node_len - 1 position_at_end_of_range = True range_.setStart(node, offset) range_.setEnd(node, offset + 1) rect = range_.getBoundingClientRect() if not rect: print(str.format('Could not find caret position for {} (offset: {})', cfi, decoded.offset)) range_ = None else: print(f'Caret offset pointed to an empty text node or a non-text node for {cfi}, (offset: {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 # }}} # It's only valid to call this if you have a decoded CFI with a range included. # Call decoded_to_document_position if you're not sure. def decoded_range_to_document_position(decoded): # Character offset # Get the bounding rect of the range, in (real) viewport space rect = decoded.range.getBoundingClientRect() inserted_node = None if not rect.width and not rect.height and not rect.left and not rect.right: # this happens is range is a text node containing a newline after a #
https://bugs.launchpad.net/bugs/1944433 inserted_node = document.createTextNode('\xa0') decoded.range.insertNode(inserted_node) rect = decoded.range.getBoundingClientRect() # 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. # Unless the flag is set to use the end of the range: # (because ranges indices may not exceed the range, # but CFI offsets are allowed to be one past the end.) if decoded.use_range_end_pos: inline_vs_pos = scroll_viewport.rect_inline_end(rect) else: inline_vs_pos = scroll_viewport.rect_inline_start(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) if inserted_node: inserted_node.parentNode.removeChild(inserted_node) # 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 spatial 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_spatial_offset_to_document_position(decoded): node = decoded.node rect = node.getBoundingClientRect() percentx, percenty = decoded.x, decoded.y # If we have a spatial 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_spatial_offset_to_document_position(decoded) # No range, so we can't use that, and no node, so any spatial offset is meaningless return None, None def scroll_to(cfi, callback, doc): # {{{ decoded = decode_with_range(cfi, doc) if not decoded: print('No location information found for cfi: ' + cfi) return if jstype(decoded.time) is 'number': set_current_time(decoded.node, decoded.time) def handle_text_node(): # Character offset r = decoded.range so, eo = r.startOffset, r.endOffset original_node = r.startContainer # 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 v'var i = 0; i < node_parent.childNodes.length; i++': if node_parent.childNodes[i].isSameNode(original_node): original_node_index = i break ndoc = original_node.ownerDocument or document span = ndoc.createElement('span') span.setAttribute('style', 'border-width: 0; padding: 0; margin: 0') r.surroundContents(span) scroll_viewport.scroll_into_view(span) return def (): nonlocal original_node, r # Remove the span and get the new position now that scrolling # has (hopefully) completed p = span.parentNode for node in span.childNodes: span.removeChild(node) p.insertBefore(node, span) p.removeChild(span) p.normalize() original_node = node_parent.childNodes[original_node_index] if not original_node: # something bad happened, give up return # Reset the range to what it was before the span was added offset = so while offset > -1: try: r.setStart(original_node, offset) break except: offset -= 1 offset = eo while offset > -1: try: r.setEnd(original_node, offset) break except: offset -= 1 doc_x, doc_y = decoded_range_to_document_position(decoded) # 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() if callback: callback(doc_x, doc_y) def handle_element_node(): node = decoded.node if node.nodeType is Node.TEXT_NODE: node = decoded.node = node.parentNode if not node.scrollIntoView: return scroll_viewport.scroll_into_view(node) return def (): doc_x, doc_y = decoded_node_or_spatial_offset_to_document_position(decoded) # 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() if callback: callback(doc_x, doc_y) if decoded.range is not None: fn = handle_text_node() else: fn = handle_element_node() setTimeout(fn, 10) # }}} def at_point(x, y): # {{{ # The CFI at the specified point. Different to at() in that this method # returns null if there is an error, and also calculates a point from # the CFI and returns null if the calculated point is far from the # original point. def dist(p1, p2): Math.sqrt(Math.pow(p1[0] - p2[0], 2), Math.pow(p1[1] - p2[1], 2)) try: cfi = at(x, y) except: cfi = None if cfi is None: return None try: decoded = decode_with_range(cfi) except: decoded = None if not decoded: return None if cfi: try: cfix, cfiy = decoded_to_document_position(decoded) except: cfix = cfiy = None if cfix is None or cfiy is None or dist(scroll_viewport.viewport_to_document(x, y), [cfix, cfiy]) > 50: cfi = None return cfi # }}} def at_current(): # {{{ # We subtract one because the actual position query for CFI elements is relative to the # viewport, and the viewport coordinates actually go from 0 to the size - 1. # If we don't do this, the first line of queries will always fail in those cases where we # start at the right of the viewport because they'll be outside the viewport wini, winb = scroll_viewport.inline_size() - 1, scroll_viewport.block_size() - 1 # Don't let the deltas go too small or too large, to prevent perverse cases where # we don't loop at all, loop too many times, or skip elements because we're # looping too fast. deltai = deltab = max(8, min(Math.ceil(rem_size() / 2), 32)) # 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_vertical_mode if scroll_viewport.vertical_writing_mode else at_point 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: # print(f'found CFI at {curi}, {curb} {cfi}\n\t{starti} {deltai} {endi} {startb} {deltab} {endb}') return cfi curi += deltai curb = startb cfi = None while low_boundb <= curb <= up_boundb and not cfi: cfi = i_loop(curb) curb += deltab if cfi: return cfi # Use a spatial offset on the html element, since we could not find a # normal CFI x, y = scroll_viewport.x() + (0 if scroll_viewport.ltr else scroll_viewport.width()), scroll_viewport.y() de = document.documentElement rect = de.getBoundingClientRect() px = (x * 100) / rect.width py = (y * 100) / rect.height cfi = str.format('/2@{}:{}', fstr(px), fstr(py)) return cfi # }}} def cfi_for_selection(r): # {{{ if not r: r = window.getSelection().getRangeAt(0) def pos(container, offset): nt = container.nodeType if nt is Node.TEXT_NODE or nt is Node.CDATA_SECTION_NODE or nt is Node.COMMENT_NODE: return container, offset if nt is Node.ELEMENT_NODE and offset and container.childNodes.length > offset: return container.childNodes[offset], 0 return container, offset return { 'start': encode(r.startContainer.ownerDocument, *pos(r.startContainer, r.startOffset)), 'end': encode(r.endContainer.ownerDocument, *pos(r.endContainer, r.endOffset)), } # }}} def range_from_cfi(start, end): # {{{ start = decode(start) end = decode(end) if not start or start.error or not end or end.error: return r = document.createRange() r.setStart(start.node, start.offset) r.setEnd(end.node, end.offset) return r # }}}