Why Gemfury? Push, build, and install  RubyGems npm packages Python packages Maven artifacts PHP packages Go Modules Debian packages RPM packages NuGet packages

Repository URL to install this package:

Details    
tia / rlab / table.py
Size: Mime:
from reportlab.platypus import Table, TableStyle, Flowable
from reportlab.lib.colors import grey, white, HexColor, black, gray
from matplotlib.colors import rgb2hex, LinearSegmentedColormap
from matplotlib.pyplot import get_cmap
import numpy as np
import pandas as pd

from tia.rlab.components import KeepInFrame
import tia.util.fmt as fmt


__all__ = ['ConditionalRedBlack', 'DynamicTable', 'TableFormatter', 'RegionFormatter', 'IntFormatter', 'FloatFormatter',
           'PercentFormatter', 'ThousandsFormatter', 'MillionsFormatter', 'BillionsFormatter', 'DollarCentsFormatter',
           'DollarFormatter', 'ThousandDollarsFormatter', 'MillionDollarsFormatter', 'BillionDollarsFormatter',
           'YmdFormatter', 'Y_m_dFormatter', 'DynamicNumberFormatter', 'BorderTypeGrid', 'BorderTypeHorizontal',
           'BorderTypeOutline', 'BorderTypeOutline', 'BorderTypeVertical', 'Style', 'BorderTypeOutlineCols']

DefaultHeaderStyle = {
    "GRID": (.5, grey), "BOX": (.25, black), "VALIGN": "MIDDLE", "LEADING": 6, "LEFTPADDING": 3,
    "RIGHTPADDING": 3, "BOTTOMPADDING": 3, "TOPPADDING": 3, "FONTSIZE": 6, "BACKGROUND": HexColor("#404040"),
    "FONTNAME": "Helvetica", "ALIGN": "CENTER", "TEXTCOLOR": white
}

DefaultCellStyle = {
    "GRID": (.5, grey), "BOX": (.25, black), "VALIGN": "MIDDLE", "LEADING": 6, "LEFTPADDING": 3,
    "RIGHTPADDING": 3, "BOTTOMPADDING": 2, "TOPPADDING": 2, "ALIGN": "CENTER", "TEXTCOLOR": black,
    "ROWBACKGROUNDS": [[HexColor("#e3ebf4"), white]], "FONTSIZE": 6, "FONTNAME": "Helvetica"  # "FONTNAME": "Courier"
}

DefaultIndexStyle = {
    "GRID": (.5, grey), "BOX": (.25, black), "VALIGN": "MIDDLE", "LEADING": 6, "LEFTPADDING": 3,
    "RIGHTPADDING": 3, "BOTTOMPADDING": 2, "TOPPADDING": 2, "ALIGN": "RIGHT", "TEXTCOLOR": black,
    "ROWBACKGROUNDS": [[HexColor("#e3ebf4"), white]], "FONTSIZE": 6, "FONTNAME": "Helvetica"
}

DefaultWeight = .7

AlignRight = {'ALIGN': 'RIGHT'}

ConditionalRedBlack = lambda x: x < 0 and dict(TEXTCOLOR=HexColor("#800000"))


def pad_positive_wrapper(fmtfct):
    """Ensure that numbers are aligned in table by appending a blank space to postive values if 'parenthesis' are
    used to denote negative numbers"""

    def check_and_append(*args, **kwargs):
        result = fmtfct(*args, **kwargs)
        if fmtfct.parens and not result.endswith(')'):
            result += ' '
        return result

    return check_and_append


IntFormatter = pad_positive_wrapper(fmt.new_int_formatter(nan='-'))
FloatFormatter = pad_positive_wrapper(fmt.new_float_formatter(nan='-'))
PercentFormatter = pad_positive_wrapper(fmt.new_percent_formatter(nan='-'))
ThousandsFormatter = pad_positive_wrapper(fmt.new_thousands_formatter(nan='-'))
MillionsFormatter = pad_positive_wrapper(fmt.new_millions_formatter(nan='-'))
BillionsFormatter = pad_positive_wrapper(fmt.new_billions_formatter(nan='-'))
# Don't attempt to pad
DynamicNumberFormatter = fmt.DynamicNumberFormat(method='col', nan='-', pcts=1, trunc_dot_zeros=1)

DollarCentsFormatter = pad_positive_wrapper(fmt.new_float_formatter(prefix='$', nan='-'))
DollarFormatter = pad_positive_wrapper(fmt.new_int_formatter(prefix='$', nan='-'))
ThousandDollarsFormatter = pad_positive_wrapper(fmt.new_thousands_formatter(prefix='$', nan='-'))
MillionDollarsFormatter = pad_positive_wrapper(fmt.new_millions_formatter(prefix='$', nan='-'))
BillionDollarsFormatter = pad_positive_wrapper(fmt.new_billions_formatter(prefix='$', nan='-'))
YmdFormatter = fmt.new_datetime_formatter('%Y%m%d', True)
Y_m_dFormatter = fmt.new_datetime_formatter('%Y-%m-%d', True)
mdYFormatter = fmt.new_datetime_formatter('%m/%d/%Y', True)


