1# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: 2 3# Copyright 2014-2021 Florian Bruhin (The Compiler) <mail@qutebrowser.org> 4# 5# This file is part of qutebrowser. 6# 7# qutebrowser is free software: you can redistribute it and/or modify 8# it under the terms of the GNU General Public License as published by 9# the Free Software Foundation, either version 3 of the License, or 10# (at your option) any later version. 11# 12# qutebrowser is distributed in the hope that it will be useful, 13# but WITHOUT ANY WARRANTY; without even the implied warranty of 14# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15# GNU General Public License for more details. 16# 17# You should have received a copy of the GNU General Public License 18# along with qutebrowser. If not, see <https://www.gnu.org/licenses/>. 19 20"""QtWebKit specific part of the web element API.""" 21 22from typing import cast, TYPE_CHECKING, Iterator, List, Optional, Set 23 24from PyQt5.QtCore import QRect, Qt 25from PyQt5.QtWebKit import QWebElement, QWebSettings 26from PyQt5.QtWebKitWidgets import QWebFrame 27 28from qutebrowser.config import config 29from qutebrowser.utils import log, utils, javascript, usertypes 30from qutebrowser.browser import webelem 31 32if TYPE_CHECKING: 33 from qutebrowser.browser.webkit import webkittab 34 35 36class IsNullError(webelem.Error): 37 38 """Gets raised by WebKitElement if an element is null.""" 39 40 41class WebKitElement(webelem.AbstractWebElement): 42 43 """A wrapper around a QWebElement.""" 44 45 _tab: 'webkittab.WebKitTab' 46 47 def __init__(self, elem: QWebElement, tab: 'webkittab.WebKitTab') -> None: 48 super().__init__(tab) 49 if isinstance(elem, self.__class__): 50 raise TypeError("Trying to wrap a wrapper!") 51 if elem.isNull(): 52 raise IsNullError('{} is a null element!'.format(elem)) 53 self._elem = elem 54 55 def __str__(self) -> str: 56 self._check_vanished() 57 return self._elem.toPlainText() 58 59 def __eq__(self, other: object) -> bool: 60 if not isinstance(other, WebKitElement): 61 return NotImplemented 62 return self._elem == other._elem 63 64 def __getitem__(self, key: str) -> str: 65 self._check_vanished() 66 if key not in self: 67 raise KeyError(key) 68 return self._elem.attribute(key) 69 70 def __setitem__(self, key: str, val: str) -> None: 71 self._check_vanished() 72 self._elem.setAttribute(key, val) 73 74 def __delitem__(self, key: str) -> None: 75 self._check_vanished() 76 if key not in self: 77 raise KeyError(key) 78 self._elem.removeAttribute(key) 79 80 def __contains__(self, key: object) -> bool: 81 assert isinstance(key, str) 82 self._check_vanished() 83 return self._elem.hasAttribute(key) 84 85 def __iter__(self) -> Iterator[str]: 86 self._check_vanished() 87 yield from self._elem.attributeNames() 88 89 def __len__(self) -> int: 90 self._check_vanished() 91 return len(self._elem.attributeNames()) 92 93 def _check_vanished(self) -> None: 94 """Raise an exception if the element vanished (is null).""" 95 if self._elem.isNull(): 96 raise IsNullError('Element {} vanished!'.format(self._elem)) 97 98 def has_frame(self) -> bool: 99 self._check_vanished() 100 return self._elem.webFrame() is not None 101 102 def geometry(self) -> QRect: 103 self._check_vanished() 104 return self._elem.geometry() 105 106 def classes(self) -> Set[str]: 107 self._check_vanished() 108 return set(self._elem.classes()) 109 110 def tag_name(self) -> str: 111 """Get the tag name for the current element.""" 112 self._check_vanished() 113 return self._elem.tagName().lower() 114 115 def outer_xml(self) -> str: 116 """Get the full HTML representation of this element.""" 117 self._check_vanished() 118 return self._elem.toOuterXml() 119 120 def is_content_editable_prop(self) -> bool: 121 self._check_vanished() 122 val = self._elem.evaluateJavaScript('this.isContentEditable || false') 123 assert isinstance(val, bool) 124 return val 125 126 def value(self) -> webelem.JsValueType: 127 self._check_vanished() 128 val = self._elem.evaluateJavaScript('this.value') 129 assert isinstance(val, (int, float, str, type(None))), val 130 return val 131 132 def set_value(self, value: webelem.JsValueType) -> None: 133 self._check_vanished() 134 if self._tab.is_deleted(): 135 raise webelem.OrphanedError("Tab containing element vanished") 136 if self.is_content_editable(): 137 log.webelem.debug("Filling {!r} via set_text.".format(self)) 138 assert isinstance(value, str) 139 self._elem.setPlainText(value) 140 else: 141 log.webelem.debug("Filling {!r} via javascript.".format(self)) 142 value = javascript.to_js(value) 143 self._elem.evaluateJavaScript("this.value={}".format(value)) 144 145 def dispatch_event(self, event: str, 146 bubbles: bool = False, 147 cancelable: bool = False, 148 composed: bool = False) -> None: 149 self._check_vanished() 150 log.webelem.debug("Firing event on {!r} via javascript.".format(self)) 151 self._elem.evaluateJavaScript( 152 "this.dispatchEvent(new Event({}, " 153 "{{'bubbles': {}, 'cancelable': {}, 'composed': {}}}))" 154 .format(javascript.to_js(event), 155 javascript.to_js(bubbles), 156 javascript.to_js(cancelable), 157 javascript.to_js(composed))) 158 159 def caret_position(self) -> int: 160 """Get the text caret position for the current element.""" 161 self._check_vanished() 162 pos = self._elem.evaluateJavaScript('this.selectionStart') 163 if pos is None: 164 return 0 165 return int(pos) 166 167 def insert_text(self, text: str) -> None: 168 self._check_vanished() 169 if not self.is_editable(strict=True): 170 raise webelem.Error("Element is not editable!") 171 log.webelem.debug("Inserting text into element {!r}".format(self)) 172 self._elem.evaluateJavaScript(""" 173 var text = {}; 174 var event = document.createEvent("TextEvent"); 175 event.initTextEvent("textInput", true, true, null, text); 176 this.dispatchEvent(event); 177 """.format(javascript.to_js(text))) 178 179 def _parent(self) -> Optional['WebKitElement']: 180 """Get the parent element of this element.""" 181 self._check_vanished() 182 elem = cast(Optional[QWebElement], self._elem.parent()) 183 if elem is None or elem.isNull(): 184 return None 185 186 return WebKitElement(elem, tab=self._tab) 187 188 def _rect_on_view_js(self) -> Optional[QRect]: 189 """Javascript implementation for rect_on_view.""" 190 # FIXME:qtwebengine maybe we can reuse this? 191 rects = self._elem.evaluateJavaScript("this.getClientRects()") 192 if rects is None: # pragma: no cover 193 # On e.g. Void Linux with musl libc, the stack size is too small 194 # for jsc, and running JS will fail. If that happens, fall back to 195 # the Python implementation. 196 # https://github.com/qutebrowser/qutebrowser/issues/1641 197 return None 198 199 text = utils.compact_text(self._elem.toOuterXml(), 500) 200 log.webelem.vdebug( # type: ignore[attr-defined] 201 "Client rectangles of element '{}': {}".format(text, rects)) 202 203 for i in range(int(rects.get("length", 0))): 204 rect = rects[str(i)] 205 width = rect.get("width", 0) 206 height = rect.get("height", 0) 207 if width > 1 and height > 1: 208 # fix coordinates according to zoom level 209 zoom = self._elem.webFrame().zoomFactor() 210 if not config.val.zoom.text_only: 211 rect["left"] *= zoom 212 rect["top"] *= zoom 213 width *= zoom 214 height *= zoom 215 rect = QRect(int(rect["left"]), int(rect["top"]), 216 int(width), int(height)) 217 218 frame = cast(Optional[QWebFrame], self._elem.webFrame()) 219 while frame is not None: 220 # Translate to parent frames' position (scroll position 221 # is taken care of inside getClientRects) 222 rect.translate(frame.geometry().topLeft()) 223 frame = frame.parentFrame() 224 225 return rect 226 227 return None 228 229 def _rect_on_view_python(self, elem_geometry: Optional[QRect]) -> QRect: 230 """Python implementation for rect_on_view.""" 231 if elem_geometry is None: 232 geometry = self._elem.geometry() 233 else: 234 geometry = elem_geometry 235 rect = QRect(geometry) 236 237 frame = cast(Optional[QWebFrame], self._elem.webFrame()) 238 while frame is not None: 239 rect.translate(frame.geometry().topLeft()) 240 rect.translate(frame.scrollPosition() * -1) 241 frame = cast(Optional[QWebFrame], frame.parentFrame()) 242 243 return rect 244 245 def rect_on_view(self, *, elem_geometry: QRect = None, 246 no_js: bool = False) -> QRect: 247 """Get the geometry of the element relative to the webview. 248 249 Uses the getClientRects() JavaScript method to obtain the collection of 250 rectangles containing the element and returns the first rectangle which 251 is large enough (larger than 1px times 1px). If all rectangles returned 252 by getClientRects() are too small, falls back to elem.rect_on_view(). 253 254 Skipping of small rectangles is due to <a> elements containing other 255 elements with "display:block" style, see 256 https://github.com/qutebrowser/qutebrowser/issues/1298 257 258 Args: 259 elem_geometry: The geometry of the element, or None. 260 Calling QWebElement::geometry is rather expensive so 261 we want to avoid doing it twice. 262 no_js: Fall back to the Python implementation 263 """ 264 self._check_vanished() 265 266 # First try getting the element rect via JS, as that's usually more 267 # accurate 268 if elem_geometry is None and not no_js: 269 rect = self._rect_on_view_js() 270 if rect is not None: 271 return rect 272 273 # No suitable rects found via JS, try via the QWebElement API 274 return self._rect_on_view_python(elem_geometry) 275 276 def _is_hidden_css(self) -> bool: 277 """Check if the given element is hidden via CSS.""" 278 attr_values = { 279 attr: self._elem.styleProperty(attr, QWebElement.ComputedStyle) 280 for attr in ['visibility', 'display', 'opacity'] 281 } 282 invisible = attr_values['visibility'] == 'hidden' 283 none_display = attr_values['display'] == 'none' 284 zero_opacity = attr_values['opacity'] == '0' 285 286 is_framework = ('ace_text-input' in self.classes() or 287 'custom-control-input' in self.classes()) 288 return invisible or none_display or (zero_opacity and not is_framework) 289 290 def _is_visible(self, mainframe: QWebFrame) -> bool: 291 """Check if the given element is visible in the given frame. 292 293 This is not public API because it can't be implemented easily here with 294 QtWebEngine, and is only used via find_css(..., only_visible=True) via 295 the tab API. 296 """ 297 self._check_vanished() 298 if self._is_hidden_css(): 299 return False 300 301 elem_geometry = self._elem.geometry() 302 if not elem_geometry.isValid() and elem_geometry.x() == 0: 303 # Most likely an invisible link 304 return False 305 # First check if the element is visible on screen 306 elem_rect = self.rect_on_view(elem_geometry=elem_geometry) 307 mainframe_geometry = mainframe.geometry() 308 if elem_rect.isValid(): 309 visible_on_screen = mainframe_geometry.intersects(elem_rect) 310 else: 311 # We got an invalid rectangle (width/height 0/0 probably), but this 312 # can still be a valid link. 313 visible_on_screen = mainframe_geometry.contains( 314 elem_rect.topLeft()) 315 # Then check if it's visible in its frame if it's not in the main 316 # frame. 317 elem_frame = self._elem.webFrame() 318 framegeom = QRect(elem_frame.geometry()) 319 if not framegeom.isValid(): 320 visible_in_frame = False 321 elif elem_frame.parentFrame() is not None: 322 framegeom.moveTo(0, 0) 323 framegeom.translate(elem_frame.scrollPosition()) 324 if elem_geometry.isValid(): 325 visible_in_frame = framegeom.intersects(elem_geometry) 326 else: 327 # We got an invalid rectangle (width/height 0/0 probably), but 328 # this can still be a valid link. 329 visible_in_frame = framegeom.contains(elem_geometry.topLeft()) 330 else: 331 visible_in_frame = visible_on_screen 332 return all([visible_on_screen, visible_in_frame]) 333 334 def remove_blank_target(self) -> None: 335 elem: Optional[WebKitElement] = self 336 for _ in range(5): 337 if elem is None: 338 break 339 if elem.is_link(): 340 if elem.get('target', None) == '_blank': 341 elem['target'] = '_top' 342 break 343 elem = elem._parent() # pylint: disable=protected-access 344 345 def delete(self) -> None: 346 self._elem.evaluateJavaScript('this.remove();') 347 348 def _move_text_cursor(self) -> None: 349 if self.is_text_input() and self.is_editable(): 350 self._tab.caret.move_to_end_of_document() 351 352 def _requires_user_interaction(self) -> bool: 353 return False 354 355 def _click_editable(self, click_target: usertypes.ClickTarget) -> None: 356 ok = self._elem.evaluateJavaScript('this.focus(); true;') 357 if ok: 358 self._move_text_cursor() 359 else: 360 log.webelem.debug("Failed to focus via JS, falling back to event") 361 self._click_fake_event(click_target) 362 363 def _click_js(self, click_target: usertypes.ClickTarget) -> None: 364 settings = QWebSettings.globalSettings() 365 attribute = QWebSettings.JavascriptCanOpenWindows 366 could_open_windows = settings.testAttribute(attribute) 367 settings.setAttribute(attribute, True) 368 ok = self._elem.evaluateJavaScript('this.click(); true;') 369 settings.setAttribute(attribute, could_open_windows) 370 if not ok: 371 log.webelem.debug("Failed to click via JS, falling back to event") 372 self._click_fake_event(click_target) 373 374 def _click_fake_event(self, click_target: usertypes.ClickTarget, 375 button: Qt.MouseButton = Qt.LeftButton) -> None: 376 self._tab.data.override_target = click_target 377 super()._click_fake_event(click_target) 378 379 380def get_child_frames(startframe: QWebFrame) -> List[QWebFrame]: 381 """Get all children recursively of a given QWebFrame. 382 383 Loosely based on https://blog.nextgenetics.net/?e=64 384 385 Args: 386 startframe: The QWebFrame to start with. 387 388 Return: 389 A list of children QWebFrame, or an empty list. 390 """ 391 results = [] 392 frames = [startframe] 393 while frames: 394 new_frames: List[QWebFrame] = [] 395 for frame in frames: 396 results.append(frame) 397 new_frames += frame.childFrames() 398 frames = new_frames 399 return results 400