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