class DynamicTable(Table):
    def __init__(self, data, on_wrap=None, **kwargs):
        self.on_wrap = on_wrap
        Table.__init__(self, data, **kwargs)
        self._longTableOptimize = 0

    def wrap(self, awidth, aheight):
        self.on_wrap and self.on_wrap(self, awidth, aheight)
        return Table.wrap(self, awidth, aheight)


def is_contiguous(idx):
    if len(idx) > 0:
        s0, s1 = idx.min(), idx.max()
        expected = pd.Index(np.array(list(range(s0, s1 + 1))), dtype='int64')
        # return idx.isin(expected).all()
        return expected.isin(idx).all()


def find_locations(index, match_value_or_fct, levels=None, max_matches=0):
    matches = []
    fct = match_value_or_fct
    if not callable(fct):
        match_value = match_value_or_fct
        if not isinstance(match_value, str) and hasattr(match_value, '__iter__'):
            fct = lambda v: v in match_value
        else:
            fct = lambda v: v == match_value_or_fct

    for lvl, loc, val in level_iter(index, levels):
        if fct(val):
            matches.append(loc)
            if max_matches and len(matches) >= matches:
                break
    return matches


def level_iter(index, levels=None):
    if levels is None:
        levels = list(range(index.nlevels))
    elif np.isscalar(levels):
        levels = [levels]

    for level in levels:
        for i, v in enumerate(index.get_level_values(level)):
            yield level, i, v


def span_iter(series):
    sorted = series.sort_index()
    isnull = pd.isnull(sorted).values
    isnulleq = isnull[1:] & isnull[:-1]
    iseq = sorted.values[1:] == sorted.values[:-1]
    eq = isnulleq | iseq
    li = 0
    for i in range(len(eq)):
        islast = i == (len(eq) - 1)
        if eq[i]:  # if currently true then consecutive
            if islast or not eq[i + 1]:
                yield sorted.index[li], sorted.index[i + 1]
        else:
            li = i + 1
    #raise StopIteration


class BorderType(object):
    def __init__(self, weight=DefaultWeight, color=black, cap=None, dashes=None, join=None, count=None, space=None):
        args = locals()
        args.pop('self')
        self.kwargs = args

    def apply(self, rng, **overrides):
        args = self.kwargs.copy()
        args.update(overrides)
        self._do_apply(rng, args)

    def _do_apply(self, rng, args):
        raise NotImplementedError()


class BorderTypeGrid(BorderType):
    def _do_apply(self, rng, args):
        rng.set_grid(**args)


class BorderTypeOutline(BorderType):
    def _do_apply(self, rng, args):
        rng.set_box(**args)


class BorderTypeHorizontal(BorderType):
    def _do_apply(self, rng, args):
        fct = lambda r: (r.set_lineabove(**args), r.set_linebelow(**args))
        [fct(row) for row in rng.iter_rows()]


class BorderTypeOutlineCols(BorderType):
    def _do_apply(self, rng, args):
        [col.set_box(**args) for col in rng.iter_cols()]


class BorderTypeVertical(BorderType):
    def _do_apply(self, rng, args):
        fct = lambda r: (r.set_linebefore(**args), r.set_lineafter(**args))
        [fct(col) for col in rng.iter_cols()]


class Style(object):
    Blue = {'Light': HexColor('#dce6f1'),
            'Medium': HexColor('#95b3d7'),
            'Dark': HexColor('#4f81bd')}

    Black = {'Light': HexColor('#d9d9d9'),
             'Medium': HexColor('#6c6c6c'),
             'Dark': black}

    Red = {'Light': HexColor('#f2dcdb'),
           'Medium': HexColor('#da9694'),
           'Dark': HexColor('#c0504d')}

    Lime = {'Light': HexColor('#ebf1de'),
            'Medium': HexColor('#c4d79b'),
            'Dark': HexColor('#9bbb59')}

    Purple = {'Light': HexColor('#e4dfec'),
              'Medium': HexColor('#b1a0c7'),
              'Dark': HexColor('#8064a2')}

    Orange = {'Light': HexColor('#fde9d9'),
              'Medium': HexColor('#fabf8f'),
              'Dark': HexColor('#f79646')}

    Cyan = {'Light': HexColor('#eff4f6'),
            'Medium': HexColor('#a8c2cb'),
            'Dark': HexColor('#3b595f')}

    DarkBlue = {'Light': HexColor('#e5eaee '),
                'Medium': HexColor('#9aabbc'),
                'Dark': HexColor('#042f59')}

    @staticmethod
    def apply_basic(formatter, font='Helvetica', font_bold='Helvetica-Bold', font_size=8, rpad=None, lpad=None,
                    bpad=None, tpad=None, colspans=1, rowspans=0):
        lpad = 4. / 8. * font_size if lpad is None else 3
        rpad = 4. / 8. * font_size if rpad is None else 3
        bpad = 4. / 8. * font_size if bpad is None else 4
        tpad = 4. / 8. * font_size if tpad is None else 4
        formatter.all.set_font(font, size=font_size, leading=font_size)
        formatter.all.set_pad(lpad, bpad, rpad, tpad)
        formatter.all.set_valign_middle()
        # do the default things
        formatter.header.set_font(font_bold)
        formatter.header.set_align_center()
        formatter.index_header.set_font(font_bold)
        formatter.index_header.set_align_left()
        formatter.index.set_font(font_bold)
        formatter.index.set_align_left()
        formatter.cells.set_font(font)
        formatter.cells.set_align_right()
        # do col spans and row spans
        if rowspans and formatter.index.ncols > 1:
            formatter.index.iloc[:, :formatter.index.ncols - 1].detect_rowspans()
        if colspans and formatter.header.nrows > 1:
            formatter.header.iloc[:formatter.header.nrows - 1, :].detect_colspans()

    @staticmethod
    def apply_color(formatter, cmap=None, font_bw=1, stripe_rows=1, stripe_cols=0,
                    hdr_border_clazz=BorderTypeGrid, cell_border_clazz=BorderTypeOutline, border_weight=.7):
        """
        font_bw: bool, If True use black and white fonts. If False, then use the cmap
        """
        cmap = cmap or Style.Blue
        light = cmap.get('Light', white)
        medium = cmap.get('Medium', gray)
        dark = cmap.get('Dark', black)
        # the ranges
        header = formatter.all.iloc[:formatter.header.nrows]
        cells = formatter.all.iloc[formatter.header.nrows:]
        # color the header
        hdr_border_clazz and header.set_border_type(hdr_border_clazz, color=medium, weight=border_weight)
        header.set_textcolor(font_bw and white or light)
        header.set_background(dark)
        # color the cells
        cell_border_clazz and cells.set_border_type(cell_border_clazz, color=medium, weight=border_weight)
        stripe_rows and cells.set_row_backgrounds([light, white])
        stripe_cols and cells.set_col_backgrounds([white, light])
        not font_bw and cells.set_textcolor(dark)


