calibre/src/pyj/range_utils.pyj
Kovid Goyal d346c19c80
E-book viewer: Fix modifying an existing highlight causing duplicates to be created in some books. Fixes #2122747 [Private bug](https://bugs.launchpad.net/calibre/+bug/2122747)
Probably the issue manifests when using box-sizing: border-box in the
book CSS, but I didnt bother to check for the exact cause.
2025-09-15 11:13:45 +05:30

274 lines
9.2 KiB
Python

# vim:fileencoding=utf-8
# License: GPL v3 Copyright: 2020, Kovid Goyal <kovid at kovidgoyal.net>
# globals: NodeFilter, Range
from __python__ import bound_methods, hash_literals
def is_non_empty_text_node(node):
return (node.nodeType is Node.TEXT_NODE or node.nodeType is Node.CDATA_SECTION_NODE) and node.nodeValue.length > 0
def is_element_visible(elem):
s = window.getComputedStyle(elem)
return s.display is not 'none' and s.visibility is not 'hidden'
def is_node_visible(node):
if node.nodeType is not Node.ELEMENT_NODE:
node = node.parentElement
if not node:
return False
current = node
while current:
if not is_element_visible(current):
return False
current = current.parentElement
return True
def select_nodes_from_range(r, predicate):
parent = r.commonAncestorContainer
doc = parent.ownerDocument or document
iterator = doc.createNodeIterator(parent)
in_range = False
ans = v'[]'
check_for_end = not (r.startContainer.isSameNode(r.endContainer) and r.startContainer.isSameNode(parent))
while True:
node = iterator.nextNode()
if not node:
break
if not in_range and node.isSameNode(r.startContainer):
in_range = True
if in_range:
if predicate(node):
ans.push(node)
if check_for_end and node.isSameNode(r.endContainer):
break
return ans
def select_first_node_from_range(r, predicate):
parent = r.commonAncestorContainer
doc = parent.ownerDocument or document
iterator = doc.createNodeIterator(parent)
in_range = False
check_for_end = not (r.startContainer.isSameNode(r.endContainer) and r.startContainer.isSameNode(parent))
while True:
node = iterator.nextNode()
if not node:
break
if not in_range and node.isSameNode(r.startContainer):
in_range = True
if in_range:
if predicate(node):
return node
if check_for_end and node.isSameNode(r.endContainer):
break
def text_nodes_in_range(r):
return select_nodes_from_range(r, is_non_empty_text_node)
def all_annots_in_range(r, annot_id_uuid_map, ans):
parent = r.commonAncestorContainer
doc = parent.ownerDocument or document
iterator = doc.createNodeIterator(parent)
is_full_tree = parent is doc.documentElement
in_range = is_full_tree
check_for_end = not is_full_tree and not (r.startContainer.isSameNode(r.endContainer) and r.startContainer.isSameNode(parent))
while True:
node = iterator.nextNode()
if not node:
break
if not in_range and node.isSameNode(r.startContainer):
in_range = True
if in_range:
if node.dataset and node.dataset.calibreRangeWrapper:
annot_id = annot_id_uuid_map[node.dataset.calibreRangeWrapper]
if annot_id:
if not ans:
return annot_id
ans[annot_id] = True
if check_for_end and node.isSameNode(r.endContainer):
break
return ans
def first_annot_in_range(r, annot_id_uuid_map):
return all_annots_in_range(r, annot_id_uuid_map)
def all_annots_in_selection(sel, annot_id_uuid_map):
ans = v'{}'
for i in range(sel.rangeCount):
all_annots_in_range(sel.getRangeAt(i), annot_id_uuid_map, ans)
return Object.keys(ans)
def remove(node):
if node.parentNode:
node.parentNode.removeChild(node)
def replace_node(replacement, node):
p = node.parentNode
p.insertBefore(replacement, node)
remove(node)
return p
def unwrap(node):
r = (node.ownerDocument or document).createRange()
r.selectNodeContents(node)
p = replace_node(r.extractContents(), node)
if p:
p.normalize()
def unwrap_crw(crw):
for node in document.querySelectorAll(f'span[data-calibre-range-wrapper="{crw}"]'):
unwrap(node)
def unwrap_all_crw():
for node in document.querySelectorAll('span[data-calibre-range-wrapper]'):
unwrap(node)
def select_crw(crw):
nodes = document.querySelectorAll(f'span[data-calibre-range-wrapper="{crw}"]')
if nodes and nodes.length:
r = document.createRange()
r.setStart(nodes[0].firstChild, 0)
r.setEnd(nodes[-1].lastChild, nodes[-1].lastChild.nodeValue.length)
sel = window.getSelection()
sel.removeAllRanges()
sel.addRange(r)
return True
print(f'range-wrapper: {crw} does not exist')
return False
def wrap_range(r, wrapper):
try:
r.surroundContents(wrapper)
except:
wrapper.appendChild(r.extractContents())
r.insertNode(wrapper)
def create_wrapper_function(wrapper_elem, r, intersecting_wrappers, process_wrapper, all_wrappers):
start_node = r.startContainer
end_node = r.endContainer
start_offset = r.startOffset
end_offset = r.endOffset
def wrap_node(node):
nonlocal start_node, end_node, start_offset, end_offset
current_range = (node.ownerDocument or document).createRange()
current_wrapper = wrapper_elem.cloneNode()
current_range.selectNodeContents(node)
# adjust start and end in case the current node is one of the
# boundaries of the original range
if node.isSameNode(start_node):
current_range.setStart(node, start_offset)
start_node = current_wrapper
start_offset = 0
if node.isSameNode(end_node):
current_range.setEnd(node, end_offset)
end_node = current_wrapper
end_offset = 1
if current_range.collapsed:
# Don't wrap empty ranges. This is needed otherwise two adjacent
# selections of text will incorrectly be detected as overlapping.
# For example: highlight abc then def in the word abcdef here the
# second highlight's first range is the collapsed range at the end
# of <span wrapper-for-first-highlight>abc<span wrapper-for-2nd></span></span>
return
crw = node.parentNode?.dataset?.calibreRangeWrapper
if crw:
intersecting_wrappers[crw] = True
wrap_range(current_range, current_wrapper)
if process_wrapper:
process_wrapper(current_wrapper)
all_wrappers.push(current_wrapper)
return wrap_node
wrapper_counter = 0
def wrap_text_in_range(styler, r, class_to_add_to_last, process_wrapper):
if not r:
sel = window.getSelection()
if not sel or not sel.rangeCount:
return None, v'[]'
r = sel.getRangeAt(0)
if r.isCollapsed:
return None, v'[]'
wrapper_elem = document.createElement('span')
wrapper_elem.dataset.calibreRangeWrapper = v'++wrapper_counter' + ''
if styler:
styler(wrapper_elem)
intersecting_wrappers = {}
all_wrappers = v'[]'
wrap_node = create_wrapper_function(wrapper_elem, r, intersecting_wrappers, process_wrapper, all_wrappers)
text_nodes_in_range(r).map(wrap_node)
ancestor = r.commonAncestorContainer
if ancestor.nodeType is Node.TEXT_NODE:
ancestor = ancestor.parentNode
# remove any empty text nodes created by surroundContents() on either
# side of the wrapper. This happens for instance on Chrome when
# wrapping all text inside <i>some text</i>
ancestor.normalize()
crw = wrapper_elem.dataset.calibreRangeWrapper
v'delete intersecting_wrappers[crw]'
if class_to_add_to_last and all_wrappers.length:
all_wrappers[-1].classList.add(class_to_add_to_last)
return crw, Object.keys(intersecting_wrappers)
def last_span_for_crw(crw):
nodes = document.querySelectorAll(f'span[data-calibre-range-wrapper="{crw}"]')
if nodes and nodes.length:
return nodes[-1]
def reset_highlight_counter():
nonlocal wrapper_counter
wrapper_counter = 0
def get_annot_id_for(node, offset, annot_id_uuid_map):
if not node:
return
if node.nodeType is Node.ELEMENT_NODE:
if node.dataset?.calibreRangeWrapper:
return annot_id_uuid_map[node.dataset.calibreRangeWrapper]
if offset is 0:
if node.firstChild?.nodeType is Node.ELEMENT_NODE and node.firstChild.dataset?.calibreRangeWrapper:
return annot_id_uuid_map[node.firstChild.dataset.calibreRangeWrapper]
elif offset < node.childNodes.length:
node = node.childNodes[offset]
return get_annot_id_for(node, 0, annot_id_uuid_map)
elif node.nodeType is Node.TEXT_NODE:
if node.parentNode?.nodeType is Node.ELEMENT_NODE and node.parentNode.dataset?.calibreRangeWrapper:
return annot_id_uuid_map[node.parentNode.dataset.calibreRangeWrapper]
def highlight_associated_with_selection(sel, annot_id_uuid_map):
# Return the annotation id for a highlight intersecting the selection
if sel.rangeCount:
annot_id = get_annot_id_for(sel.focusNode, sel.focusOffset, annot_id_uuid_map) or get_annot_id_for(sel.anchorNode, sel.anchorOffset, annot_id_uuid_map)
if annot_id:
return annot_id
for v'var i = 0; i < sel.rangeCount; i++':
r = sel.getRangeAt(i)
annot_id = first_annot_in_range(r, annot_id_uuid_map)
if annot_id:
return annot_id