Source code for orangewidget.report.report

from orangecanvas.utils.localization.si import plsi, plsi_sz, z_besedo
from orangecanvas.utils.localization import Translator  # pylint: disable=wrong-import-order
_tr = Translator("orangewidget", "biolab.si", "Orange")
del Translator
import itertools
import math
import time
import warnings
from collections.abc import Iterable
from typing import Optional

from AnyQt.QtCore import (
    Qt, QAbstractItemModel, QByteArray, QBuffer, QIODevice, QLocale,
    QSize)
from AnyQt.QtGui import QColor, QBrush, QIcon, QPalette
from AnyQt.QtWidgets import \
    QGraphicsScene, QTableView, QMessageBox, QGraphicsWidget, QGraphicsView

from orangewidget.io import PngFormat
from orangewidget.utils import getdeepattr

__all__ = ["Report",
           "bool_str", "colored_square",
           "plural", "plural_w",
           "clip_string", "clipped_list",
           "get_html_img", "get_html_section", "get_html_subsection",
           "list_legend",
           "render_items", "render_items_vert"]


def try_(func, default=None):
    """Try return the result of func, else return default."""
    try:
        return func()
    except Exception:
        return default


class Report:
    """
    A class that adds report-related methods to the widget.
    """
    report_html = ""
    name = ""

    # Report view. The canvas framework will override this when it needs to
    # route reports to a specific window.
    # `friend class WidgetsScheme`
    __report_view = None  # type: Optional[Callable[[], OWReport]]

    def _get_designated_report_view(self):
        # OWReport is a Report
        from orangewidget.report.owreport import OWReport
        if self.__report_view is not None:
            return self.__report_view()
        else:
            return OWReport.get_instance()

    def show_report(self):
        """
        Raise the report window.
        """
        self.create_report_html()
        from orangewidget.report.owreport import HAVE_REPORT

        report = self._get_designated_report_view()
        if not HAVE_REPORT and not report.have_report_warning_shown:
            QMessageBox.critical(
                None, _tr.m[58, "Missing Component"],
                (_tr.m[59, "Orange can not display reports, because your installation "] + (_tr.m[60, "contains neither WebEngine nor WebKit.\n\n"] + (_tr.m[61, "If you installed Orange with conda or pip, try using another "] + (_tr.m[62, "PyQt distribution. "] + (_tr.m[63, "If you installed Orange with a standard installer, please "] + _tr.m[64, "report this bug."])))))
            )
            report.have_report_warning_shown = True

        # Should really have a signal `report_ready` or similar to decouple
        # the implementations.
        report.make_report(self)
        report.show()
        report.raise_()

    def get_widget_name_extension(self):
        """
        Return the text that is added to the section name in the report.

        For instance, the Distribution widget adds the name of the attribute
        whose distribution is shown.

        :return: str or None
        """
        return None

    def create_report_html(self):
        """ Start a new section in report and call :obj:`send_report` method
        to add content."""
        self.report_html = '<section class="section">'
        self.report_html += get_html_section(self.name)
        self.report_html += '<div class="content">\n'
        self.send_report()
        self.report_html += '</div></section>\n\n'

    @staticmethod
    def _fix_args(name, items):
        if items is None:
            return "", name
        else:
            return name, items

    def report_items(self, name, items=None):
        """
        Add a sequence of pairs or a dictionary as a HTML list to report.

        The first argument, `name` can be omitted.

        :param name: report section name (can be omitted)
        :type name: str or tuple or dict
        :param items: a sequence of items
        :type items: list or tuple or dict
        """
        name, items = self._fix_args(name, items)
        self.report_name(name)
        self.report_html += render_items(items)

    def report_name(self, name):
        """ Add a section name to the report"""
        if name != "":
            self.report_html += get_html_subsection(name)

    def report_plot(self, name=None, plot=None):
        """
        Add a plot to the report.

        Both arguments can be omitted.

        - `report_plot("graph name", self.plotView)` reports plot
            `self.plotView` with name `"graph name"`
        - `report_plot(self.plotView) reports plot without name
        - `report_plot()` reports plot stored in attribute whose name is
            taken from `self.graph_name`
        - `report_plot("graph name")` reports plot stored in attribute
            whose name is taken from `self.graph_name`

        :param name: report section name (can be omitted)
        :type name: str or tuple or dict
        :param plot: plot widget
        :type plot:
            QGraphicsScene or pyqtgraph.PlotItem or pyqtgraph.PlotWidget
            or pyqtgraph.GraphicsWidget. If omitted, the name of the
            attribute storing the graph is taken from `self.graph_name`
        """
        if not (isinstance(name, str) and plot is None):
            name, plot = self._fix_args(name, plot)

        from pyqtgraph import PlotWidget, PlotItem, GraphicsWidget, GraphicsView
        try:
            from orangewidget.utils.webview import WebviewWidget
        except ImportError:
            WebviewWidget = None

        self.report_name(name)
        if plot is None:
            plot = getdeepattr(self, self.graph_name)
        if isinstance(plot, (QGraphicsScene, PlotItem)):
            self.report_html += get_html_img(plot)
        elif isinstance(plot, PlotWidget):
            self.report_html += get_html_img(plot.plotItem)
        elif isinstance(plot, QGraphicsWidget):
            self.report_html += get_html_img(plot.scene())
        elif isinstance(plot, QGraphicsView):
            self.report_html += get_html_img(plot)
        elif WebviewWidget is not None and isinstance(plot, WebviewWidget):
            try:
                svg = plot.svg()
            except (IndexError, ValueError):
                svg = plot.html()
            self.report_html += svg

    # noinspection PyBroadException
    def report_table(self, name, table=None, header_rows=0, header_columns=0,
                     num_format=None, indicate_selection=True):
        """
        Add content of a table to the report.

        The method accepts different kinds of two-dimensional data, including
        Qt's views and models.

        The first argument, `name` can be omitted if other arguments (except
        `table`) are passed as keyword arguments.

        :param name: name of the section
        :type name: str
        :param table: table to be reported
        :type table:
            QAbstractItemModel or QStandardItemModel or two-dimensional list or
            any object with method `model()` that returns one of the above
        :param header_rows: the number of rows that are marked as header rows
        :type header_rows: int
        :param header_columns:
            the number of columns that are marked as header columns
        :type header_columns: int
        :param num_format: numeric format, e.g. `{:.3}`
        """
        row_limit = 100
        name, table = self._fix_args(name, table)
        join = "".join

        def report_abstract_model(model, view=None):
            columns = [i for i in range(model.columnCount())
                       if not view or not view.isColumnHidden(i)]
            rows = [i for i in range(model.rowCount())
                    if not view or not view.isRowHidden(i)]

            has_horizontal_header = (try_(lambda: not view.horizontalHeader().isHidden()) or
                                     try_(lambda: not view.header().isHidden()))
            has_vertical_header = try_(lambda: not view.verticalHeader().isHidden())
            if view is not None:
                opts = view.viewOptions()
                decoration_size = QSize(opts.decorationSize)
            else:
                decoration_size = QSize(16, 16)

            def item_html(row, col):
                def data(role=Qt.DisplayRole,
                         orientation=Qt.Horizontal if row is None else Qt.Vertical):
                    if row is None or col is None:
                        return model.headerData(col if row is None else row,
                                                orientation, role)
                    data_ = model.data(model.index(row, col), role)
                    if isinstance(data_, QGraphicsScene):
                        data_ = get_html_img(
                            data_,
                            max_height=view.verticalHeader().defaultSectionSize()
                        )
                    elif isinstance(data_, QIcon):
                        data_ = get_icon_html(data_, size=decoration_size) + " "
                    return data_

                value = data()
                if view is not None and col is not None:
                    delegate = view.itemDelegateForColumn(col)
                    if delegate is None:
                        delegate = view.itemDelegate()
                    if hasattr(delegate, "displayText"):
                        value = delegate.displayText(value, QLocale())
                value = value or ""

                decoration = data(role=Qt.DecorationRole) or ''

                selected = (view.selectionModel().isSelected(model.index(row, col))
                            if view and row is not None and col is not None else False)

                if indicate_selection and selected:
                    report = self._get_designated_report_view()
                    fgcolor = report.palette().color(
                        QPalette.ColorGroup.Active,
                        QPalette.ColorRole.HighlightedText
                    ).name()
                    bgcolor = report.palette().color(
                        QPalette.ColorGroup.Active,
                        QPalette.ColorRole.Highlight
                    ).name()
                else:
                    fgcolor = data(Qt.ForegroundRole)
                    if isinstance(fgcolor, (QBrush, QColor)):
                        fgcolor = QBrush(fgcolor).color().name()
                    else:
                        fgcolor = 'black'

                    bgcolor = data(Qt.BackgroundRole)
                    if isinstance(bgcolor, (QBrush, QColor)):
                        bgcolor = QBrush(bgcolor).color().name()
                        if bgcolor.lower() == '#ffffff':
                            bgcolor = 'transparent'
                    else:
                        bgcolor = 'transparent'

                font = data(Qt.FontRole)
                weight = 'font-weight: bold; ' if font and font.bold() else ''

                alignment = data(Qt.TextAlignmentRole) or Qt.AlignLeft
                halign = ('left' if alignment & Qt.AlignLeft else
                          'right' if alignment & Qt.AlignRight else
                          'center')
                valign = ('top' if alignment & Qt.AlignTop else
                          'bottom' if alignment & Qt.AlignBottom else
                          'middle')

                style = ('style="' + (f'color:{fgcolor}; background:{bgcolor}; {weight}' + f'text-align:{halign}; vertical-align:{valign};"'))
                tag = 'th' if row is None or col is None else 'td'
                return f'<{tag} {style}>{decoration}{value}</{tag}>\n'

            stream = []

            if has_horizontal_header:
                stream.append('<tr>')
                if has_vertical_header:
                    stream.append('<th></th>')
                stream.extend(item_html(None, col) for col in columns)
                stream.append('</tr>')

            for row in rows[:row_limit]:
                stream.append('<tr>')
                if has_vertical_header:
                    stream.append(item_html(row, None))
                stream.extend(item_html(row, col) for col in columns)
                stream.append('</tr>')

            return ''.join(stream)

        if num_format:
            def fmtnum(s):
                try:
                    return num_format.format(float(s))
                except:
                    return s
        else:
            def fmtnum(s):
                return s

        def report_list(data,
                        header_rows=header_rows, header_columns=header_columns):
            cells = ["<td>{}</td>", "<th>{}</th>"]
            return join("  <tr>\n    {}</tr>\n".format(
                join(cells[rowi < header_rows or coli < header_columns]
                     .format(fmtnum(elm)) for coli, elm in enumerate(row))
            ) for rowi, row in zip(range(row_limit + header_rows), data))

        self.report_name(name)
        n_hidden_rows, n_cols = 0, 1
        if isinstance(table, QTableView):
            body = report_abstract_model(table.model(), table)
            n_hidden_rows = table.model().rowCount() - row_limit
            n_cols = table.model().columnCount()
        elif isinstance(table, QAbstractItemModel):
            body = report_abstract_model(table)
            n_hidden_rows = table.rowCount() - row_limit
            n_cols = table.columnCount()
        elif isinstance(table, Iterable):
            body = report_list(table, header_rows, header_columns)
            table = list(table)
            n_hidden_rows = len(table) - row_limit
            if len(table) and isinstance(table[0], Iterable):
                n_cols = len(table[0])
        else:
            body = None

        if n_hidden_rows > 0:
            body += _tr.m[65, """<tr><th></th><td colspan='{}'><b>+ {} more</b></td></tr>
            """].format(n_cols, n_hidden_rows)

        if body:
            self.report_html += "<table>\n" + body + "</table>"

    # noinspection PyBroadException
    def report_list(self, name, data=None, limit=1000):
        """
        Add a list to the report.

        The method accepts different kinds of one-dimensional data, including
        Qt's views and models.

        The first argument, `name` can be omitted.

        :param name: name of the section
        :type name: str
        :param data: table to be reported
        :type data:
            QAbstractItemModel or any object with method `model()` that
            returns QAbstractItemModel
        :param limit: the maximal number of reported items (default: 1000)
        :type limit: int
        """
        name, data = self._fix_args(name, data)

        def report_abstract_model(model):
            content = (model.data(model.index(row, 0))
                       for row in range(model.rowCount()))
            return clipped_list(content, limit, less_lookups=True)

        self.report_name(name)
        try:
            model = data.model()
        except:
            model = None
        if isinstance(model, QAbstractItemModel):
            txt = report_abstract_model(model)
        else:
            txt = ""
        self.report_html += txt

    def report_paragraph(self, name, text=None):
        """
        Add a paragraph to the report.

        The first argument, `name` can be omitted.

        :param name: name of the section
        :type name: str
        :param text: text of the paragraph
        :type text: str
        """
        name, text = self._fix_args(name, text)
        self.report_name(name)
        self.report_html += "<p>{}</p>".format(text)

    def report_caption(self, text):
        """
        Add caption to the report.
        """
        self.report_html += "<p class='caption'>{}</p>".format(text)

    def report_raw(self, name, html=None):
        """
        Add raw HTML to the report.
        """
        name, html = self._fix_args(name, html)
        self.report_name(name)
        self.report_html += html

    def combo_value(self, combo):
        """
        Add the value of a combo box to the report.

        The methods assumes that the combo box was created by
        :obj:`Orange.widget.gui.comboBox`. If the value of the combo equals
        `combo.emptyString`, this function returns None.
        """
        text = combo.currentText()
        if text != combo.emptyString:
            return text