class RegionFormatter(object):
    def __init__(self, parent, row_ilocs, col_ilocs):
        self.row_ilocs = row_ilocs
        self.col_ilocs = col_ilocs
        self.parent = parent
        self.style_cmds = parent.style_cmds
        self.is_contiguous_rows = isrcont = is_contiguous(row_ilocs)
        self.is_contiguous_cols = isccont = is_contiguous(col_ilocs)
        self.iloc = _RegionIX(self, 'iloc')

        # Build coord arrays for easy iteration
        if isccont:
            self.col_coord_tuples = [(col_ilocs.min(), col_ilocs.max())]
        else:
            self.col_coord_tuples = list(zip(col_ilocs, col_ilocs))

        if isrcont:
            self.row_coord_tuples = [(row_ilocs.min(), row_ilocs.max())]
        else:
            self.row_coord_tuples = list(zip(row_ilocs, row_ilocs))

    @property
    def nrows(self):
        return len(self.row_ilocs)

    @property
    def ncols(self):
        return len(self.col_ilocs)

    @property
    def last_row(self):
        return self.empty_frame() if self.nrows == 0 else self.iloc[-1:, :]

    @property
    def last_col(self):
        return self.empty_frame() if self.ncols == 0 else self.iloc[:, -1:]

    def is_empty(self):
        return self.nrows == 0 and self.ncols == 0

    @property
    def formatted_values(self):
        return self.parent.formatted_values.iloc[self.row_ilocs, self.col_ilocs]

    @property
    def actual_values(self):
        return self.parent.actual_values.iloc[self.row_ilocs, self.col_ilocs]

    def new_instance(self, local_row_idxs, local_col_idxs):
        rows = pd.Index([self.row_ilocs[r] for r in local_row_idxs], dtype='Int64')
        cols = pd.Index([self.col_ilocs[c] for c in local_col_idxs], dtype='Int64')
        return RegionFormatter(self.parent, rows, cols)

    def empty_frame(self):
        return self.new_instance([], [])

    def match_column_labels(self, match_value_or_fct, levels=None, max_matches=0, empty_res=1):
        """Check the original DataFrame's column labels to find a subset of the current region
        :param match_value_or_fct: value or function(hdr_value) which returns True for match
        :param levels: [None, scalar, indexer]
        :param max_matches: maximum number of columns to return
        :return:
        """
        allmatches = self.parent._find_column_label_positions(match_value_or_fct, levels)
        # only keep matches which are within this region
        matches = [m for m in allmatches if m in self.col_ilocs]
        if max_matches and len(matches) > max_matches:
            matches = matches[:max_matches]

        if matches:
            return RegionFormatter(self.parent, self.row_ilocs, pd.Index(matches, dtype='Int64'))
        elif empty_res:
            return self.empty_frame()

    def match_row_labels(self, match_value_or_fct, levels=None, max_matches=0, empty_res=1):
        """Check the original DataFrame's row labels to find a subset of the current region
        :param match_value_or_fct: value or function(hdr_value) which returns True for match
        :param levels: [None, scalar, indexer]
        :param max_matches: maximum number of columns to return
        :return:
        """
        allmatches = self.parent._find_row_label_positions(match_value_or_fct, levels)
        # only keep matches which are within this region
        matches = [m for m in allmatches if m in self.row_ilocs]
        if max_matches and len(matches) > max_matches:
            matches = matches[:max_matches]

        if matches:
            return RegionFormatter(self.parent, pd.Index(matches, dtype='Int64'), self.col_ilocs)
        elif empty_res:
            return self.empty_frame()

    def match_any_labels(self, match_value_or_fct, levels=None, max_matches=0, empty_res=1):
        res = self.match_column_labels(match_value_or_fct, levels, max_matches, empty_res=0)
        res = res or self.match_row_labels(match_value_or_fct, levels, max_matches, empty_res)
        return res

    def iter_rows(self, start=None, end=None):
        """Iterate each of the Region rows in this region"""
        start = start or 0
        end = end or self.nrows
        for i in range(start, end):
            yield self.iloc[i, :]

    def iter_cols(self, start=None, end=None):
        """Iterate each of the Region cols in this region"""
        start = start or 0
        end = end or self.ncols
        for i in range(start, end):
            yield self.iloc[:, i]

    def __repr__(self):
        return repr(self.formatted_values)

    def apply_style(self, cmd, *args):
        """
        Apply the specified style cmd to this region. For example, set all fonts to size 12, apply_style('FONTSIZE', 12)
        :param cmd: reportlab  format command
        :param args: arguments for the cmd
        :return: self
        """
        for c0, c1 in self.col_coord_tuples:
            for r0, r1 in self.row_coord_tuples:
                c = [cmd, (c0, r0), (c1, r1)] + list(args)
                self.style_cmds.append(c)
        return self

    def apply_styles(self, cmdmap):
        """
        Apply the set of commands defined in cmdmap. for example, apply_styles({'FONTSIZE': 12, 'BACKGROUND': white})
        :param cmdmap: dict of commands mapped to the command arguments
        :return: self
        """
        is_list_like = lambda arg: isinstance(arg, (list, tuple))
        is_first_param_list = lambda c: c in ('COLBACKGROUNDS', 'ROWBACKGROUNDS')
        for cmd, args in cmdmap.items():
            if not is_list_like(args):
                args = [args]
            elif is_first_param_list(cmd) and is_list_like(args) and not is_list_like(args[0]):
                args = [args]
            self.apply_style(cmd, *args)
        return self

    def apply_conditional_styles(self, cbfct):
        """
        Ability to provide dynamic styling of the cell based on its value.
        :param cbfct: function(cell_value) should return a dict of format commands to apply to that cell
        :return: self
        """
        for ridx in range(self.nrows):
            for cidx in range(self.ncols):
                fmts = cbfct(self.actual_values.iloc[ridx, cidx])
                fmts and self.iloc[ridx, cidx].apply_styles(fmts)
        return self

    def detect_colspans(self, use_actual=1):
        """Determine if any col spans are present in the values.
        :param use_actual:  if True, check actual_values for span. if False, use the formatted_values
        :return: self
        """
        vals = self.actual_values if use_actual else self.formatted_values
        if self.is_contiguous_cols:
            for ridx in range(self.nrows):
                for c0, c1 in span_iter(vals.iloc[ridx, :]):
                    actual_idx = self.row_ilocs[ridx]
                    self.style_cmds.append(['SPAN', (c0, actual_idx), (c1, actual_idx)])
        return self

    def detect_rowspans(self, use_actual=1):
        """Determine if any row spans are present in the values.
        :param use_actual:  if True, check actual_values for span. if False, use the formatted_values
        :return: self
        """
        """ Determine if any row spans are present"""
        vals = self.actual_values if use_actual else self.formatted_values
        if self.is_contiguous_rows:
            for cidx in range(self.ncols):
                for r0, r1 in span_iter(vals.iloc[:, cidx]):
                    actual_idx = self.col_ilocs[cidx]
                    self.style_cmds.append(['SPAN', (actual_idx, r0), (actual_idx, r1)])
        return self

    def detect_spans(self, colspans=1, rowspans=1, use_actual=1):
        colspans and self.detect_colspans(use_actual)
        rowspans and self.detect_rowspans(use_actual)

    def apply_format(self, fmtfct):
        """
        For each cell in the region, invoke fmtfct(cell_value) and store result in the formatted_values
        :param fmtfct: function(cell_value) which should return a formatted value for display
        :return: self
        """
        for ridx in range(self.nrows):
            for cidx in range(self.ncols):
                # MUST set the parent as local view is immutable
                riloc = self.row_ilocs[ridx]
                ciloc = self.col_ilocs[cidx]
                self.parent.formatted_values.iloc[riloc, ciloc] = fmtfct(self.actual_values.iloc[ridx, cidx])
        return self

    def apply_rowattrs(self, **kwargs):
        for k, v in kwargs.items():
            self.parent.rowattrs.iloc[self.row_ilocs, k] = v
        return self

    def apply_colattrs(self, **kwargs):
        for k, v in kwargs.items():
            self.parent.colattrs.loc[self.col_ilocs, k] = v
        return self

    def apply(self, **kwargs):
        """
        Accepts the following keys:
        'styles': see apply_styles for args
        'cstyles': see apply_condition_styles for args
        'format': see apply_format for args
        'c': col width (array or scalar)
        'cmin': min col width (array or scalar)
        'cmax': max col width (array or scalar)
        'cweight: col weight use at runtime to determine width
        'r': row height (array or scalar)
        'rmin': min row height (array or scalar)
        'rmax': max row height (array or scalar)
        'rweight: row weight use at runtime to determine height
        'cspans': detect colspans
        'rspans': detect rowspans
        'spans': bool, detetch both rowspans and colspans

        @param kwargs:
        @return:
        """

        def _apply_if_avail(key, fct):
            if key in kwargs:
                val = kwargs.pop(key)
                if val is not None:
                    fct(val)

        _apply_if_avail('styles', lambda v: self.apply_styles(v))
        _apply_if_avail('cstyles', lambda v: self.apply_conditional_styles(v))
        _apply_if_avail('format', lambda v: self.apply_format(v))
        _apply_if_avail('c', lambda v: self.apply_colattrs(value=v))
        _apply_if_avail('cmin', lambda v: self.apply_colattrs(min=v))
        _apply_if_avail('cmax', lambda v: self.apply_colattrs(max=v))
        _apply_if_avail('cweight', lambda v: self.apply_colattrs(weight=v))
        _apply_if_avail('r', lambda v: self.apply_rowattrs(value=v))
        _apply_if_avail('rmin', lambda v: self.apply_rowattrs(min=v))
        _apply_if_avail('rmax', lambda v: self.apply_rowattrs(max=v))
        _apply_if_avail('rweight', lambda v: self.apply_rowattrs(weight=v))
        _apply_if_avail('rspans', lambda v: v and self.detect_rowspans())
        _apply_if_avail('cspans', lambda v: v and self.detect_colspans())
        _apply_if_avail('spans', lambda v: v and (self.detect_rowspans(), self.detect_colspans()))

    def apply_number_format(self, formatter, rb=1, align=1):
        styles = align and AlignRight or {}
        cstyles = rb and ConditionalRedBlack or None
        self.apply(format=formatter, styles=styles, cstyles=cstyles)
        return self

    def _do_number_format(self, rb, align, fmt_fct, fmt_args, defaults):
        args = {}
        defaults and args.update(defaults)
        fmt_args and args.update(fmt_args)
        f = pad_positive_wrapper(fmt_fct(**args))
        return self.apply_number_format(f, rb=rb, align=align)

    def percent_format(self, rb=1, align=1, **fmt_args):
        defaults = {'precision': 2, 'nan': '-'}
        return self._do_number_format(rb, align, fmt.new_percent_formatter, fmt_args, defaults)

    def int_format(self, rb=1, align=1, **fmt_args):
        defaults = {'nan': '-'}
        return self._do_number_format(rb, align, fmt.new_int_formatter, fmt_args, defaults)

    def float_format(self, rb=1, align=1, **fmt_args):
        defaults = {'precision': 2, 'nan': '-'}
        return self._do_number_format(rb, align, fmt.new_float_formatter, fmt_args, defaults)

    def thousands_format(self, rb=1, align=1, **fmt_args):
        defaults = {'precision': 1, 'nan': '-'}
        return self._do_number_format(rb, align, fmt.new_thousands_formatter, fmt_args, defaults)

    def millions_format(self, rb=1, align=1, **fmt_args):
        defaults = {'precision': 1, 'nan': '-'}
        return self._do_number_format(rb, align, fmt.new_millions_formatter, fmt_args, defaults)

    def billions_format(self, rb=1, align=1, **fmt_args):
        defaults = {'precision': 1, 'nan': '-'}
        return self._do_number_format(rb, align, fmt.new_billions_formatter, fmt_args, defaults)

    def guess_number_format(self, rb=1, align=1, **fmt_args):
        """Determine the most appropriate formatter by inspected all the region values"""
        fct = fmt.guess_formatter(self.actual_values, **fmt_args)
        return self.apply_number_format(fct, rb=rb, align=align)

    def guess_format(self, rb=1, align=1, **fmt_args):
        from tia.util.fmt import NumberFormat

        fct = fmt.guess_formatter(self.actual_values, **fmt_args)
        if isinstance(fmt, NumberFormat):
            return self.apply_number_format(fct, rb=rb, align=align)
        else:
            return self.apply_format(fct)

    def dynamic_number_format(self, rb=1, align=1, **fmt_args):
        """Formatter changes based on the cell value"""
        fct = fmt.DynamicNumberFormatter(**fmt_args)
        return self.apply_number_format(fct, rb=rb, align=align)

    # def heat_map(self, cmap=None, min=None, max=None, font_cmap=None):
    def heat_map(self, cmap='RdYlGn', vmin=None, vmax=None, font_cmap=None):
        if cmap is None:
            carr = ['#d7191c', '#fdae61', '#ffffff', '#a6d96a', '#1a9641']
            cmap = LinearSegmentedColormap.from_list('default-heatmap', carr)

        if isinstance(cmap, str):
            cmap = get_cmap(cmap)
        if isinstance(font_cmap, str):
            font_cmap = get_cmap(font_cmap)

        vals = self.actual_values.astype(float)
        if vmin is None:
            vmin = vals.min().min()
        if vmax is None:
            vmax = vals.max().max()
        norm = (vals - vmin) / (vmax - vmin)
        for ridx in range(self.nrows):
            for cidx in range(self.ncols):
                v = norm.iloc[ridx, cidx]
                if np.isnan(v):
                    continue
                color = cmap(v)
                hex = rgb2hex(color)
                styles = {'BACKGROUND': HexColor(hex)}
                if font_cmap is not None:
                    styles['TEXTCOLOR'] = HexColor(rgb2hex(font_cmap(v)))
                self.iloc[ridx, cidx].apply_styles(styles)
        return self

    heatmap = heat_map

    def set_font(self, name=None, size=None, leading=None, color=None):
        name and self.set_fontname(name)
        size and self.set_fontsize(size)
        leading and self.set_leading(leading)
        color and self.set_textcolor(color)
        return self

    def set_fontname(self, name):
        return self.apply_style('FONTNAME', name)

    def set_fontsize(self, size):
        return self.apply_style('FONTSIZE', size)

    def set_textcolor(self, color):
        return self.apply_style('TEXTCOLOR', color)

    def set_leading(self, n):
        return self.apply_style('LEADING', n)

    def set_valign(self, pos):
        return self.apply_style('VALIGN', pos)

    def set_valign_middle(self):
        return self.set_valign('MIDDLE')

    def set_valign_center(self):
        return self.set_valign_middle()

    def set_valign_top(self):
        return self.set_valign('TOP')

    def set_valign_bottom(self):
        return self.set_valign('BOTTOM')

    def set_align(self, pos):
        return self.apply_style('ALIGN', pos)

    def set_align_center(self):
        return self.set_align('CENTER')

    def set_align_middle(self):
        return self.set_align_center()

    def set_align_left(self):
        return self.set_align('LEFT')

    def set_align_right(self):
        return self.set_align('RIGHT')

    def set_pad(self, left, bottom, right, top):
        return self.set_lpad(left).set_bpad(bottom).set_rpad(right).set_tpad(top)

    def set_lpad(self, n):
        return self.apply_style('LEFTPADDING', n)

    def set_bpad(self, n):
        return self.apply_style('BOTTOMPADDING', n)

    def set_rpad(self, n):
        return self.apply_style('RIGHTPADDING', n)

    def set_tpad(self, n):
        return self.apply_style('TOPPADDING', n)

    def set_box(self, weight=DefaultWeight, color=None, cap=None, dashes=None, join=None, count=None, space=None):
        return self.apply_style('BOX', weight, color, cap, dashes, join, count, space)

    def set_grid(self, weight=DefaultWeight, color=None, cap=None, dashes=None, join=None, count=None, space=None):
        return self.apply_style('GRID', weight, color, cap, dashes, join, count, space)

    def set_lineabove(self, weight=DefaultWeight, color=None, cap=None, dashes=None, join=None, count=None, space=None):
        return self.apply_style('LINEABOVE', weight, color, cap, dashes, join, count, space)

    def set_linebelow(self, weight=DefaultWeight, color=None, cap=None, dashes=None, join=None, count=None, space=None):
        return self.apply_style('LINEBELOW', weight, color, cap, dashes, join, count, space)

    def set_linebefore(self, weight=DefaultWeight, color=None, cap=None, dashes=None, join=None, count=None,
                       space=None):
        return self.apply_style('LINEBEFORE', weight, color, cap, dashes, join, count, space)

    def set_lineafter(self, weight=DefaultWeight, color=None, cap=None, dashes=None, join=None, count=None, space=None):
        return self.apply_style('LINEAFTER', weight, color, cap, dashes, join, count, space)

    def set_border_type(self, clazz, weight=DefaultWeight, color=None, cap=None, dashes=None, join=None, count=None,
                        space=None):
        """example: set_border_type(BorderTypePartialRows) would set a border above and below each row in the range"""
        args = locals()
        args.pop('clazz')
        args.pop('self')
        clazz(**args).apply(self)

    def set_background(self, color):
        return self.apply_style('BACKGROUND', color)

    def set_col_backgrounds(self, colors):
        """Set alternative column colors"""
        return self.apply_style('COLBACKGROUNDS', colors)

    def set_row_backgrounds(self, colors):
        """Set alternative row colors"""
        return self.apply_style('ROWBACKGROUNDS', colors)


