1import itertools 2import math 3import time 4from collections import OrderedDict, Iterable 5from typing import Optional 6 7from AnyQt.QtCore import ( 8 Qt, QAbstractItemModel, QByteArray, QBuffer, QIODevice, 9 QSize) 10from AnyQt.QtGui import QColor, QBrush, QIcon 11from AnyQt.QtWidgets import QGraphicsScene, QTableView, QMessageBox, QStyle 12 13from orangewidget.io import PngFormat 14from orangewidget.utils import getdeepattr 15 16__all__ = ["Report", 17 "bool_str", "colored_square", 18 "plural", "plural_w", 19 "clip_string", "clipped_list", 20 "get_html_img", "get_html_section", "get_html_subsection", 21 "list_legend", 22 "render_items", "render_items_vert"] 23 24 25def try_(func, default=None): 26 """Try return the result of func, else return default.""" 27 try: 28 return func() 29 except Exception: 30 return default 31 32 33class Report: 34 """ 35 A class that adds report-related methods to the widget. 36 """ 37 report_html = "" 38 name = "" 39 40 # Report view. The canvas framework will override this when it needs to 41 # route reports to a specific window. 42 # `friend class WidgetsScheme` 43 __report_view = None # type: Optional[Callable[[], OWReport]] 44 45 def _get_designated_report_view(self): 46 # OWReport is a Report 47 from orangewidget.report.owreport import OWReport 48 if self.__report_view is not None: 49 return self.__report_view() 50 else: 51 return OWReport.get_instance() 52 53 def show_report(self): 54 """ 55 Raise the report window. 56 """ 57 self.create_report_html() 58 from orangewidget.report.owreport import HAVE_REPORT 59 60 report = self._get_designated_report_view() 61 if not HAVE_REPORT and not report.have_report_warning_shown: 62 QMessageBox.critical( 63 None, "Missing Component", 64 "Orange can not display reports, because your installation " 65 "contains neither WebEngine nor WebKit.\n\n" 66 "If you installed Orange with conda or pip, try using another " 67 "PyQt distribution. " 68 "If you installed Orange with a standard installer, please " 69 "report this bug." 70 ) 71 report.have_report_warning_shown = True 72 73 # Should really have a signal `report_ready` or similar to decouple 74 # the implementations. 75 report.make_report(self) 76 report.show() 77 report.raise_() 78 79 def get_widget_name_extension(self): 80 """ 81 Return the text that is added to the section name in the report. 82 83 For instance, the Distribution widget adds the name of the attribute 84 whose distribution is shown. 85 86 :return: str or None 87 """ 88 return None 89 90 def create_report_html(self): 91 """ Start a new section in report and call :obj:`send_report` method 92 to add content.""" 93 self.report_html = '<section class="section">' 94 self.report_html += get_html_section(self.name) 95 self.report_html += '<div class="content">\n' 96 self.send_report() 97 self.report_html += '</div></section>\n\n' 98 99 @staticmethod 100 def _fix_args(name, items): 101 if items is None: 102 return "", name 103 else: 104 return name, items 105 106 def report_items(self, name, items=None): 107 """ 108 Add a sequence of pairs or an `OrderedDict` as a HTML list to report. 109 110 The first argument, `name` can be omitted. 111 112 :param name: report section name (can be omitted) 113 :type name: str or tuple or OrderedDict 114 :param items: a sequence of items 115 :type items: list or tuple or OrderedDict 116 """ 117 name, items = self._fix_args(name, items) 118 self.report_name(name) 119 self.report_html += render_items(items) 120 121 def report_name(self, name): 122 """ Add a section name to the report""" 123 if name != "": 124 self.report_html += get_html_subsection(name) 125 126 def report_plot(self, name=None, plot=None): 127 """ 128 Add a plot to the report. 129 130 Both arguments can be omitted. 131 132 - `report_plot("graph name", self.plotView)` reports plot 133 `self.plotView` with name `"graph name"` 134 - `report_plot(self.plotView) reports plot without name 135 - `report_plot()` reports plot stored in attribute whose name is 136 taken from `self.graph_name` 137 - `report_plot("graph name")` reports plot stored in attribute 138 whose name is taken from `self.graph_name` 139 140 :param name: report section name (can be omitted) 141 :type name: str or tuple or OrderedDict 142 :param plot: plot widget 143 :type plot: 144 QGraphicsScene or pyqtgraph.PlotItem or pyqtgraph.PlotWidget 145 or pyqtgraph.GraphicsWidget. If omitted, the name of the 146 attribute storing the graph is taken from `self.graph_name` 147 """ 148 if not (isinstance(name, str) and plot is None): 149 name, plot = self._fix_args(name, plot) 150 151 from pyqtgraph import PlotWidget, PlotItem, GraphicsWidget, GraphicsView 152 try: 153 from orangewidget.utils.webview import WebviewWidget 154 except ImportError: 155 WebviewWidget = None 156 157 self.report_name(name) 158 if plot is None: 159 plot = getdeepattr(self, self.graph_name) 160 if isinstance(plot, (QGraphicsScene, PlotItem)): 161 self.report_html += get_html_img(plot) 162 elif isinstance(plot, PlotWidget): 163 self.report_html += get_html_img(plot.plotItem) 164 elif isinstance(plot, GraphicsWidget): 165 self.report_html += get_html_img(plot.scene()) 166 elif isinstance(plot, GraphicsView): 167 self.report_html += get_html_img(plot) 168 elif WebviewWidget is not None and isinstance(plot, WebviewWidget): 169 try: 170 svg = plot.svg() 171 except (IndexError, ValueError): 172 svg = plot.html() 173 self.report_html += svg 174 175 # noinspection PyBroadException 176 def report_table(self, name, table=None, header_rows=0, header_columns=0, 177 num_format=None): 178 """ 179 Add content of a table to the report. 180 181 The method accepts different kinds of two-dimensional data, including 182 Qt's views and models. 183 184 The first argument, `name` can be omitted if other arguments (except 185 `table`) are passed as keyword arguments. 186 187 :param name: name of the section 188 :type name: str 189 :param table: table to be reported 190 :type table: 191 QAbstractItemModel or QStandardItemModel or two-dimensional list or 192 any object with method `model()` that returns one of the above 193 :param header_rows: the number of rows that are marked as header rows 194 :type header_rows: int 195 :param header_columns: 196 the number of columns that are marked as header columns 197 :type header_columns: int 198 :param num_format: numeric format, e.g. `{:.3}` 199 """ 200 row_limit = 100 201 name, table = self._fix_args(name, table) 202 join = "".join 203 204 def report_abstract_model(model, view=None): 205 columns = [i for i in range(model.columnCount()) 206 if not view or not view.isColumnHidden(i)] 207 rows = [i for i in range(model.rowCount()) 208 if not view or not view.isRowHidden(i)] 209 210 has_horizontal_header = (try_(lambda: not view.horizontalHeader().isHidden()) or 211 try_(lambda: not view.header().isHidden())) 212 has_vertical_header = try_(lambda: not view.verticalHeader().isHidden()) 213 if view is not None: 214 opts = view.viewOptions() 215 decoration_size = QSize(opts.decorationSize) 216 else: 217 decoration_size = QSize(16, 16) 218 219 def item_html(row, col): 220 def data(role=Qt.DisplayRole, 221 orientation=Qt.Horizontal if row is None else Qt.Vertical): 222 if row is None or col is None: 223 return model.headerData(col if row is None else row, 224 orientation, role) 225 data_ = model.data(model.index(row, col), role) 226 if isinstance(data_, QGraphicsScene): 227 data_ = get_html_img( 228 data_, 229 max_height=view.verticalHeader().defaultSectionSize() 230 ) 231 elif isinstance(data_, QIcon): 232 data_ = get_icon_html(data_, size=decoration_size) 233 return data_ 234 235 selected = (view.selectionModel().isSelected(model.index(row, col)) 236 if view and row is not None and col is not None else False) 237 238 fgcolor = data(Qt.ForegroundRole) 239 fgcolor = (QBrush(fgcolor).color().name() 240 if isinstance(fgcolor, (QBrush, QColor)) else 'black') 241 242 bgcolor = data(Qt.BackgroundRole) 243 bgcolor = (QBrush(bgcolor).color().name() 244 if isinstance(bgcolor, (QBrush, QColor)) else 'transparent') 245 if bgcolor.lower() == '#ffffff': 246 bgcolor = 'transparent' 247 248 font = data(Qt.FontRole) 249 weight = 'font-weight: bold;' if font and font.bold() else '' 250 251 alignment = data(Qt.TextAlignmentRole) or Qt.AlignLeft 252 halign = ('left' if alignment & Qt.AlignLeft else 253 'right' if alignment & Qt.AlignRight else 254 'center') 255 valign = ('top' if alignment & Qt.AlignTop else 256 'bottom' if alignment & Qt.AlignBottom else 257 'middle') 258 return ('<{tag} style="' 259 'color:{fgcolor};' 260 'border:{border};' 261 'background:{bgcolor};' 262 '{weight}' 263 'text-align:{halign};' 264 'vertical-align:{valign};">{decoration}' 265 '{text}</{tag}>'.format( 266 tag='th' if row is None or col is None else 'td', 267 border='1px solid black' if selected else '0', 268 decoration=data(role=Qt.DecorationRole) or '', 269 text=data() or '', weight=weight, fgcolor=fgcolor, 270 bgcolor=bgcolor, halign=halign, valign=valign)) 271 272 stream = [] 273 274 if has_horizontal_header: 275 stream.append('<tr>') 276 if has_vertical_header: 277 stream.append('<th></th>') 278 stream.extend(item_html(None, col) for col in columns) 279 stream.append('</tr>') 280 281 for row in rows[:row_limit]: 282 stream.append('<tr>') 283 if has_vertical_header: 284 stream.append(item_html(row, None)) 285 stream.extend(item_html(row, col) for col in columns) 286 stream.append('</tr>') 287 288 return ''.join(stream) 289 290 if num_format: 291 def fmtnum(s): 292 try: 293 return num_format.format(float(s)) 294 except: 295 return s 296 else: 297 def fmtnum(s): 298 return s 299 300 def report_list(data, 301 header_rows=header_rows, header_columns=header_columns): 302 cells = ["<td>{}</td>", "<th>{}</th>"] 303 return join(" <tr>\n {}</tr>\n".format( 304 join(cells[rowi < header_rows or coli < header_columns] 305 .format(fmtnum(elm)) for coli, elm in enumerate(row)) 306 ) for rowi, row in zip(range(row_limit + header_rows), data)) 307 308 self.report_name(name) 309 n_hidden_rows, n_cols = 0, 1 310 if isinstance(table, QTableView): 311 body = report_abstract_model(table.model(), table) 312 n_hidden_rows = table.model().rowCount() - row_limit 313 n_cols = table.model().columnCount() 314 elif isinstance(table, QAbstractItemModel): 315 body = report_abstract_model(table) 316 n_hidden_rows = table.rowCount() - row_limit 317 n_cols = table.columnCount() 318 elif isinstance(table, Iterable): 319 body = report_list(table, header_rows, header_columns) 320 table = list(table) 321 n_hidden_rows = len(table) - row_limit 322 if len(table) and isinstance(table[0], Iterable): 323 n_cols = len(table[0]) 324 else: 325 body = None 326 327 if n_hidden_rows > 0: 328 body += """<tr><th></th><td colspan='{}'><b>+ {} more</b></td></tr> 329 """.format(n_cols, n_hidden_rows) 330 331 if body: 332 self.report_html += "<table>\n" + body + "</table>" 333 334 # noinspection PyBroadException 335 def report_list(self, name, data=None, limit=1000): 336 """ 337 Add a list to the report. 338 339 The method accepts different kinds of one-dimensional data, including 340 Qt's views and models. 341 342 The first argument, `name` can be omitted. 343 344 :param name: name of the section 345 :type name: str 346 :param data: table to be reported 347 :type data: 348 QAbstractItemModel or any object with method `model()` that 349 returns QAbstractItemModel 350 :param limit: the maximal number of reported items (default: 1000) 351 :type limit: int 352 """ 353 name, data = self._fix_args(name, data) 354 355 def report_abstract_model(model): 356 content = (model.data(model.index(row, 0)) 357 for row in range(model.rowCount())) 358 return clipped_list(content, limit, less_lookups=True) 359 360 self.report_name(name) 361 try: 362 model = data.model() 363 except: 364 model = None 365 if isinstance(model, QAbstractItemModel): 366 txt = report_abstract_model(model) 367 else: 368 txt = "" 369 self.report_html += txt 370 371 def report_paragraph(self, name, text=None): 372 """ 373 Add a paragraph to the report. 374 375 The first argument, `name` can be omitted. 376 377 :param name: name of the section 378 :type name: str 379 :param text: text of the paragraph 380 :type text: str 381 """ 382 name, text = self._fix_args(name, text) 383 self.report_name(name) 384 self.report_html += "<p>{}</p>".format(text) 385 386 def report_caption(self, text): 387 """ 388 Add caption to the report. 389 """ 390 self.report_html += "<p class='caption'>{}</p>".format(text) 391 392 def report_raw(self, name, html=None): 393 """ 394 Add raw HTML to the report. 395 """ 396 name, html = self._fix_args(name, html) 397 self.report_name(name) 398 self.report_html += html 399 400 def combo_value(self, combo): 401 """ 402 Add the value of a combo box to the report. 403 404 The methods assumes that the combo box was created by 405 :obj:`Orange.widget.gui.comboBox`. If the value of the combo equals 406 `combo.emptyString`, this function returns None. 407 """ 408 text = combo.currentText() 409 if text != combo.emptyString: 410 return text 411 412 413def plural(s, number, suffix="s"): 414 """ 415 Insert the number into the string, and make plural where marked, if needed. 416 417 The string should use `{number}` to mark the place(s) where the number is 418 inserted and `{s}` where an "s" needs to be added if the number is not 1. 419 420 For instance, a string could be "I saw {number} dog{s} in the forest". 421 422 Argument `suffix` can be used for some forms or irregular plural, like: 423 424 plural("I saw {number} fox{s} in the forest", x, "es") 425 plural("I say {number} child{s} in the forest", x, "ren") 426 427 :param s: string 428 :type s: str 429 :param number: number 430 :type number: int 431 :param suffix: the suffix to use; default is "s" 432 :type suffix: str 433 :rtype: str 434 """ 435 return s.format(number=number, s=suffix if number % 100 != 1 else "") 436 437 438def plural_w(s, number, suffix="s", capitalize=False): 439 """ 440 Insert the number into the string, and make plural where marked, if needed. 441 442 If the number is smaller or equal to ten, a word is used instead of a 443 numeric representation. 444 445 The string should use `{number}` to mark the place(s) where the number is 446 inserted and `{s}` where an "s" needs to be added if the number is not 1. 447 448 For instance, a string could be "I saw {number} dog{s} in the forest". 449 450 Argument `suffix` can be used for some forms or irregular plural, like: 451 452 plural("I saw {number} fox{s} in the forest", x, "es") 453 plural("I say {number} child{s} in the forest", x, "ren") 454 455 :param s: string 456 :type s: str 457 :param number: number 458 :type number: int 459 :param suffix: the suffix to use; default is "s" 460 :type suffix: str 461 :rtype: str 462 """ 463 numbers = ("zero", "one", "two", "three", "four", "five", "six", "seven", 464 "nine", "ten") 465 number_str = numbers[number] if number < len(numbers) else str(number) 466 if capitalize: 467 number_str = number_str.capitalize() 468 return s.format(number=number_str, s=suffix if number % 100 != 1 else "") 469 470 471def bool_str(v): 472 """Convert a boolean to a string.""" 473 return "Yes" if v else "No" 474 475 476def clip_string(s, limit=1000, sep=None): 477 """ 478 Clip a string at a given character and add "..." if the string was clipped. 479 480 If a separator is specified, the string is not clipped at the given limit 481 but after the last occurence of the separator below the limit. 482 483 :param s: string to clip 484 :type s: str 485 :param limit: number of characters to retain (including "...") 486 :type limit: int 487 :param sep: separator 488 :type sep: str 489 :rtype: str 490 """ 491 if len(s) < limit: 492 return s 493 s = s[:limit - 3] 494 if sep is None: 495 return s 496 sep_pos = s.rfind(sep) 497 if sep_pos == -1: 498 return s 499 return s[:sep_pos + len(sep)] + "..." 500 501 502def clipped_list(items, limit=1000, less_lookups=False, total_min=10, total=""): 503 """ 504 Return a clipped comma-separated representation of the list. 505 506 If `less_lookups` is `True`, clipping will use a generator across the first 507 `(limit + 2) // 3` items only, which suffices even if each item is only a 508 single character long. This is useful in case when retrieving items is 509 expensive, while it is generally slower. 510 511 If there are at least `total_lim` items, and argument `total` is present, 512 the string `total.format(len(items))` is added to the end of string. 513 Argument `total` can be, for instance `"(total: {} variables)"`. 514 515 If `total` is given, `s` cannot be a generator. 516 517 :param items: list 518 :type items: list or another iterable object 519 :param limit: number of characters to retain (including "...") 520 :type limit: int 521 :param total_min: the minimal number of items that triggers adding `total` 522 :type total_min: int 523 :param total: the string that is added if `len(items) >= total_min` 524 :type total: str 525 :param less_lookups: minimize the number of lookups 526 :type less_lookups: bool 527 :return: 528 """ 529 if less_lookups: 530 s = ", ".join(itertools.islice(items, (limit + 2) // 3)) 531 else: 532 s = ", ".join(items) 533 s = clip_string(s, limit, ", ") 534 if total and len(items) >= total_min: 535 s += " " + total.format(len(items)) 536 return s 537 538 539def get_html_section(name): 540 """ 541 Return a new section as HTML, with the given name and a time stamp. 542 543 :param name: section name 544 :type name: str 545 :rtype: str 546 """ 547 datetime = time.strftime("%a %b %d %y, %H:%M:%S") 548 return "<h1>{} <span class='timestamp'>{}</h1>".format(name, datetime) 549 550 551def get_html_subsection(name): 552 """ 553 Return a subsection as HTML, with the given name 554 555 :param name: subsection name 556 :type name: str 557 :rtype: str 558 """ 559 return "<h2>{}</h2>".format(name) 560 561 562def render_items(items): 563 """ 564 Render a sequence of pairs or an `OrderedDict` as a HTML list. 565 566 The function skips the items whose values are `None` or `False`. 567 568 :param items: a sequence of items 569 :type items: list or tuple or OrderedDict 570 :return: rendered content 571 :rtype: str 572 """ 573 if isinstance(items, dict): 574 items = items.items() 575 return "<ul>" + "".join( 576 "<b>{}:</b> {}</br>".format(key, value) for key, value in items 577 if value is not None and value is not False) + "</ul>" 578 579 580def render_items_vert(items): 581 """ 582 Render a sequence of pairs or an `OrderedDict` as a comma-separated list. 583 584 The function skips the items whose values are `None` or `False`. 585 586 :param items: a sequence of items 587 :type items: list or tuple or OrderedDict 588 :return: rendered content 589 :rtype: str 590 """ 591 if isinstance(items, dict): 592 items = items.items() 593 return ", ".join("<b>{}</b>: {}".format(key, value) for key, value in items 594 if value is not None and value is not False) 595 596 597def get_html_img( 598 scene: QGraphicsScene, max_height: Optional[int] = None 599) -> str: 600 """ 601 Create HTML img element with base64-encoded image from the scene. 602 If max_height is not none set the max height of the image in html. 603 """ 604 byte_array = QByteArray() 605 filename = QBuffer(byte_array) 606 filename.open(QIODevice.WriteOnly) 607 PngFormat.write(filename, scene) 608 img_encoded = byte_array.toBase64().data().decode("utf-8") 609 return '<img {} src="data:image/png;base64,{}"/>'.format( 610 ("" if max_height is None 611 else 'style="max-height: {}px"'.format(max_height)), 612 img_encoded 613 ) 614 615 616def get_icon_html(icon: QIcon, size: QSize) -> str: 617 """ 618 Transform an icon to html <img> tag. 619 """ 620 if not size.isValid(): 621 return "" 622 if size.width() < 0 or size.height() < 0: 623 size = QSize(16, 16) # just in case 624 byte_array = QByteArray() 625 buffer = QBuffer(byte_array) 626 buffer.open(QIODevice.WriteOnly) 627 pixmap = icon.pixmap(size) 628 if pixmap.isNull(): 629 return "" 630 pixmap.save(buffer, "PNG") 631 buffer.close() 632 633 dpr = pixmap.devicePixelRatioF() 634 if dpr != 1.0: 635 size_ = pixmap.size() / dpr 636 size_part = ' width="{}" height="{}"'.format( 637 int(math.floor(size_.width())), int(math.floor(size_.height())) 638 ) 639 else: 640 size_part = '' 641 img_encoded = byte_array.toBase64().data().decode("utf-8") 642 return '<img src="data:image/png;base64,{}"{}/>'.format(img_encoded, size_part) 643 644 645def colored_square(r, g, b): 646 return '<span class="legend-square" ' \ 647 'style="background-color: rgb({}, {}, {})"></span>'.format(r, g, b) 648 649 650def list_legend(model, selected=None): 651 """ 652 Create HTML with a legend constructed from a Qt model or a view. 653 654 This function can be used for reporting the legend for graph in widgets 655 in which the colors representing different values are shown in a listbox 656 with colored icons. The function returns a string with values from the 657 listbox, preceded by squares of the corresponding colors. 658 659 The model must return data for Qt.DecorationRole. If a view is passed as 660 an argument, it has to have method `model()`. 661 662 :param model: model or view, usually a list box 663 :param selected: if given, only items with the specified indices are shown 664 """ 665 if hasattr(model, "model"): 666 model = model.model() 667 legend = "" 668 for row in range(model.rowCount()): 669 if selected is not None and row not in selected: 670 continue 671 index = model.index(row, 0) 672 icon = model.data(index, Qt.DecorationRole) 673 r, g, b, a = QColor( 674 icon.pixmap(12, 12).toImage().pixel(0, 0)).getRgb() 675 text = model.data(index, Qt.DisplayRole) 676 legend += colored_square(r, g, b) + \ 677 '<span class="legend-item">{}</span>'.format(text) 678 return legend 679