diff --git a/greater_tables/greater_tables.py b/greater_tables/greater_tables.py index 69652e7..50d87dc 100644 --- a/greater_tables/greater_tables.py +++ b/greater_tables/greater_tables.py @@ -34,7 +34,7 @@ if logger.hasHandlers(): # Clear existing handlers logger.handlers.clear() # SET DEGBUGGER LEVEL -LEVEL = logging.ERROR # DEBUG or INFO, WARNING, ERROR, CRITICAL +LEVEL = logging.WARNING # DEBUG or INFO, WARNING, ERROR, CRITICAL logger.setLevel(LEVEL) handler = logging.StreamHandler(sys.stderr) handler.setLevel(LEVEL) @@ -86,11 +86,171 @@ GT_Format = TableFormat( class GT(object): - """Create greater_tables.""" + """ + Create a greater_tables formatting object. + + Provides html and latex output in quarto/Jupyter accessible manner. + Wraps AND COPIES the dataframe df. WILL NOT REFLECT CHANGES TO DF. + + Recommended usage is to subclass GT (or use functools.partial) and set + defaults suitable to your particular + application. In that way you can maintain a "house-style" + + Process + -------- + + **Input transformation** + + * ``pd.Series`` converted to ``DataFrame`` + * ``list`` converted to ``DataFrame``, optionally using row 0 as + ``header_row`` + * A string is assumed to be a pipe-separated markdown table which is + converted to a ``DataFrame`` setting aligners per the alignment row + * All other input types are an error + + The input ``df`` must have unique column names. It is then copied into + ``self.df`` which will be changed and ``self.raw_df`` for reference. + The copy is hashed for the table name. + + **Mangling** + + * If show_index, the index is reset and kept, so that all columns are on an + equal footing + * The index change levels are computed to determine LaTeX hrules + * ratio year, and raw columns converted to a list (can be input as a single + string name) + * Columns, except raw columns, are cast to floats + * Column types by index determined + * default formatter function set (wrapping input, if any) + * Aligner column input decoded into aligner values + (``grt-left,grt-right,grt-center``); index aligners separated + * Formatters decoded, strings mapped to lambda functions as f-string + formatters, integers as number of decimals + * Tab values expanded into an iterable + * Dataframe at this point (index reset, cast) saved to + ``df_pre_applying_formatters`` + * Determine formatters (``df_formatters`` property, a list of column index + formatting functions: + * Make the default float formatter if entered (callable, string, number; + wrapped in try/except) + * Determine each column's format type and add function + * Run ``apply_formatters`` to apply all format choices to ``df``. This + function handles index columns slightly differently, but results in the + formatters being applied to each column. + * Sparsify if requested and if multiindex + * Result is a dataframe with all object column types and values that + reflect the formatting choices. + + + Parameters + ----------- + + :param df: target DataFrame or list of lists or markdown table string + :param caption: table caption, optional (GT will look for gt_caption + attribute of df and use that) + :param label: TeX label (used in \\label{} command). For markdown + tables with #tbl:... in the caption it is extracted automatically. + :param aligners: None or dict (type or colname) -> left | center | + right + :param formatters: None or dict (type or colname) -> format function + for the column; formatters trump ratio_cols + :param ratio_cols: None, or "all" or list of column names treated as + ratios. Set defaults in derived class suitable to application. + :param year_cols: None, or "all" or list of column names treated as + years (no commas, no decimals). Set defaults in derived class suitable + to application. + :param date_cols: None, or "all" or list of column names treated as + dates. Set defaults in derived class suitable to application. + :param raw_cols: None, or "all" or list of column names that are NOT + cast to floats. Set defaults in derived class suitable to application. + :param show_index: if True, show the index columns, default True + :param default_integer_str: format f-string for integers, default + value '{x:,d}' + :param default_float_str: format f-string for floats, default + value '{x:,.3f}' + :param default_date_str: format f-string for dates, default '%Y-%m-%d'. + NOTE: no braces or x! + :param default_ratio_str: format f-string for ratios, default '{x:.1%}' + :param table_float_format: None or format string for floats in the + table format function, applied to entire table, default None + :param table_hrule_width: width of the table top, botton and header + hrule, default 1 + :param table_vrule_width: width of the table vrule, separating the + index from the body, default 1 + :param hrule_widths: None or tuple of three ints for hrule widths + (for use with multiindexes) + :param vrule_widths: None or tuple of three ints for vrule widths + (for use when columns have multiindexes) + :param sparsify: if True, sparsify the index columns, you almost always + want this to be true! + :param sparsify_columns: if True, sparsify the columns, default True, + generally a better look, headings centered in colspans + :param spacing: 'tight', 'medium', 'wide' to quickly set cell padding. + Medium is default (2, 10, 2, 10). + :param padding_trbl: None or tuple of four ints for padding, in order + top, right, bottom, left. + :param tikz_scale: scale factor applied to tikz LaTeX tables. + :param font_body: font size for body text, default 0.9. Units in em. + :param font_head: font size for header text, default 1.0. Units in em. + :param font_caption: font size for caption text, default 1.1. + Units in em. + :param font_bold_index: if True, make the index columns bold, + default False. + :param pef_precision: precision (digits after period) for pandas + engineering format, default 3. + :param pef_lower: apply engineering format to floats with absolute + value < 10**pef_lower; default -3. + :param pef_upper: apply engineering format to floats with absolute + value > 10**pef_upper; default 6. + :param cast_to_floats: if True, try to cast all non-integer, non-date + columns to floats + :param header_row: True: use first row as headers; False no headings. + Default True + :param tabs: None or list of column widths in characters or a common + int or float width. (It is converted into em; one character is about + 0.5em on average; digits are exactly 0.5em.) If None, will be calculated. + Default None. + :param equal: if True, set all column widths equal. Default False. Maybe + ignored, depending on computed ideal column widths. + :param caption_align: for the caption + :param large_ok: signal that you are intentionally applying to a large + dataframe. Sub-classes may restrict or apply .head() to df. + :param max_str_length: maximum displayed length of object types, that + are cast to strings. Eg if you have nested DataFrames! + :param str_table_fmt: table border format used for string output + (markdown), default mixed_grid DEPRECATED?? + :param table_width_mode: + 'explicit': set using max_table_width + 'natural': each cell on one line (can be very wide with long strings) + 'breakable': wrap breakable cells (text strings) at word boundaries + to fit longest word + 'minimum': wrap breakable and ok-to-break (dates) cells + :param table_width_header_adjust: additional proportion of table width + used to balance header columns. + :param table_width_header_relax: extra spaces allowed per column heading + to facilitate better column header wrapping. + :param max_table_width: max table width used for markdown string output, + default 200; width is never less than minimum width. Padding (3 chars + per row plus 1) consumed out of max_table_width in string output mode. + :param debug: if True, add id to caption and use colored lines in table, + default False. + """ + + # TeX control sequence display widths (heuristic) + TEX_SIMPLE_GLYPHS = { + 'alpha', 'beta', 'gamma', 'delta', 'epsilon', 'zeta', 'eta', 'theta', + 'iota', 'kappa', 'lambda', 'mu', 'nu', 'xi', 'omicron', 'pi', 'rho', + 'sigma', 'tau', 'upsilon', 'phi', 'chi', 'psi', 'omega', 'infty', + 'sum', 'prod', 'int', 'cup', 'cap', 'vee', 'wedge', 'forall', 'exists', + 'neg', 'leq', 'geq', 'neq', 'approx', 'to', 'leftarrow', 'rightarrow' + } + TEX_WIDE = {'frac', 'sqrt', 'sum', 'int', 'prod'} + TEX_SPACING = {'quad', 'qquad', ',', ';', ' ', '!'} def __init__(self, df, caption='', + label='', aligners=None, formatters=None, ratio_cols=None, @@ -112,6 +272,7 @@ class GT(object): sparsify_columns=True, # column sparsification with colspans spacing='medium', # tight, medium, wide padding_trbl=None, # tuple of four ints for padding + tikz_scale=1.0, font_body=0.9, font_head=1.0, font_caption=1.1, @@ -132,133 +293,7 @@ class GT(object): table_width_header_relax=10, max_table_width=200, debug=False): - """ - Create a greater_tables formatting object. - Provides html and latex output in quarto/Jupyter accessible manner. - Wraps AND COPIES the dataframe df. WILL NOT REFLECT CHANGES TO DF. - - Recommended usage is to subclass GT (or use functools.partial) and set defaults suitable to your particular - application. In that way you can maintain a "house-style" - - Process - -------- - - **Input transformation** - - * ``pd.Series`` converted to ``DataFrame`` - * ``list`` converted to ``DataFrame``, optionally using row 0 as ``header_row`` - * A string is assumed to be a pipe-separated markdown table which is converted to a ``DataFrame`` setting aligners per the alignment row - * All other input types are an error - - The input ``df`` must have unique column names. It is then copied into ``self.df`` which will be changed and ``self.raw_df`` for reference. The copy is hashed for the table name. - - **Mangling** - - * The index is reset and kept, so that all columns are on an equal footing - * The index change levels are computed to determine LaTeX hrules - * ratio year, and raw columns converted to a list (can be input as a single string name) - * Columns, except raw columns, are cast to floats - * Column types by index determined - * default formatter function set (wrapping input, if any) - * Aligner column input decoded into aligner values (``grt-left,grt-right,grt-center``); index aligners separated - * Formatters decoded, strings mapped to lambda functions as f-string formatters, integers as number of decimals - * Tab values expanded into an iterable - * Dataframe at this point (index reset, cast) saved to ``df_pre_applying_formatters`` - * Determine formatters (``df_formatters`` property, a list of column index formatting functions: - * Make the default float formatter if entered (callable, string, number; wrapped in try/except) - * Determine each column's format type and add function - * Run ``apply_formatters`` to apply all format choices to ``df``. This function handles index columns slightly differently, but results in the formatters being applied to each column. - * Sparsify if requested and if multiindex - * Result is a dataframe with all object column types and values that reflect the formatting choices. - - - Parameters - ----------- - - :param df: target DataFrame or list of lists or markdown table string - :param caption: table caption, optional (GT will look for gt_caption - attribute of df and use that) - :param aligners: None or dict (type or colname) -> left | center | - right - :param formatters: None or dict (type or colname) -> format function - for the column; formatters trump ratio_cols - :param ratio_cols: None, or "all" or list of column names treated as - ratios. Set defaults in derived class suitable to application. - :param year_cols: None, or "all" or list of column names treated as - years (no commas, no decimals). Set defaults in derived class suitable to application. - :param date_cols: None, or "all" or list of column names treated as - dates. Set defaults in derived class suitable to application. - :param raw_cols: None, or "all" or list of column names that are NOT - cast to floats. Set defaults in derived class suitable to application. - :param show_index: if True, show the index columns, default True - :param default_integer_str: format f-string for integers, default - value '{x:,d}' - :param default_float_str: format f-string for floats, default - value '{x:,.3f}' - :param default_date_str: format f-string for dates, default '%Y-%m-%d'. - NOTE: no braces or x! - :param default_ratio_str: format f-string for ratios, default '{x:.1%}' - :param table_float_format: None or format string for floats in the - table format function, applied to entire table, default None - :param table_hrule_width: width of the table top, botton and header - hrule, default 1 - :param table_vrule_width: width of the table vrule, separating the - index from the body, default 1 - :param hrule_widths: None or tuple of three ints for hrule widths - (for use with multiindexes) - :param vrule_widths: None or tuple of three ints for vrule widths - (for use when columns have multiindexes) - :param sparsify: if True, sparsify the index columns, you almost always - want this to be true! - :param sparsify_columns: if True, sparsify the columns, default True, - generally a better look, headings centered in colspans - :param spacing: 'tight', 'medium', 'wide' to quickly set cell padding. - Medium is default (2, 10, 2, 10). - :param padding_trbl: None or tuple of four ints for padding, in order - top, right, bottom, left. - :param font_body: font size for body text, default 0.9. Units in em. - :param font_head: font size for header text, default 1.0. Units in em. - :param font_caption: font size for caption text, default 1.1. - Units in em. - :param font_bold_index: if True, make the index columns bold, - default False. - :param pef_precision: precision (digits after period) for pandas - engineering format, default 3. - :param pef_lower: apply engineering format to floats with absolute - value < 10**pef_lower; default -3. - :param pef_upper: apply engineering format to floats with absolute - value > 10**pef_upper; default 6. - :param cast_to_floats: if True, try to cast all non-integer, non-date - columns to floats - :param header_row: True: use first row as headers; False no headings. - Default True - :param tabs: None or list of column widths in characters or a common - int or float width. (It is converted into em; one character is about 0.5em on average; digits are exactly 0.5em.) If None, will be calculated. Default None. - :param equal: if True, set all column widths equal. Default False. - :param caption_align: for the caption - :param large_ok: signal that you are intentionally applying to a large - dataframe. Sub-classes may restrict or apply .head() to df. - :param max_str_length: maximum displayed length of object types, that - are cast to strings. Eg if you have nested DataFrames! - :param str_table_fmt: table border format used for string output - (markdown), default mixed_grid DEPRECATED?? - :param table_width_mode: - 'explicit': set using max_table_width - 'natural': each cell on one line (can be very wide with long strings) - 'breakable': wrap breakable cells (text strings) at word boundaries - to fit longest word - 'minimum': wrap breakable and ok-to-break (dates) cells - :param table_width_header_adjust: additional proportion of table width - used to balance header columns. - :param table_width_header_relax: extra spaces allowed per column heading - to facilitate better column header wrapping. - :param max_table_width: max table width used for markdown string output, - default 200; width is never less than minimum width. Padding (3 chars - per row plus 1) consumed out of max_table_width in string output mode. - :param debug: if True, add id to caption and use colored lines in table, - default False. - """ # deal with alternative input modes if df is None: # don't want None to fail @@ -278,8 +313,12 @@ class GT(object): # Drop first row and reset index df = df[1:].reset_index(drop=True) elif isinstance(df, str): - df, aligners = GT.md_to_df(df) - show_index = False + df = df.strip() + if df == '': + df = pd.DataFrame([]) + else: + df, aligners, caption, label = GT.md_to_df(df) + show_index = False else: raise ValueError( 'df must be a DataFrame, a list of lists, or a markdown table string') @@ -297,7 +336,7 @@ class GT(object): else: # used by querex etc. self.caption = getattr(df, 'gt_caption', '') - + self.label = label self.df = df.copy(deep=True) # the object being formatted self.raw_df = df.copy(deep=True) # if not column_names: @@ -540,6 +579,7 @@ class GT(object): self.font_body = font_body self.font_head = font_head self.font_caption = font_caption + self.tikz_scale = tikz_scale self.font_bold_index = font_bold_index self.caption_align = caption_align self.sparsify_columns = sparsify_columns @@ -576,7 +616,7 @@ class GT(object): self.df_style = '' self.df_html = '' self._clean_html = '' - self.tex = '' + self._clean_tex = '' # finally sparsify and then apply formaters # this radically alters the df, so keep a copy for now... self.df_pre_applying_formatters = self.df.copy() @@ -606,6 +646,15 @@ class GT(object): """ return self.html + def _repr_latex_(self): + """Generate a LaTeX tabular representation.""" + # return '' + # latex = self.df.to_latex(caption=self.caption, formatters=self._df_formatters) + if self._clean_tex == '': + self._clean_tex = self.make_tikz() + logger.info('CREATED LATEX') + return self._clean_tex + def cols_from_regex(self, regex): """Return columns of self.df matching regex""" return [col for col in self.df.columns if isinstance(col, str) and re.search(regex, col)] @@ -832,7 +881,9 @@ class GT(object): def make_column_width_df(self): """ - Return dataframe of width information. d + Return dataframe of width information. + + Returned dataframe has columns for * natural width, all on one line = max len by col * min width = max length given breaks @@ -924,59 +975,62 @@ class GT(object): # OK severely too small ans['recommended'] = ans['min_acceptable_width'] logger.warning('Desired width too small for pleasant formatting, table will be too wide.') - shortfall = min_acceptable - target_width - return ans + space = target_width - min_acceptable - # Allocate the excess ------------------------------ - # Fancy col headings currently only for 1-d index - # TODO NOTE: use sparsify logic you have for index applied to df.T - # to sort the columns!! input_df = None - if df.columns.nlevels == 1: - # Step 1: baseline comes in from code above - ans['raw_rec'] = ans['recommended'] + if space >= 0: + # Allocate the excess ------------------------------ + # Fancy col headings currently only for 1-d index + # TODO NOTE: use sparsify logic you have for index applied to df.T + # to sort the columns!! + if df.columns.nlevels == 1: + # Step 1: baseline comes in from code above + ans['raw_rec'] = ans['recommended'] - # Step 2: get rid of intra-line breaks - if max_extra > 0: - adj, input_df = self.header_adjustment(df, ans['recommended'], space, max_extra) - # create new col and populate per GPT - ans['header_tweak'] = pd.Series(adj) - else: - ans['header_tweak'] = 0 - ans['recommended'] = ans['recommended'] + ans['header_tweak'] - ans['natural_w_header'] = ans['recommended'] + # Step 2: get rid of intra-line breaks + if max_extra > 0: + adj, input_df = self.header_adjustment(df, ans['recommended'], space, max_extra) + # create new col and populate per GPT + ans['header_tweak'] = pd.Series(adj) + else: + ans['header_tweak'] = 0 + ans['recommended'] = ans['recommended'] + ans['header_tweak'] + ans['natural_w_header'] = ans['recommended'] - # Step 3: distribute remaining slack proportionally - remaining = target_width - ans['recommended'].sum() - if remaining > 0: - slack = ans['natural_width'] - ans['recommended'] - total_slack = slack.clip(lower=0).sum() - if total_slack > 0: - fractions = slack.clip(lower=0) / total_slack - ans['recommended'] += np.floor(fractions * remaining).astype(int) - ans['recommended'] = np.maximum(ans['recommended'], ans['natural_w_header']) + # Step 3: distribute remaining slack proportionally + remaining = target_width - ans['recommended'].sum() + if remaining > 0: + slack = ans['natural_width'] - ans['recommended'] + total_slack = slack.clip(lower=0).sum() + if total_slack > 0: + fractions = slack.clip(lower=0) / total_slack + ans['recommended'] += np.floor(fractions * remaining).astype(int) + ans['recommended'] = np.maximum(ans['recommended'], ans['natural_w_header']) - # Ensure final constraint - ans['recommended'] = ans['recommended'].astype(int) - logger.warning("Raw rec: %s\tTweaks: %s\tActual: %s\tTarget: %s\tOver/(U): %s", - ans['raw_rec'].sum(), - ans['header_tweak'].sum(), - ans['recommended'].sum(), - target_width, - ans['recommended'].sum() - target_width - ) - ans = ans[[ - 'alignment', - 'break_penalties', - 'breakability', - 'natural_width', - 'break_acceptable', - 'min_acceptable_width', - 'raw_rec', - 'header_tweak', - 'natural_w_header', - 'recommended', - ]] + # Ensure final constraint + ans['recommended'] = ans['recommended'].astype(int) + logger.warning("Raw rec: %s\tTweaks: %s\tActual: %s\tTarget: %s\tOver/(U): %s", + ans['raw_rec'].sum(), + ans['header_tweak'].sum(), + ans['recommended'].sum(), + target_width, + ans['recommended'].sum() - target_width + ) + ans = ans[[ + 'alignment', + 'break_penalties', + 'breakability', + 'natural_width', + 'break_acceptable', + 'min_acceptable_width', + 'raw_rec', + 'header_tweak', + 'natural_w_header', + 'recommended', + ]] + # in all cases... + # need recommended to be > 0 + ans['recommended'] = np.maximum(ans['recommended'], 1) self.cache_set('column_width_df', ans) # info about the header adjustment self.cache_set('input_df', input_df) @@ -1473,6 +1527,9 @@ class GT(object): # Start table html = [f'