class _RegionIX(object):
    """ Custom version of indexer which ensures a DataFrame is created for proper use with the  RangeFormatter"""

    def __init__(self, region, idx_fct_name='iloc'):
        self.region = region
        self.idx_fct_name = idx_fct_name

    def __getitem__(self, key):
        """Sloppy implementation as I do not handle nested tuples properly"""
        if isinstance(key, tuple):
            if len(key) != 2:
                raise Exception('if tuple is used, it must contain 2 indexers')
            ridx = key[0]
            cidx = key[1]
        else:
            ridx = key
            cidx = slice(None)

        region = self.region
        # bug when ridx is -1 and only a single row - cannot get DataFrame
        if np.isscalar(ridx) and ridx == -1 and len(region.formatted_values.index) == 1:
            ridx = [0]
        else:
            ridx = [ridx] if np.isscalar(ridx) else ridx
        cidx = [cidx] if np.isscalar(cidx) else cidx
        idx = getattr(region.formatted_values, self.idx_fct_name)
        result = idx[ridx, cidx]
        if not isinstance(result, pd.DataFrame):
            raise Exception('index %s is expected to return a DataFrame, not %s' % (key, type(result)))
        return RegionFormatter(self.region.parent, result.index, result.columns)


class TableFormatter(object):
    def __init__(self, df, inc_header=1, inc_index=1):
        self.df = df
        self.inc_header = inc_header
        self.inc_index = inc_index
        self.ncols = ncols = len(df.columns)
        self.nrows = nrows = len(df.index)
        self.nhdrs = nhdrs = inc_header and df.columns.nlevels or 0
        self.nidxs = nidxs = inc_index and df.index.nlevels or 0
        self.style_cmds = []

        # copy the actual values to the formatted cells
        values = df.reset_index(drop=not inc_index).T.reset_index(drop=not inc_header).T.reset_index(drop=True)
        if inc_index and nhdrs > 1:  # move index name down
            values.iloc[nhdrs - 1, :nidxs] = values.iloc[0, :nidxs]
            values.iloc[:nhdrs - 1, :nidxs] = ''

        formatted_values = pd.DataFrame(np.empty((nhdrs + nrows, nidxs + ncols), dtype=object))
        formatted_values.iloc[:, :] = values.copy().values
        self.actual_values = values
        self.formatted_values = formatted_values
        self.named_regions = {
            'ALL': RegionFormatter(self, formatted_values.index, formatted_values.columns),
            'HEADER': RegionFormatter(self, formatted_values.index[:nhdrs], formatted_values.columns[nidxs:]),
            'INDEX': RegionFormatter(self, formatted_values.index[nhdrs:], formatted_values.columns[:nidxs]),
            'CELLS': RegionFormatter(self, formatted_values.index[nhdrs:], formatted_values.columns[nidxs:]),
            'INDEX_HEADER': RegionFormatter(self, formatted_values.index[:nhdrs], formatted_values.columns[:nidxs]),
        }

        # Define some fields to handle weight of rows/columns
        self.rowattrs = pd.DataFrame(np.empty((nhdrs + nrows, 4)), columns=['weight', 'min', 'max', 'value'])
        self.rowattrs[:] = np.nan
        self.colattrs = pd.DataFrame(np.empty((nidxs + ncols, 4)), columns=['weight', 'min', 'max', 'value'])
        self.colattrs[:] = np.nan

    def __getitem__(self, name):
        return self.named_regions[name]

    def get_default_header_style(self, **overrides):
        return dict(DefaultHeaderStyle, **overrides)

    def apply_default_cell_style(self, **overrides):
        styles = dict(DefaultCellStyle, **overrides)
        self.cells.apply_styles(styles)
        return self

    def apply_default_header_style(self, inc_index=0, **overrides):
        styles = self.get_default_header_style(**overrides)
        self.header.apply_styles(styles)
        if inc_index:
            self.index_header.apply_styles(styles)
        return self

    def apply_default_index_style(self, **overrides):
        styles = dict(DefaultIndexStyle, **overrides)
        self.index.apply_styles(styles)
        return self

    def apply_default_style(self, inc_cells=1, inc_header=1, inc_index=1, inc_index_header=0, cells_override=None,
                            header_override=None,
                            index_override=None):
        inc_cells and self.apply_default_cell_style(**(cells_override or {}))
        inc_header and self.apply_default_header_style(inc_index=inc_index_header, **(header_override or {}))
        inc_index and self.apply_default_index_style(**(index_override or {}))
        return self

    def apply_basic_style(self, font='Helvetica', font_bold='Helvetica-Bold', font_size=8, rpad=None, lpad=None,
                          bpad=None, tpad=None, colspans=1, rowspans=0, cmap=None, font_bw=1, stripe_rows=1,
                          stripe_cols=0,
                          hdr_border_clazz=BorderTypeGrid, cell_border_clazz=BorderTypeOutline, border_weight=.7):
        Style.apply_basic(self, font=font, font_bold=font_bold, font_size=font_size, rpad=rpad, lpad=lpad, bpad=bpad,
                          tpad=tpad, colspans=colspans, rowspans=rowspans)
        Style.apply_color(self, cmap, font_bw=font_bw, stripe_cols=stripe_cols, stripe_rows=stripe_rows,
                          hdr_border_clazz=hdr_border_clazz, cell_border_clazz=cell_border_clazz,
                          border_weight=border_weight)
        return self

    @property
    def all(self):
        return self['ALL']

    @property
    def header(self):
        return self['HEADER']

    @property
    def index(self):
        return self['INDEX']

    @property
    def index_header(self):
        return self['INDEX_HEADER']

    @property
    def cells(self):
        return self['CELLS']

    def set_row_heights(self, pcts=None, amts=None, maxs=None, mins=None):
        """
        :param pcts: the percent of available height to use or ratio is also ok
        :param amts: (Array or scalar) the fixed height of the rows
        :param maxs: (Array or scalar) the maximum height of the rows (only use when pcts is used)
        :param mins: (Array or scalar) the minimum height of the rows (only used when pcts is used)
        :return:
        """
        for arr, attr in zip([pcts, amts, maxs, mins], ['weight', 'value', 'max', 'min']):
            if arr is not None:
                if not np.isscalar(arr):
                    if len(arr) != len(self.formatted_values.index):
                        raise ValueError(
                            '%s: expected %s rows but got %s' % (attr, len(arr), len(self.formatted_values.index)))
                self.rowattrs.loc[:, attr] = arr
        return self

    def set_col_widths(self, pcts=None, amts=None, maxs=None, mins=None):
        """
        :param pcts: the percent of available width to use or ratio is also ok
        :param amts: (Array or scalar) the fixed width of the cols
        :param maxs: (Array or scalar) the maximum width of the cols (only use when pcts is used)
        :param mins: (Array or scalar) the minimum width of the cols (only used when pcts is used)
        :return:
        """
        for arr, attr in zip([pcts, amts, maxs, mins], ['weight', 'value', 'max', 'min']):
            if arr is not None:
                if not np.isscalar(arr):
                    if len(arr) != len(self.formatted_values.columns):
                        raise ValueError(
                            '%s: expected %s cols but got %s' % (attr, len(arr), len(self.formatted_values.columns)))
                self.colattrs.loc[:, attr] = arr
        return self

    def _resolve_dims(self, available, attrs):
        def _clean(v):
            return None if np.isnan(v) else v

        if attrs['value'].notnull().any():  # Static values
            # Assume that if one is set than all are set
            return [_clean(a) for a in attrs['value']]
        elif attrs['weight'].notnull().any():
            # Dynamic values
            f = attrs
            f['active'] = (attrs['weight'] * available) / attrs['weight'].sum()
            f['active'] = f[['active', 'min']].max(axis=1)
            f['active'] = f[['active', 'max']].min(axis=1)
            return list(f.active.fillna(0))
        elif attrs['min'].notnull().any():
            return [_clean(a) for a in attrs['min']]
        else:
            return None

    def resolve_col_widths(self, availWidth):
        return self._resolve_dims(availWidth, self.colattrs)

    def resolve_row_heights(self, availHeight):
        return self._resolve_dims(availHeight, self.rowattrs)

    def build(self, expand='wh', shrink='wh', vAlign='MIDDLE', hAlign='CENTER'):
        return TableLayout(self, expand, shrink, hAlign, vAlign)

    def _find_column_label_positions(self, match_value_or_fct, levels=None):
        """Check the original DataFrame's column labels to find the locations of columns. And return the adjusted
        column indexing within region (offset if including index)"""
        allmatches = find_locations(self.df.columns, match_value_or_fct, levels)
        if allmatches and self.inc_index:  # tramslate back
            allmatches = [m + self.nidxs for m in allmatches]
        return allmatches

    def _find_row_label_positions(self, match_value_or_fct, levels=None):
        """Check the original DataFrame's row labels to find the locations of rows. And return the adjusted
        row indexing within region (offset if including index)"""
        allmatches = find_locations(self.df.index, match_value_or_fct, levels)
        if allmatches and self.inc_index:  # tramslate back
            allmatches = [m + self.nhdrs for m in allmatches]
        return allmatches