def plural(s, number, suffix="s"):
    """
    Insert the number into the string, and make plural where marked, if needed.

    The string should use `{number}` to mark the place(s) where the number is
    inserted and `{s}` where an "s" needs to be added if the number is not 1.

    For instance, a string could be "I saw {number} dog{s} in the forest".

    Argument `suffix` can be used for some forms or irregular plural, like:

        plural("I saw {number} fox{s} in the forest", x, "es")
        plural("I say {number} child{s} in the forest", x, "ren")

    :param s: string
    :type s: str
    :param number: number
    :type number: int
    :param suffix: the suffix to use; default is "s"
    :type suffix: str
    :rtype: str
    """
    warnings.warn(("Plural formed by this function is difficult to translate. " + "Use orangecanvas.utils.localization.pl instead."))
    return s.format(number=number, s=suffix if number % 100 != 1 else "")


def plural_w(s, number, suffix="s", capitalize=False):
    """
    Insert the number into the string, and make plural where marked, if needed.

    If the number is smaller or equal to ten, a word is used instead of a
    numeric representation.

    The string should use `{number}` to mark the place(s) where the number is
    inserted and `{s}` where an "s" needs to be added if the number is not 1.

    For instance, a string could be "I saw {number} dog{s} in the forest".

    Argument `suffix` can be used for some forms or irregular plural, like:

        plural("I saw {number} fox{s} in the forest", x, "es")
        plural("I say {number} child{s} in the forest", x, "ren")

    :param s: string
    :type s: str
    :param number: number
    :type number: int
    :param suffix: the suffix to use; default is "s"
    :type suffix: str
    :rtype: str
    """
    numbers = ("zero", "one", "two", "three", "four", "five", "six", "seven",
               "nine", "ten")
    number_str = numbers[number] if number < len(numbers) else str(number)
    if capitalize:
        number_str = number_str.capitalize()
    return s.format(number=number_str, s=suffix if number % 100 != 1 else "")