class TableLayout(Flowable):
    def __init__(self, tb, expand='wh', shrink='wh', hAlign='CENTER', vAlign='MIDDLE'):
        self.tb = tb
        self.expand = expand or ''
        self.shrink = shrink or ''
        self.vAlign = vAlign
        self.hAlign = hAlign
        self._style_and_data = None
        self.component = None

    @property
    def style_and_data(self):
        if self._style_and_data is None:
            data = self.tb.formatted_values.values.tolist()
            style = TableStyle(self.tb.style_cmds)
            self._style_and_data = style, data
        return self._style_and_data

    def wrap(self, aw, ah):
        style, data = self.style_and_data
        # Apply any column / row sizes requested
        widths = self.tb.resolve_col_widths(aw)
        heights = self.tb.resolve_row_heights(ah)
        tbl = Table(data, colWidths=widths, rowHeights=heights, style=style, vAlign=self.vAlign, hAlign=self.hAlign,
                    repeatCols=False, repeatRows=True)
        w, h = tbl.wrap(aw, ah)
        pw, ph = w / float(aw), h / float(ah)
        shrink, expand = self.shrink, self.expand
        scale = 0
        if expand and pw < 1. and ph < 1.:
            scale = max('w' in expand and pw or 0, 'h' in expand and ph or 0)
        elif shrink and (pw > 1. or ph > 1.):
            scale = max('w' in shrink and pw or 0, 'h' in expand and ph or 0)

        if scale:
            self.component = comp = KeepInFrame(aw, ah, content=[tbl], hAlign=self.hAlign, vAlign=self.vAlign)
            w, h = comp.wrapOn(self.canv, aw, ah)
            comp._scale = scale
        else:
            self.component = tbl
        return w, h

    def drawOn(self, canvas, x, y, _sW=0):
        return self.component.drawOn(canvas, x, y, _sW=_sW)

    def split(self, aw, ah):
        if self.component:
            return self.component.split(aw, ah)
        else:
            return []