def bool_str(v):
    """Convert a boolean to a string."""
    return _tr.m[66, "Yes"] if v else _tr.m[67, "No"]


def clip_string(s, limit=1000, sep=None):
    """
    Clip a string at a given character and add "..." if the string was clipped.

    If a separator is specified, the string is not clipped at the given limit
    but after the last occurence of the separator below the limit.

    :param s: string to clip
    :type s: str
    :param limit: number of characters to retain (including "...")
    :type limit: int
    :param sep: separator
    :type sep: str
    :rtype: str
    """
    if len(s) < limit:
        return s
    s = s[:limit - 3]
    if sep is None:
        return s
    sep_pos = s.rfind(sep)
    if sep_pos == -1:
        return s
    return s[:sep_pos + len(sep)] + "..."


def clipped_list(items, limit=1000, less_lookups=False, total_min=10, total=""):
    """
    Return a clipped comma-separated representation of the list.

    If `less_lookups` is `True`, clipping will use a generator across the first
    `(limit + 2) // 3` items only, which suffices even if each item is only a
    single character long. This is useful in case when retrieving items is
    expensive, while it is generally slower.

    If there are at least `total_lim` items, and argument `total` is present,
    the string `total.format(len(items))` is added to the end of string.
    Argument `total` can be, for instance `"(total: {} variables)"`.

    If `total` is given, `s` cannot be a generator.

    :param items: list
    :type items: list or another iterable object
    :param limit: number of characters to retain (including "...")
    :type limit: int
    :param total_min: the minimal number of items that triggers adding `total`
    :type total_min: int
    :param total: the string that is added if `len(items) >= total_min`
    :type total: str
    :param less_lookups: minimize the number of lookups
    :type less_lookups: bool
    :return:
    """
    if less_lookups:
        s = ", ".join(itertools.islice(items, (limit + 2) // 3))
    else:
        s = ", ".join(items)
    s = clip_string(s, limit, ", ")
    if total and len(items) >= total_min:
        s += " " + total.format(len(items))
    return s


def get_html_section(name):
    """
    Return a new section as HTML, with the given name and a time stamp.

    :param name: section name
    :type name: str
    :rtype: str
    """
    datetime = time.strftime(_tr.m[68, "%a %b %d %y, %H:%M:%S"])
    return "<h1>{} <span class='timestamp'>{}</h1>".format(name, datetime)


def get_html_subsection(name):
    """
    Return a subsection as HTML, with the given name

    :param name: subsection name
    :type name: str
    :rtype: str
    """
    return "<h2>{}</h2>".format(name)


def render_items(items):
    """
    Render a sequence of pairs or a dictionary as a HTML list.

    The function skips the items whose values are `None` or `False`.

    :param items: a sequence of items
    :type items: list or tuple or dict
    :return: rendered content
    :rtype: str
    """
    if isinstance(items, dict):
        items = items.items()
    return "<ul>" + "".join(
        "<b>{}:</b> {}</br>".format(key, value) for key, value in items
        if value is not None and value is not False) + "</ul>"


def render_items_vert(items):
    """
    Render a sequence of pairs or a dictionary as a comma-separated list.

    The function skips the items whose values are `None` or `False`.

    :param items: a sequence of items
    :type items: list or tuple or dict
    :return: rendered content
    :rtype: str
    """
    if isinstance(items, dict):
        items = items.items()
    return ", ".join("<b>{}</b>: {}".format(key, value) for key, value in items
                     if value is not None and value is not False)


def get_html_img(
        scene: QGraphicsScene, max_height: Optional[int] = None
) -> str:
    """
    Create HTML img element with base64-encoded image from the scene.
    If max_height is not none set the max height of the image in html.
    """
    byte_array = QByteArray()
    filename = QBuffer(byte_array)
    filename.open(QIODevice.WriteOnly)
    img_data = PngFormat.write(filename, scene)

    img_encoded = byte_array.toBase64().data().decode("utf-8")

    style_opts = []
    if max_height is not None:
        style_opts.append(f"max-height: {max_height}px")
    if img_data is not None:
        ratio = img_data.get("pixel_ratio", 1)
        if ratio != 1:
            style_opts.append(f"zoom: {1 / ratio:.1f}")
    style = f' style="{"; ".join(style_opts)}"' if style_opts else ''
    return f'<img{style} src="data:image/png;base64,{img_encoded}"/>'


def get_icon_html(icon: QIcon, size: QSize) -> str:
    """
    Transform an icon to html <img> tag.
    """
    if not size.isValid():
        return ""
    if size.width() < 0 or size.height() < 0:
        size = QSize(16, 16)  # just in case
    byte_array = QByteArray()
    buffer = QBuffer(byte_array)
    buffer.open(QIODevice.WriteOnly)
    pixmap = icon.pixmap(size)
    if pixmap.isNull():
        return ""
    pixmap.save(buffer, "PNG")
    buffer.close()

    dpr = pixmap.devicePixelRatioF()
    if dpr != 1.0:
        size_ = pixmap.size() / dpr
        size_part = ' width="{}" height="{}"'.format(
            int(math.floor(size_.width())), int(math.floor(size_.height()))
        )
    else:
        size_part = ''
    img_encoded = byte_array.toBase64().data().decode("utf-8")
    return ('<img style="display: inline; vertical-align: middle" ' + f'src="data:image/png;base64,{img_encoded}"{size_part}/>')


def colored_square(r, g, b):
    return ('<span class="legend-square" ' + 'style="background-color: rgb({}, {}, {})"></span>').format(r, g, b)


def list_legend(model, selected=None):
    """
    Create HTML with a legend constructed from a Qt model or a view.

    This function can be used for reporting the legend for graph in widgets
    in which the colors representing different values are shown in a listbox
    with colored icons. The function returns a string with values from the
    listbox, preceded by squares of the corresponding colors.

    The model must return data for Qt.DecorationRole. If a view is passed as
    an argument, it has to have method `model()`.

    :param model: model or view, usually a list box
    :param selected: if given, only items with the specified indices are shown
    """
    if hasattr(model, "model"):
        model = model.model()
    legend = ""
    for row in range(model.rowCount()):
        if selected is not None and row not in selected:
            continue
        index = model.index(row, 0)
        icon = model.data(index, Qt.DecorationRole)
        r, g, b, a = QColor(
            icon.pixmap(12, 12).toImage().pixel(0, 0)).getRgb()
        text = model.data(index, Qt.DisplayRole)
        legend += colored_square(r, g, b) + \
                  '<span class="legend-item">{}</span>'.format(text)
    return legend