From d2da15c35eec88854dec5b1f4058e2e9d673e6ea Mon Sep 17 00:00:00 2001 From: andersonfrailey Date: Wed, 10 Dec 2025 09:25:05 -0500 Subject: [PATCH 1/8] minimum working product --- statstables/renderers.py | 75 ++++++++++++++++++++++++++++++++++++++++ statstables/tables.py | 51 +++++++++++++++++++++++++-- 2 files changed, 123 insertions(+), 3 deletions(-) diff --git a/statstables/renderers.py b/statstables/renderers.py index 317c130..7dc3179 100644 --- a/statstables/renderers.py +++ b/statstables/renderers.py @@ -864,3 +864,78 @@ def padding(self, value): if value > 20: raise ValueError("Woah there buddy. That's a lot of space.") self._padding = value + + +class TypstRenderer(Renderer): + def __init__(self, table): + self.table = table + self.ncolumns = self.table.ncolumns + int( + self.table.table_params["include_index"] + ) + self.indent_level = 0 + + def render(self, in_figure: bool = True, include_settings: bool = False): + header = self.generate_header(in_figure=in_figure) + body = self.generate_body() + footer = self.generate_footer( + in_figure=in_figure, include_settings=include_settings + ) + return header + body + footer + + def generate_header(self, in_figure: bool = True, include_settings: bool = False): + header = "" + if include_settings: + header += "#{\\n set table(\\n" + if in_figure: + header += "#figure(\ntable(\n" + else: + header += "#table(\n" + # TODO: allow for all the other specifications typst supports + header += f" columns: {self.ncolumns},\n table.hline(stroke: 1.5pt),\n" + if self.table.table_params["show_columns"]: + header += f" table.header(" + _index_name = self.table.index_name + header += f"[{_index_name * self.table.table_params['include_index']}]," + for col in self.table.columns: + _col = self.table._column_labels.get(col, col) + header += f" [{_col}]," + header = header[:-1] + ")," # lop off the last comma + return header + "\n" + + def generate_body(self): + rows = self.table._create_rows() + body = "" + for row in rows: + body += " " + for r in row: + body += f"[{self._format_value(r)}]," + body += "\n" + return body + + def generate_footer(self, in_figure: bool = True, include_settings: bool = False): + return "table.hline()\n)\n" + (")\n" * in_figure) + ("}" * include_settings) + + def _create_line(self, line): + out = "" + if line["deliminate"]: + out += " table.hline\n" + out += f" [{line['label']}], " * self.table.table_params["include_index"] + for elm in line["line"]: + out += f" [{elm}]," + out += "\\\\\n" + return out + + def _format_value(self, formatting_dict, **kwargs): + start = "" + end = "" + if formatting_dict["bold"]: + start += "*" + end += "*" + if formatting_dict["italic"]: + start += "_" + end += "_" + if formatting_dict["color"] is not None: + start += f"text({formatting_dict['color']})[" + end += "]" + _value = formatting_dict["value"].replace("*", "\\*") + return start + _value + end diff --git a/statstables/tables.py b/statstables/tables.py index 4a5a396..d93f79d 100644 --- a/statstables/tables.py +++ b/statstables/tables.py @@ -10,7 +10,7 @@ from typing import Union, Callable, overload from collections import defaultdict, ChainMap from pathlib import Path -from .renderers import LatexRenderer, HTMLRenderer, ASCIIRenderer +from .renderers import LatexRenderer, HTMLRenderer, ASCIIRenderer, TypstRenderer from .utils import pstars, validate_line_location, VALID_LINE_LOCATIONS, latex_preamble from .parameters import TableParams, MeanDiffsTableParams, ModelTableParams from .cellformatting import DEFAULT_FORMATS, validate_format_dict @@ -592,11 +592,26 @@ def render_latex( Path(outfile).write_text(tex_str) return None + @overload + def render_html( + self, outfile: None, table_class: str, convert_latex: bool, *args, **kwargs + ) -> str: ... + + @overload + def render_html( + self, + outfile: Union[str, Path], + table_class: str, + convert_latex: bool, + *args, + **kwargs, + ) -> None: ... + def render_html( self, outfile: Union[str, Path, None] = None, - table_class="", - convert_latex=True, + table_class: str = "", + convert_latex: bool = True, *args, **kwargs, ) -> str | None: @@ -631,6 +646,36 @@ def render_html( def render_ascii(self, convert_latex=True) -> str: return ASCIIRenderer(self).render(convert_latex=convert_latex) + @overload + def render_typst( + self, + outfile: None, + in_figure: bool, + include_settings: bool, + ) -> str: ... + + @overload + def render_typst( + self, + outfile: Union[str, Path], + in_figure: bool, + include_settings: bool, + ) -> None: ... + + def render_typst( + self, + outfile: Union[str, Path, None] = None, + in_figure: bool = False, + include_settings: bool = False, + ) -> str | None: + typst_str = TypstRenderer(self).render( + in_figure=in_figure, include_settings=include_settings + ) + if not outfile: + return typst_str + Path(outfile).write_text(typst_str) + return None + def __str__(self) -> str: return self.render_ascii() From e43165b0d8b3ebe3cbefb14757feaba38b1b318a Mon Sep 17 00:00:00 2001 From: andersonfrailey Date: Sat, 13 Dec 2025 10:47:29 -0500 Subject: [PATCH 2/8] working multicolumns --- statstables/renderers.py | 104 +++++++++++++++++++++++++++++++++------ statstables/tables.py | 36 +++++++++++++- 2 files changed, 124 insertions(+), 16 deletions(-) diff --git a/statstables/renderers.py b/statstables/renderers.py index 7dc3179..93c5ced 100644 --- a/statstables/renderers.py +++ b/statstables/renderers.py @@ -1,4 +1,5 @@ import math +import warnings import statstables as st import textwrap from abc import ABC, abstractmethod @@ -867,39 +868,110 @@ def padding(self, value): class TypstRenderer(Renderer): + ALIGNMENTS = { + "l": "left", + "c": "center", + "r": "right", + "left": "left", + "center": "center", + "right": "right", + } + def __init__(self, table): self.table = table self.ncolumns = self.table.ncolumns + int( self.table.table_params["include_index"] ) - self.indent_level = 0 + self.ialign = self.ALIGNMENTS[self.table.table_params["index_alignment"]] + self.calign = self.ALIGNMENTS[self.table.table_params["column_alignment"]] - def render(self, in_figure: bool = True, include_settings: bool = False): - header = self.generate_header(in_figure=in_figure) + def render( + self, + in_figure: bool = True, + figure_params: dict | None = None, + table_params: dict | None = None, + override_settings: dict | None = None, + ): + # including an alt caption requires placing a table in a figure. + # auto turn it on in case the user leaves it off + if figure_params: + if not in_figure: + msg = ( + "Figure parameters were given but in_figure was set to False." + " statstables has changed in_figure to True to use the figure parameters." + ) + warnings.warn(msg) + in_figure = True + header = self.generate_header( + in_figure=in_figure, + figure_params=figure_params, + table_params=table_params, + ) body = self.generate_body() + has_override_settings = override_settings is not None footer = self.generate_footer( - in_figure=in_figure, include_settings=include_settings + in_figure=in_figure, has_override_settings=has_override_settings ) return header + body + footer - def generate_header(self, in_figure: bool = True, include_settings: bool = False): + def generate_header( + self, + in_figure: bool = True, + figure_params: dict | None = None, + table_params: dict | None = None, + override_settings: dict | None = None, + ): header = "" - if include_settings: - header += "#{\\n set table(\\n" + if override_settings: + header += "#{\n set table(\n" if in_figure: - header += "#figure(\ntable(\n" + header += "#figure(\n" + if figure_params: + for param, value in figure_params.items(): + header += f" {param}: {value},\n" + header += "table(\n" else: header += "#table(\n" - # TODO: allow for all the other specifications typst supports - header += f" columns: {self.ncolumns},\n table.hline(stroke: 1.5pt),\n" - if self.table.table_params["show_columns"]: + # add a small gutter between columns so that if there are multiple multicolumn + # lines and they are underlined there will be a slight gap between them. + # this will be overridden if the user provides a column-gutter parameter + col_gutter = " column-gutter: 0.5em,\n" + if table_params: + for param, value in table_params.items(): + if param == "column-gutter": + col_gutter = "" + header += f" {param}: {value},\n" + header += ( + f" columns: {self.ncolumns},\n{col_gutter} table.hline(stroke: 0.15em),\n" + ) + if self.table.table_params["show_columns"] or self.table._multicolumns: header += f" table.header(" + # multicolumns + for row in self.table._multicolumns: + header += ( + " [], " + * self.table.table_params["include_index"] + * (1 - row["cover_index"]) + ) + underline_line = "" + underline_start = 0 if row["cover_index"] else 1 + for c, s in zip(row["columns"], row["spans"]): + align = self.ALIGNMENTS[row["alignment"]] + header += f"table.cell([{c}], align: {align}, colspan: {s}), " + if row["underline"]: + underline_end = underline_start + s + underline_line += f"table.hline(start: {underline_start}, end:{underline_end}, stroke: 0.075em)," + underline_start = underline_end + if row["underline"]: + header += f"\n {underline_line}\n" + if self.table.table_params["show_columns"]: _index_name = self.table.index_name - header += f"[{_index_name * self.table.table_params['include_index']}]," + header += f" [{_index_name}]," * self.table.table_params["include_index"] for col in self.table.columns: _col = self.table._column_labels.get(col, col) header += f" [{_col}]," header = header[:-1] + ")," # lop off the last comma + header += "\n table.hline()," return header + "\n" def generate_body(self): @@ -912,8 +984,12 @@ def generate_body(self): body += "\n" return body - def generate_footer(self, in_figure: bool = True, include_settings: bool = False): - return "table.hline()\n)\n" + (")\n" * in_figure) + ("}" * include_settings) + def generate_footer( + self, in_figure: bool = True, has_override_settings: bool = False + ): + return ( + "table.hline()\n)\n" + (")\n" * in_figure) + ("}" * has_override_settings) + ) def _create_line(self, line): out = "" diff --git a/statstables/tables.py b/statstables/tables.py index d93f79d..40f2d19 100644 --- a/statstables/tables.py +++ b/statstables/tables.py @@ -175,6 +175,7 @@ def add_multicolumns( sum(spans) == _n_cols ), f"The sum of spans must equal the number of columns. There are {self.ncolumns} columns, but spans sum to {sum(spans)}" _position = len(self._multicolumns) if position is None else position + # TODO: Convert this into a class. should help with typing and clarrity row = { "columns": columns, "spans": spans, @@ -666,10 +667,41 @@ def render_typst( self, outfile: Union[str, Path, None] = None, in_figure: bool = False, - include_settings: bool = False, + figure_params: dict | None = None, + table_params: dict | None = None, + override_settings: dict | None = None, ) -> str | None: + """ + Render table formatted for typst documents + + Parameters + ---------- + outfile : Union[str, Path, None], optional + File name or file path to save the table to, by default None + in_figure : bool, optional + If true, wraps the table in a figure function, by default False + figure_params : dict | None, optional + Parameters to pass into the figure function. Note: statstables does + not validate the parameters included in this dictionary, by default None + table_params : dict | None, optional + Parameters to pass into the table function. Note: statstables does + not valuidate the parameters included in this dictionary, by default None + override_settings : dict | None, optional + Settings that can be used to override any default table settings in + your typst document, by default None + + Returns + ------- + str | None + If an outfile is not specified, the table is returned as a string + suitable for a typst document. Otherwise the table will save the + table to the specified file and return none. + """ typst_str = TypstRenderer(self).render( - in_figure=in_figure, include_settings=include_settings + in_figure=in_figure, + figure_params=figure_params, + table_params=table_params, + override_settings=override_settings, ) if not outfile: return typst_str From 54e489ef105ba51c494bc9472b30e23425edf29f Mon Sep 17 00:00:00 2001 From: andersonfrailey Date: Sat, 13 Dec 2025 10:56:30 -0500 Subject: [PATCH 3/8] working multicolumns --- statstables/renderers.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/statstables/renderers.py b/statstables/renderers.py index 93c5ced..de15337 100644 --- a/statstables/renderers.py +++ b/statstables/renderers.py @@ -987,9 +987,19 @@ def generate_body(self): def generate_footer( self, in_figure: bool = True, has_override_settings: bool = False ): - return ( + footer = "" + if self.table.custom_lines["after-footer"]: + for line in self.table.custom_lines["after-footer"]: + footer += self._create_line(line) + if self.table.notes: + for note, alignment, _ in self.table.notes: + col_span = self.ncolumns + self.table.table_params["include_index"] + align = self.ALIGNMENTS[alignment] + footer += f" table.cell(colspan: {col_span}, [{note}], align: {align})" + footer += ( "table.hline()\n)\n" + (")\n" * in_figure) + ("}" * has_override_settings) ) + return footer def _create_line(self, line): out = "" From 958f164ecfeb832f2ee4139848b28c5202aabcf7 Mon Sep 17 00:00:00 2001 From: andersonfrailey Date: Sat, 13 Dec 2025 12:23:36 -0500 Subject: [PATCH 4/8] add caption, labels to tables. add custom lines --- statstables/renderers.py | 32 +++++++++++++++++++++++++++++--- statstables/tables.py | 13 ++++++++++++- 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/statstables/renderers.py b/statstables/renderers.py index de15337..7a7cca2 100644 --- a/statstables/renderers.py +++ b/statstables/renderers.py @@ -892,8 +892,6 @@ def render( table_params: dict | None = None, override_settings: dict | None = None, ): - # including an alt caption requires placing a table in a figure. - # auto turn it on in case the user leaves it off if figure_params: if not in_figure: msg = ( @@ -922,13 +920,18 @@ def generate_header( override_settings: dict | None = None, ): header = "" + # add local scope for settings that override the main document settings if override_settings: header += "#{\n set table(\n" if in_figure: header += "#figure(\n" + caption = f" caption: [{self.table.caption}],\n" if figure_params: for param, value in figure_params.items(): + if param == "caption": + caption = "" header += f" {param}: {value},\n" + header += f" {caption}" header += "table(\n" else: header += "#table(\n" @@ -982,6 +985,24 @@ def generate_body(self): for r in row: body += f"[{self._format_value(r)}]," body += "\n" + + for line in self.table.custom_tex_lines["after-body"]: + body += line + for line in self.table.custom_lines["after-body"]: + body += self._create_line(line) + + if isinstance(self.table, st.tables.ModelTable): + body += " table.hline(stroke: 0.05em),\n" + for line in self.table.custom_lines["before-model-stats"]: + body += self._create_line(line) + stats_rows = self.table._create_stats_rows(renderer="typst") + for row in stats_rows: + body += " " + for r in row: + body += f"[{self._format_value(r)}]," + body += "\n" + for line in self.table.custom_lines["after-model-stats"]: + body += self._create_line(line) return body def generate_footer( @@ -996,8 +1017,13 @@ def generate_footer( col_span = self.ncolumns + self.table.table_params["include_index"] align = self.ALIGNMENTS[alignment] footer += f" table.cell(colspan: {col_span}, [{note}], align: {align})" + label = "" + if self.table.label: + label = f"<{self.table.label}>" footer += ( - "table.hline()\n)\n" + (")\n" * in_figure) + ("}" * has_override_settings) + "table.hline()\n)\n" + + (f"){label}\n" * in_figure) + + ("}" * has_override_settings) ) return footer diff --git a/statstables/tables.py b/statstables/tables.py index 40f2d19..a4cf42d 100644 --- a/statstables/tables.py +++ b/statstables/tables.py @@ -1216,13 +1216,23 @@ class ModelTable(Table): model_stats = [ ("observations", "Observations", False), ("ngroups", "N. Groups", False), - ("r2", {"latex": "$R^2$", "html": "R2", "ascii": "R^2"}, False), + ( + "r2", + { + "latex": "$R^2$", + "html": "R2", + "ascii": "R^2", + "typst": "$R^2$", + }, + False, + ), ( "adjusted_r2", { "latex": "Adjusted $R^2$", "html": "Adjusted R2", "ascii": "Adjusted R^2", + "typst": "Adjusted $R^2$", }, False, ), @@ -1232,6 +1242,7 @@ class ModelTable(Table): "latex": "Pseudo $R^2$", "html": "Pseudo R2", "ascii": "Pseudo R^2", + "typst": "Pseudo $R^2$", }, False, ), From 994bc39225f65a7a1dcf23e4f627668be4acab7a Mon Sep 17 00:00:00 2001 From: andersonfrailey Date: Fri, 16 Jan 2026 19:40:04 -0500 Subject: [PATCH 5/8] add custom lines; type overloads --- statstables/renderers.py | 33 +++++++-- statstables/tables.py | 153 ++++++++++++++++++++++++++++++++++++--- 2 files changed, 167 insertions(+), 19 deletions(-) diff --git a/statstables/renderers.py b/statstables/renderers.py index 7a7cca2..2c4acbb 100644 --- a/statstables/renderers.py +++ b/statstables/renderers.py @@ -241,6 +241,8 @@ def generate_body(self): row_str += self._create_line(line) if isinstance(self.table, st.tables.ModelTable): row_str += " \\midrule\n" + for line in self.table.custom_tex_lines["before-model-stats"]: + row_str += line for line in self.table.custom_lines["before-model-stats"]: row_str += self._create_line(line) stats_rows = self.table._create_stats_rows(renderer="latex") @@ -338,7 +340,7 @@ class HTMLRenderer(Renderer): "right": "right", } - def __init__(self, table, _class): + def __init__(self, table, _class: str | None): self.table = table self.ncolumns = self.table.ncolumns + int( self.table.table_params["include_index"] @@ -904,6 +906,7 @@ def render( in_figure=in_figure, figure_params=figure_params, table_params=table_params, + override_settings=override_settings, ) body = self.generate_body() has_override_settings = override_settings is not None @@ -923,6 +926,9 @@ def generate_header( # add local scope for settings that override the main document settings if override_settings: header += "#{\n set table(\n" + for param, value in override_settings.items(): + header += f" {param}: {value},\n" + header += ");\n\n[" if in_figure: header += "#figure(\n" caption = f" caption: [{self.table.caption}],\n" @@ -931,7 +937,8 @@ def generate_header( if param == "caption": caption = "" header += f" {param}: {value},\n" - header += f" {caption}" + if self.table.caption is not None: + header += f" {caption}" header += "table(\n" else: header += "#table(\n" @@ -974,8 +981,14 @@ def generate_header( _col = self.table._column_labels.get(col, col) header += f" [{_col}]," header = header[:-1] + ")," # lop off the last comma - header += "\n table.hline()," - return header + "\n" + # header += "\n table.hline(stroke: 0.05em)," + + if self.table.custom_lines["after-columns"]: + for line in self.table.custom_lines["after-columns"]: + header += self._create_line(line) + if self.table.table_params["show_columns"]: + header += " table.hline(stroke: 0.05em)," + return header def generate_body(self): rows = self.table._create_rows() @@ -986,13 +999,15 @@ def generate_body(self): body += f"[{self._format_value(r)}]," body += "\n" - for line in self.table.custom_tex_lines["after-body"]: + for line in self.table.custom_typst_lines["after-body"]: body += line for line in self.table.custom_lines["after-body"]: body += self._create_line(line) if isinstance(self.table, st.tables.ModelTable): body += " table.hline(stroke: 0.05em),\n" + for line in self.table.custom_typst_lines["before-model-stats"]: + body += line for line in self.table.custom_lines["before-model-stats"]: body += self._create_line(line) stats_rows = self.table._create_stats_rows(renderer="typst") @@ -1001,6 +1016,8 @@ def generate_body(self): for r in row: body += f"[{self._format_value(r)}]," body += "\n" + for line in self.table.custom_typst_lines["after-model-stats"]: + body += line for line in self.table.custom_lines["after-model-stats"]: body += self._create_line(line) return body @@ -1023,18 +1040,18 @@ def generate_footer( footer += ( "table.hline()\n)\n" + (f"){label}\n" * in_figure) - + ("}" * has_override_settings) + + ("]}" * has_override_settings) ) return footer def _create_line(self, line): out = "" if line["deliminate"]: - out += " table.hline\n" + out += "\n table.hline(stroke: 0.05em),\n" out += f" [{line['label']}], " * self.table.table_params["include_index"] for elm in line["line"]: out += f" [{elm}]," - out += "\\\\\n" + out += "\n" return out def _format_value(self, formatting_dict, **kwargs): diff --git a/statstables/tables.py b/statstables/tables.py index a4cf42d..13159d5 100644 --- a/statstables/tables.py +++ b/statstables/tables.py @@ -83,6 +83,7 @@ def reset_custom_features(self): self.custom_lines = defaultdict(list) self.custom_tex_lines = defaultdict(list) self.custom_html_lines = defaultdict(list) + self.custom_typst_lines = defaultdict(list) def reset_all(self, restore_to_defaults=False): self.reset_params(restore_to_defaults) @@ -424,14 +425,14 @@ def add_latex_line(self, line: str, location: str = "after-body") -> None: assumes the line is formatted as needed, including escape characters and line breaks. The provided line will be rendered as is. Note that this is different from the generic add_line method, which will format the line - to fit in either LaTeX or HTML output. + to render in the specified output. Parameters ---------- line : str The line to add to the table location : str, optional - Where in the table to place the line, by default "bottom" + Where in the table to place the line, by default "after-body" """ validate_line_location(location) self.custom_tex_lines[location].append(line) @@ -548,6 +549,69 @@ def remove_html_line( elif index is not None: self.custom_html_lines[location].pop(index) + def add_typst_line(self, line: str, location: str = "after-body") -> None: + """ + Add line that will only be rendered in Typst output. This method assumes + the line is formatted as needed. The provided line with be rendered as is. + Note that this is different from the generic add_line method, which will + format the line to render in the specified output. + + Parameters + ---------- + line : str + The line to add to the table + location : str, optional + Where in the table to place the line, by default "after-body" + """ + validate_line_location(location) + self.custom_typst_lines[location].append(line) + + def remove_typst_line( + self, + location: str | None = None, + line: str | None = None, + index: int | None = None, + all: bool = False, + ) -> None: + """ + Remove a custom Typst line. To specify which line to remove, either pass the list + containing the line as the 'line' parameter or the index of the line as the + 'index' parameter. + + Parameters + ---------- + location : str + Where in the table the line is located. + line : list, optional + List containing the line elements. + index : int, optional + Index of the line in the custom line list for the specified location. + all : bool, optional + Remove all custom LaTex lines. If true and `location` = None, all custom + lines in every position will be removed. Otherwise only the lines + in the provided location are removed. + + Raises + ------ + ValueError + Raises an error if neither 'line' or 'index' are provided, or if the + line cannot be found in the custom lines list. + """ + if location is None and all: + for loc in VALID_LINE_LOCATIONS: + self.custom_typst_lines[loc].clear() + return None + if location is None and not all: + raise ValueError("Either a location must be provided or all must be true") + validate_line_location(location) + if line is None and index is None: + raise ValueError("Either 'line' or 'index' must be provided") + + if line is not None: + self.custom_typst_lines[location].remove(line) + elif index is not None: + self.custom_typst_lines[location].pop(index) + @overload def render_latex(self, outfile: None, only_tabular: bool) -> str: ... @@ -595,14 +659,19 @@ def render_latex( @overload def render_html( - self, outfile: None, table_class: str, convert_latex: bool, *args, **kwargs + self, + outfile: None, + table_class: str | None, + convert_latex: bool, + *args, + **kwargs, ) -> str: ... @overload def render_html( self, outfile: Union[str, Path], - table_class: str, + table_class: str | None, convert_latex: bool, *args, **kwargs, @@ -611,7 +680,7 @@ def render_html( def render_html( self, outfile: Union[str, Path, None] = None, - table_class: str = "", + table_class: str | None = None, convert_latex: bool = True, *args, **kwargs, @@ -652,7 +721,9 @@ def render_typst( self, outfile: None, in_figure: bool, - include_settings: bool, + figure_params: dict | None = None, + table_params: dict | None = None, + override_settings: dict | None = None, ) -> str: ... @overload @@ -660,7 +731,9 @@ def render_typst( self, outfile: Union[str, Path], in_figure: bool, - include_settings: bool, + figure_params: dict | None = None, + table_params: dict | None = None, + override_settings: dict | None = None, ) -> None: ... def render_typst( @@ -1092,9 +1165,38 @@ def render_latex( ) -> str | None: return super().render_latex(outfile, only_tabular) + @overload + def render_html( + self, + outfile: Union[str, Path], + table_class: str | None, + convert_latex: bool, + *args, + **kwargs, + ) -> None: ... + + @overload + def render_html( + self, + outfile: None, + table_class: str | None, + convert_latex: bool, + *args, + **kwargs, + ) -> str: ... + @_render - def render_html(self, outfile=None, convert_latex=True) -> str | None: - return super().render_html(outfile=outfile, convert_latex=convert_latex) + def render_html( + self, + outfile: Union[str, Path, None] = None, + table_class: str | None = None, + convert_latex: bool = True, + *args, + **kwargs, + ) -> str | None: + return super().render_html( + outfile=outfile, table_class=table_class, convert_latex=convert_latex + ) @_render def render_ascii(self, convert_latex=True) -> str: @@ -1579,9 +1681,38 @@ def render_latex( ) -> Union[str, None]: return super().render_latex(outfile=outfile, only_tabular=only_tabular) + @overload + def render_html( + self, + outfile: None, + table_class: str | None, + convert_latex: bool, + *args, + **kwargs, + ) -> str: ... + + @overload + def render_html( + self, + outfile: str, + table_class: str | None, + convert_latex: bool, + *args, + **kwargs, + ) -> None: ... + @_render - def render_html(self, outfile=None, convert_latex: bool = True) -> Union[str, None]: - return super().render_html(outfile=outfile, convert_latex=convert_latex) + def render_html( + self, + outfile: str | None = None, + table_class: str | None = None, + convert_latex: bool = True, + *args, + **kwargs, + ) -> Union[str, None]: + return super().render_html( + outfile=outfile, table_class=table_class, convert_latex=convert_latex + ) @_render def render_ascii(self, convert_latex=True) -> str: From 676f141d77806c1b93fd203eb3c7ac83e4cd1ed0 Mon Sep 17 00:00:00 2001 From: andersonfrailey Date: Fri, 16 Jan 2026 21:40:43 -0500 Subject: [PATCH 6/8] fix all the type hints --- statstables/__init__.py | 6 +- statstables/renderers.py | 9 +- statstables/tables.py | 218 +++++++++++++++++-------------- statstables/tests/test_tables.py | 10 +- 4 files changed, 137 insertions(+), 106 deletions(-) diff --git a/statstables/__init__.py b/statstables/__init__.py index 68c0bbc..d292b4c 100644 --- a/statstables/__init__.py +++ b/statstables/__init__.py @@ -1,4 +1,4 @@ -from typing import Any +from typing import Any, Hashable from statstables import ( tables, renderers, @@ -45,12 +45,12 @@ def add_model(self, model_results_class: Any, output_class: Any) -> None: def _keyname(key: str): return key.replace("", "") - def __setitem__(self, key: str, value: Any): + def __setitem__(self, key: Hashable, value: Any): msg = "Custom models must inherit from the ModelData class" assert value.__base__ == modeltables.ModelData, msg self.models[key] = value - def __getitem__(self, key: str): + def __getitem__(self, key: Hashable): # custom models will be saved with their type as the key, but the natively # supported models are passed in as strings (see initialization below) # so they will be found in the first exception diff --git a/statstables/renderers.py b/statstables/renderers.py index 2c4acbb..41fd9fa 100644 --- a/statstables/renderers.py +++ b/statstables/renderers.py @@ -415,7 +415,7 @@ def generate_body(self, convert_latex=True): alignment = self.calign if i == 0 and self.table.table_params["include_index"]: alignment = self.ialign - val = self._format_value(r, alignment) + val = self._format_value(r, alignment=alignment) if convert_latex: val = replace_latex(val) row_str += f"{val}\n" @@ -440,7 +440,7 @@ def generate_body(self, convert_latex=True): alignment = self.calign if i == 0 and self.table.table_params["include_index"]: alignment = self.ialign - val = self._format_value(r, alignment) + val = self._format_value(r, alignment=alignment) row_str += f"{val}\n" row_str += " \n" for line in self.table.custom_lines["after-model-stats"]: @@ -500,7 +500,7 @@ def _create_line(self, line, convert_latex=True): return out - def _format_value(self, formatting_dict: dict, alignment: str, **kwargs) -> str: + def _format_value(self, formatting_dict: dict, **kwargs) -> str: cell = f" str: _id = formatting_dict["id"] cell += f' id="{_id}"' # cell style section - style = f' style="text-align: {alignment};' + style = f' style="text-align: {kwargs["alignment"]};' if formatting_dict["color"]: style += f" color: {formatting_dict['color']};" # close out the attributes section of the code @@ -821,6 +821,7 @@ def _get_table_widths(self, convert_latex=True) -> None: for col in self.table.columns: # check label size label = self.table._column_labels.get(col, col) + assert isinstance(label, str) if convert_latex: label = replace_latex(label) col_size = len(str(label)) + (self.padding * 2) diff --git a/statstables/tables.py b/statstables/tables.py index 13159d5..5371458 100644 --- a/statstables/tables.py +++ b/statstables/tables.py @@ -1,19 +1,22 @@ import copy import numbers -import pandas as pd -import numpy as np -import statstables as st +from abc import ABC, abstractmethod +from collections import ChainMap, defaultdict +from pathlib import Path +from typing import Callable, Hashable, overload + import narwhals as nw +import numpy as np +import pandas as pd from narwhals.typing import IntoDataFrame -from abc import ABC, abstractmethod from scipy import stats -from typing import Union, Callable, overload -from collections import defaultdict, ChainMap -from pathlib import Path -from .renderers import LatexRenderer, HTMLRenderer, ASCIIRenderer, TypstRenderer -from .utils import pstars, validate_line_location, VALID_LINE_LOCATIONS, latex_preamble -from .parameters import TableParams, MeanDiffsTableParams, ModelTableParams + +import statstables as st + from .cellformatting import DEFAULT_FORMATS, validate_format_dict +from .parameters import MeanDiffsTableParams, ModelTableParams, TableParams +from .renderers import ASCIIRenderer, HTMLRenderer, LatexRenderer, TypstRenderer +from .utils import VALID_LINE_LOCATIONS, latex_preamble, pstars, validate_line_location class Table(ABC): @@ -21,6 +24,17 @@ class Table(ABC): Abstract class for defining common characteristics/methods of all tables """ + columns: list + _multicolumns: list[dict] + _index_labels: dict[str, str] + _column_labels: dict[str, str] + notes: list + _formatters: dict[tuple | str, Callable] + custom_lines: defaultdict[str | None, list] + custom_tex_lines: defaultdict[str | None, list] + custom_html_lines: defaultdict[str | None, list] + custom_typst_lines: defaultdict[str | None, list] + def __init__( self, *, @@ -144,7 +158,7 @@ def add_multicolumns( Parameters ---------- - columns : Union[str, list[str]] + columns : str | list[str]] If a single string is provided, it will span the entire table. If a list is provided, each will span the number of columns in the corresponding index of the spans list. @@ -300,7 +314,10 @@ def add_notes(self, notes: list[tuple] | None) -> None: raise ValueError(f"Note {i} yields error {e}") def remove_note( - self, note: str | None = None, index: int | None = None, all: bool = False + self, + note: str | None = None, + index: int | None = None, + remove_all: bool = False, ) -> None: """ Removes a note that has been added to the table. To specify which note, @@ -313,7 +330,7 @@ def remove_note( Text of note to remove, by default None index : int, optional Index of the note to be removed, by default None - all : bool, optional + remove_all : bool, optional If true, remove all notes from the table Raises @@ -321,7 +338,7 @@ def remove_note( ValueError Raises and error if neither 'note' or 'index' are provided """ - if all: + if remove_all: self.notes.clear() return None if note is None and index is None: @@ -442,7 +459,7 @@ def remove_latex_line( location: str | None = None, line: str | None = None, index: int | None = None, - all: bool = False, + remove_all: bool = False, ) -> None: """ Remove a custom LaTex line. To specify which line to remove, either pass the list @@ -457,7 +474,7 @@ def remove_latex_line( List containing the line elements. index : int, optional Index of the line in the custom line list for the specified location. - all : bool, optional + remove_all : bool, optional Remove all custom LaTex lines. If true and `location` = None, all custom lines in every position will be removed. Otherwise only the lines in the provided location are removed. @@ -468,11 +485,11 @@ def remove_latex_line( Raises an error if neither 'line' or 'index' are provided, or if the line cannot be found in the custom lines list. """ - if location is None and all: + if location is None and remove_all: for loc in VALID_LINE_LOCATIONS: self.custom_tex_lines[loc].clear() return None - if location is None and not all: + if location is None and not remove_all: raise ValueError("Either a location must be provided or all must be true") validate_line_location(location) if line is None and index is None: @@ -508,7 +525,7 @@ def remove_html_line( location: str | None = None, line: str | None = None, index: int | None = None, - all: bool = False, + remove_all: bool = False, ): """ Remove a custom HTML line. To specify which line to remove, either pass the list @@ -523,7 +540,7 @@ def remove_html_line( List containing the line elements. index : int, optional Index of the line in the custom line list for the specified location. - all : bool, optional + remove_all : bool, optional Remove all custom LaTex lines. If true and `location` = None, all custom lines in every position will be removed. Otherwise only the lines in the provided location are removed. @@ -534,11 +551,11 @@ def remove_html_line( Raises an error if neither 'line' or 'index' are provided, or if the line cannot be found in the custom lines list. """ - if location is None and all: + if location is None and remove_all: for loc in VALID_LINE_LOCATIONS: self.custom_html_lines[loc].clear() return None - if location is None and not all: + if location is None and not remove_all: raise ValueError("Either a location must be provided or all must be true") validate_line_location(location) if line is None and index is None: @@ -571,7 +588,7 @@ def remove_typst_line( location: str | None = None, line: str | None = None, index: int | None = None, - all: bool = False, + remove_all: bool = False, ) -> None: """ Remove a custom Typst line. To specify which line to remove, either pass the list @@ -586,7 +603,7 @@ def remove_typst_line( List containing the line elements. index : int, optional Index of the line in the custom line list for the specified location. - all : bool, optional + remove_all : bool, optional Remove all custom LaTex lines. If true and `location` = None, all custom lines in every position will be removed. Otherwise only the lines in the provided location are removed. @@ -597,11 +614,11 @@ def remove_typst_line( Raises an error if neither 'line' or 'index' are provided, or if the line cannot be found in the custom lines list. """ - if location is None and all: + if location is None and remove_all: for loc in VALID_LINE_LOCATIONS: self.custom_typst_lines[loc].clear() return None - if location is None and not all: + if location is None and not remove_all: raise ValueError("Either a location must be provided or all must be true") validate_line_location(location) if line is None and index is None: @@ -613,15 +630,14 @@ def remove_typst_line( self.custom_typst_lines[location].pop(index) @overload - def render_latex(self, outfile: None, only_tabular: bool) -> str: ... - + def render_latex(self, outfile: None = None, only_tabular: bool = False) -> str: ... @overload - def render_latex(self, outfile: Union[str, Path], only_tabular: bool) -> None: ... + def render_latex(self, outfile: str | Path, only_tabular: bool = False) -> None: ... def render_latex( self, - outfile: Union[str, Path, None] = None, - only_tabular=False, + outfile: str | Path | None = None, + only_tabular: bool = False, *args, **kwargs, ) -> str | None: @@ -642,7 +658,7 @@ def render_latex( Returns ------- - Union[str, None] + str | None If an outfile is not specified, the LaTeX string will be returned. Otherwise None will be returned. """ @@ -660,26 +676,25 @@ def render_latex( @overload def render_html( self, - outfile: None, - table_class: str | None, - convert_latex: bool, + outfile: None = None, + table_class: str | None = None, + convert_latex: bool = True, *args, **kwargs, ) -> str: ... - @overload def render_html( self, - outfile: Union[str, Path], - table_class: str | None, - convert_latex: bool, + outfile: str | Path, + table_class: str | None = None, + convert_latex: bool = True, *args, **kwargs, ) -> None: ... def render_html( self, - outfile: Union[str, Path, None] = None, + outfile: str | Path | None = None, table_class: str | None = None, convert_latex: bool = True, *args, @@ -701,7 +716,7 @@ def render_html( Returns ------- - Union[str, None] + str| None If an outfile is not specified, the HTML string will be returned. Otherwise None will be returned. """ @@ -713,24 +728,23 @@ def render_html( Path(outfile).write_text(html_str) return None - def render_ascii(self, convert_latex=True) -> str: + def render_ascii(self, convert_latex: bool = True) -> str: return ASCIIRenderer(self).render(convert_latex=convert_latex) @overload def render_typst( self, - outfile: None, - in_figure: bool, + outfile: None = None, + in_figure: bool = False, figure_params: dict | None = None, table_params: dict | None = None, override_settings: dict | None = None, ) -> str: ... - @overload def render_typst( self, - outfile: Union[str, Path], - in_figure: bool, + outfile: str | Path, + in_figure: bool = False, figure_params: dict | None = None, table_params: dict | None = None, override_settings: dict | None = None, @@ -738,7 +752,7 @@ def render_typst( def render_typst( self, - outfile: Union[str, Path, None] = None, + outfile: str | Path | None = None, in_figure: bool = False, figure_params: dict | None = None, table_params: dict | None = None, @@ -749,7 +763,7 @@ def render_typst( Parameters ---------- - outfile : Union[str, Path, None], optional + outfile : str | Path | None, optional File name or file path to save the table to, by default None in_figure : bool, optional If true, wraps the table in a figure function, by default False @@ -790,7 +804,7 @@ def __repr__(self) -> str: def _repr_html_(self): return self.render_html() - def _default_formatter(self, value: Union[int, float, str], **kwargs) -> str: + def _default_formatter(self, value: int | float | str, **kwargs) -> str: thousands_sep = self.table_params["thousands_sep"] sig_digits = self.table_params["sig_digits"] # format the numbers, otherwise just return a string @@ -802,17 +816,21 @@ def _default_formatter(self, value: Union[int, float, str], **kwargs) -> str: def _format_value( self, - _index: str | int | None, - col: str | int | None, - value: Union[int, float, str], + _index: str | int | Hashable | None, + col: str | int | Hashable | None, + value: int | float | str, **kwargs, ) -> ChainMap: if (_index, col) in self._formatters.keys(): formatter = self._formatters[(_index, col)] elif _index in self._formatters.keys(): - formatter = self._formatters.get(_index, self._default_formatter) + formatter = self._formatters.get( + _index, self._default_formatter # type:ignore + ) elif col in self._formatters.keys(): - formatter = self._formatters.get(col, self._default_formatter) + formatter = self._formatters.get( + col, self._default_formatter # type:ignore + ) else: formatter = self.default_formatter # for if the row is blank @@ -928,7 +946,7 @@ class GenericTable(Table): def __init__(self, df: IntoDataFrame, **kwargs): self.df = nw.from_native(df).to_pandas() self.ncolumns = self.df.shape[1] - self.columns = self.df.columns + self.columns = list(self.df.columns) self.nrows = self.df.shape[0] super().__init__(**kwargs) @@ -943,7 +961,9 @@ def _create_rows(self) -> list[list[ChainMap]]: for _index, row in self.df.iterrows(): _row = [ self._format_value( - f"{_index}_label", "_index", self._index_labels.get(_index, _index) + f"{_index}_label", + "_index", + self._index_labels.get(_index, _index), # type:ignore ) ] for col, value in zip(row.index, row.values): @@ -1062,7 +1082,7 @@ def __init__( self._get_diffs() self.ncolumns = self.means.shape[1] # convert columns to strings to avoid issues with numerical groups - self.columns = self.means.columns.astype(str) + self.columns = list(self.means.columns.astype(str)) self.reset_custom_features() self.rename_columns(column_labels) self.rename_index(index_labels) @@ -1154,41 +1174,41 @@ def wrapper(self, *args, **kwargs): return wrapper @overload - def render_latex(self, outfile: None, only_tabular: bool) -> str: ... - + def render_latex(self, outfile: None = None, only_tabular: bool = False) -> str: ... @overload - def render_latex(self, outfile: Union[str, Path], only_tabular: bool) -> None: ... + def render_latex(self, outfile: str | Path, only_tabular: bool = False) -> None: ... @_render def render_latex( - self, outfile: Union[str, Path, None] = None, only_tabular: bool = False + self, + outfile: str | Path | None = None, + only_tabular: bool = False, ) -> str | None: return super().render_latex(outfile, only_tabular) @overload def render_html( self, - outfile: Union[str, Path], - table_class: str | None, - convert_latex: bool, + outfile: None = None, + table_class: str | None = None, + convert_latex: bool = True, *args, **kwargs, - ) -> None: ... - + ) -> str: ... @overload def render_html( self, - outfile: None, - table_class: str | None, - convert_latex: bool, + outfile: str | Path, + table_class: str | None = None, + convert_latex: bool = True, *args, **kwargs, - ) -> str: ... + ) -> None: ... @_render def render_html( self, - outfile: Union[str, Path, None] = None, + outfile: str | Path | None = None, table_class: str | None = None, convert_latex: bool = True, *args, @@ -1199,7 +1219,7 @@ def render_html( ) @_render - def render_ascii(self, convert_latex=True) -> str: + def render_ascii(self, convert_latex: bool = True) -> str: return super().render_ascii(convert_latex=convert_latex) def _get_diffs(self): @@ -1247,13 +1267,15 @@ def _create_rows(self) -> list[list[ChainMap]]: sem_row = [self._format_value(f"{_index}_label", "_index", "")] _row = [ self._format_value( - f"{_index}_label", "_index", self._index_labels.get(_index, _index) + f"{_index}_label", + "_index", + self._index_labels.get(_index, _index), # type:ignore ) ] for col, value in zip(row.index, row.values): # pull standard error and p-value try: - se = self.sem.loc[_index, col] + se = self.sem.loc[_index, col] # type:ignore except KeyError: se = None try: @@ -1265,7 +1287,7 @@ def _create_rows(self) -> list[list[ChainMap]]: ) if self.table_params["show_standard_errors"]: try: - se = self.sem.loc[_index, col] + se = self.sem.loc[_index, col] # type:ignore formatted_se = copy.copy(formatted_val) # formatted_se = self._format_value(_index, col, se) formatted_se["value"] = ( @@ -1313,6 +1335,8 @@ def reset_custom_features(self): class ModelTable(Table): + all_param_labels: list[str] + param_labels: list[str] # stats that get included in the table footer # configuration is (name of the attribute, label, whether it has a p-value) model_stats = [ @@ -1505,7 +1529,7 @@ def covariate_order(self, order: list | None) -> None: """ self.parameter_order(order) - def parameter_order(self, order: list | None) -> None: + def parameter_order(self, order: list[str] | None) -> None: """ Set the order of the parameters in the table. An error will be raised if the parameter is not in any of the models. @@ -1640,6 +1664,7 @@ def _render(render_func: Callable): """ def wrapper(self, *args, **kwargs): + _stars_note = "" if ( self.table_params["show_significance_levels"] and self.table_params["show_stars"] @@ -1670,33 +1695,31 @@ def wrapper(self, *args, **kwargs): return wrapper @overload - def render_latex(self, outfile: None, only_tabular: bool) -> str: ... - + def render_latex(self, outfile: None = None, only_tabular: bool = False) -> str: ... @overload - def render_latex(self, outfile: Union[str, Path], only_tabular: bool) -> None: ... + def render_latex(self, outfile: str | Path, only_tabular: bool = False) -> None: ... @_render def render_latex( - self, outfile: Union[str, Path, None] = None, only_tabular=False - ) -> Union[str, None]: + self, outfile: str | Path | None = None, only_tabular: bool = False + ) -> str | None: return super().render_latex(outfile=outfile, only_tabular=only_tabular) @overload def render_html( self, - outfile: None, - table_class: str | None, - convert_latex: bool, + outfile: None = None, + table_class: str | None = None, + convert_latex: bool = True, *args, **kwargs, ) -> str: ... - @overload def render_html( self, outfile: str, - table_class: str | None, - convert_latex: bool, + table_class: str | None = None, + convert_latex: bool = True, *args, **kwargs, ) -> None: ... @@ -1709,13 +1732,13 @@ def render_html( convert_latex: bool = True, *args, **kwargs, - ) -> Union[str, None]: + ) -> str | None: return super().render_html( outfile=outfile, table_class=table_class, convert_latex=convert_latex ) @_render - def render_ascii(self, convert_latex=True) -> str: + def render_ascii(self, convert_latex: bool = True) -> str: return super().render_ascii(convert_latex=convert_latex) ##### Properties ##### @@ -1801,7 +1824,12 @@ def __init__( assert panel_label_alignment in self.VALID_ALIGNMENTS self.panel_label_alignment = panel_label_alignment - def render_latex(self, outfile, **kwargs) -> str | None: + @overload + def render_latex(self, outfile: None = None, **kwargs) -> str: ... + @overload + def render_latex(self, outfile: str | Path, **kwargs) -> None: ... + + def render_latex(self, outfile: str | Path | None = None, **kwargs) -> str | None: # assign multicolumns to each table match self.enumerate_type: case "alpha_upper": @@ -1823,7 +1851,7 @@ def render_latex(self, outfile, **kwargs) -> str | None: label_str = f"Panel {self.label_char}: {label}" table.panel_label = label_str table.panel_label_alignment = self.ALIGNMENTS[self.panel_label_alignment] - _tex_str = table.render_latex(only_tabular=True) + _tex_str = table.render_latex(outfile=None, only_tabular=True) assert isinstance(_tex_str, str) if i < self.npanels - 1: # add space between previous panel and label for next one @@ -1842,7 +1870,7 @@ def render_latex(self, outfile, **kwargs) -> str | None: Path(outfile).write_text(tex_str) return None - def render_ascii(self) -> str: + def render_ascii(self, convert_latex: bool = True) -> str: # assign multicolumns to each table match self.enumerate_type: case "alpha_upper": @@ -1873,14 +1901,14 @@ def render_ascii(self) -> str: _label = f"Panel {self.label_char}) {label}" label_align = self.ASCII_ALIGNMENTS[self.panel_label_alignment] label_str = f"{_label:{label_align}{max_width}}\n" - table_str = table.render_ascii() + table_str = table.render_ascii(convert_latex=convert_latex) out_str += label_str + table_str + "\n" self._increment_label_char() return out_str def _modify_latex(self, table: Table, label: str): - tex_str = table.render_latex(only_tabular=True) + tex_str = table.render_latex(outfile=None, only_tabular=True) out_str = tex_str.replace("\\begin{tabular}\n", "") new_start = "\\begin{tabular}\n" ncols = len(table.columns) + int(table.table_params["include_index"]) diff --git a/statstables/tests/test_tables.py b/statstables/tests/test_tables.py index 58203f6..21135cf 100644 --- a/statstables/tests/test_tables.py +++ b/statstables/tests/test_tables.py @@ -141,10 +141,12 @@ def test_model_table_linearmodels(): data = mroz.load() data = data.dropna() data = add_constant(data, has_constant="add") - iv = IV2SLS(np.log(data.wage), data[["const"]], data.educ, data.fatheduc).fit( - cov_type="unadjusted" + iv = IV2SLS( + np.log(data.wage), data[["const"]], data.educ, data.fatheduc # type:ignore + ).fit(cov_type="unadjusted") + ivtable = tables.ModelTable( + models=[iv.first_stage.individual["educ"], iv] # type:ignore ) - ivtable = tables.ModelTable(models=[iv.first_stage.individual["educ"], iv]) ivtable.rename_covariates( { "const": "Intercept", @@ -399,7 +401,7 @@ def pull_params(self): def compare_expected_output( expected_file: Path, - actual_table: tables.Table, + actual_table: tables.Table | tables.PanelTable, render_type: str, temp_file: Path, only_tabular: bool = False, From 27082175f43c87d4bb3a0464a5f5186eae114511 Mon Sep 17 00:00:00 2001 From: andersonfrailey Date: Sun, 22 Feb 2026 18:39:04 -0500 Subject: [PATCH 7/8] implement panel table --- panel.typ | 35 ++++++++++++++ samplenotebook.ipynb | 5 +- statstables/renderers.py | 12 +++++ statstables/tables.py | 83 ++++++++++++++++++++++++++++++++ statstables/tests/test_tables.py | 9 ++++ 5 files changed, 142 insertions(+), 2 deletions(-) create mode 100644 panel.typ diff --git a/panel.typ b/panel.typ new file mode 100644 index 0000000..19a09a8 --- /dev/null +++ b/panel.typ @@ -0,0 +1,35 @@ +#figure( + table( + columns: (100%,), + stroke: none, +table( + columns: (1fr,1fr,1fr), + align: (left, center), + column-gutter: 0.5em, + table.hline(stroke: 0.15em), + table.cell([Panel A: Men], colspan: 3, align: left), + table.hline(), + table.header( [], [ID], [School]), table.hline(stroke: 0.05em), [Matthew Ortiz],[1234],[Texas], + [Michael Costa],[6789],[UVA], + [Samuel Johnson],[1023],[UMBC], + [Dakota Snyder],[5810],[UGA], + [Scott Mills],[9182],[Rice], +table.hline() +) +,table( + columns: (1fr,1fr,1fr), + align: (left, center), + column-gutter: 0.5em, + table.hline(stroke: 0.15em), + table.cell([Panel B: Women], colspan: 3, align: left), + table.hline(), + table.header( [], [ID], [School]), table.hline(stroke: 0.05em), [Erin Anderson],[9183],[Wake Forrest], + [Michelle Zimmerman],[5734],[Emory], + [Danielle King],[1290],[Texas], + [Shannon Nelson],[4743],[UVA], + [Stephanie Booth],[8912],[Columbia], +table.hline() +) +, +) +) \ No newline at end of file diff --git a/samplenotebook.ipynb b/samplenotebook.ipynb index 0f83c2e..f1e9fd4 100644 --- a/samplenotebook.ipynb +++ b/samplenotebook.ipynb @@ -3220,11 +3220,12 @@ }, { "cell_type": "code", - "execution_count": 31, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ - "panel.render_latex(\"panel.tex\")" + "panel.render_latex(\"panel.tex\")\n", + "panel.render_typst(\"panel.typ\")" ] }, { diff --git a/statstables/renderers.py b/statstables/renderers.py index 41fd9fa..ac81c10 100644 --- a/statstables/renderers.py +++ b/statstables/renderers.py @@ -454,6 +454,11 @@ def generate_footer(self, convert_latex=True): for line in self.table.custom_lines["after-footer"]: footer += self._create_line(line, convert_latex=convert_latex) footer += " \n" + footer += " \n" + footer += ( + " \n" + ) + footer += " \n" if self.table.notes: ncols = self.table.ncolumns + self.table.table_params["include_index"] for note, alignment, _ in self.table.notes: @@ -955,6 +960,13 @@ def generate_header( header += ( f" columns: {self.ncolumns},\n{col_gutter} table.hline(stroke: 0.15em),\n" ) + if self.table.panel_label is not None: + n = len(self.table.columns) + self.table.table_params["include_index"] + header += ( + f" table.cell([{self.table.panel_label}]," + + f" colspan: {n}, align: {self.table.panel_label_alignment}),\n" + + " table.hline(),\n" + ) if self.table.table_params["show_columns"] or self.table._multicolumns: header += f" table.header(" # multicolumns diff --git a/statstables/tables.py b/statstables/tables.py index 5371458..0a6e2f1 100644 --- a/statstables/tables.py +++ b/statstables/tables.py @@ -1794,6 +1794,14 @@ class PanelTable: "center": "^", "right": ">", } + TYPST_ALIGNMENTS = { + "l": "left", + "c": "center", + "r": "right", + "left": "left", + "center": "center", + "right": "right", + } def __init__( self, @@ -1907,6 +1915,81 @@ def render_ascii(self, convert_latex: bool = True) -> str: return out_str + @overload + def render_typst( + self, + outfile: None = None, + figure_params: dict | None = None, + table_params: dict | None = None, + override_settings: dict | None = None, + **kwargs, + ) -> str: ... + @overload + def render_typst( + self, + outfile: str | Path, + figure_params: dict | None = None, + table_params: dict | None = None, + override_settings: dict | None = None, + **kwargs, + ) -> None: ... + + def render_typst( + self, + outfile: str | Path | None = None, + figure_params: dict | None = None, + table_params: dict | None = None, + override_settings: dict | None = None, + **kwargs, + ) -> str | None: + match self.enumerate_type: + case "alpha_upper": + self.label_char = "A" + case "alpha_lower": + self.label_char = "a" + case "int": + self.label_char = "1" + case "roman": + self.label_char = "i" + case _: + self.label_char = "" + table_str = "#figure(\n" + if figure_params: + for param, value in figure_params.items(): + table_str += f" {param}: {value},\n" + table_str += " table(\n columns: (100%,),\n stroke: none,\n" + if table_params: + for param, value in table_params.items(): + table_str += f" {param}: {value},\n" + for table, label in zip(self.panels, self.panel_labels): + label_str = f"Panel {self.label_char}: {label}" + table.panel_label = label_str + table.panel_label_alignment = self.TYPST_ALIGNMENTS[ + self.panel_label_alignment + ] + _typst_str = table.render_typst(outfile=None, in_figure=False) + assert isinstance(_typst_str, str) + # various changes needed to put the tables together + _typst_str = _typst_str.replace("#table", "table") + n = table.ncolumns + table.table_params["include_index"] + fr = ",".join(["1fr"] * n) + ialign = self.TYPST_ALIGNMENTS[table.table_params["index_alignment"]] + calign = self.TYPST_ALIGNMENTS[table.table_params["column_alignment"]] + align = f"({ialign}, {calign})" + if not table.table_params["include_index"]: + align = f"{calign}" + col_str = f"columns: ({fr}), \n align: {align}," + _typst_str = _typst_str.replace(f"columns: {n},", col_str) + table_str += _typst_str + table_str += "," + self._increment_label_char() + table_str += "\n)\n)" + + if not outfile: + return table_str + Path(outfile).write_text(table_str) + return None + def _modify_latex(self, table: Table, label: str): tex_str = table.render_latex(outfile=None, only_tabular=True) out_str = tex_str.replace("\\begin{tabular}\n", "") diff --git a/statstables/tests/test_tables.py b/statstables/tests/test_tables.py index 21135cf..619a34a 100644 --- a/statstables/tests/test_tables.py +++ b/statstables/tests/test_tables.py @@ -267,6 +267,12 @@ def test_panel_table(): render_type="tex", temp_file=Path("panel_table_actual.tex"), ) + compare_expected_output( + expected_file=Path(CUR_PATH, "..", "..", "panel.typ"), + actual_table=panel, + render_type="typ", + temp_file=Path("panel_table_actual.tex"), + ) def test_linear_models(): @@ -405,10 +411,13 @@ def compare_expected_output( render_type: str, temp_file: Path, only_tabular: bool = False, + in_figure: bool = False, ): match render_type: case "tex": actual_table.render_latex(temp_file, only_tabular=only_tabular) + case "typ": + actual_table.render_typst(temp_file, in_figure=in_figure) actual_text = temp_file.read_text() expected_text = expected_file.read_text() msg = f"Output has changed. New output in {str(temp_file)}" From 69297ce1ef1c8f731b5cc32d8e3f2d306f117e21 Mon Sep 17 00:00:00 2001 From: andersonfrailey Date: Mon, 23 Feb 2026 08:33:01 -0500 Subject: [PATCH 8/8] update sample notebook --- samplenotebook.ipynb | 37 +++++++++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/samplenotebook.ipynb b/samplenotebook.ipynb index f1e9fd4..efcefd9 100644 --- a/samplenotebook.ipynb +++ b/samplenotebook.ipynb @@ -69,7 +69,7 @@ "type": "integer" } ], - "ref": "dabae8ec-9941-4705-bacb-d4968ec32494", + "ref": "8f2a7b99-50f5-4e7b-b566-ca6d16cc70f2", "rows": [ [ "0", @@ -818,6 +818,9 @@ " (0.140)\n", "\n", " \n", + " \n", + " \n", + " \n", " * p< 0.1, ** p< 0.05, *** p< 0.01\n", " \n", "" @@ -1026,6 +1029,9 @@ " Low C\n", " \n", " \n", + " \n", + " \n", + " \n", " The default note aligns over here.\n", " But you can move it to the middle!\n", " Or over here!\n", @@ -1159,6 +1165,9 @@ " 5,000\n", "\n", " \n", + " \n", + " \n", + " \n", " \n", "" ], @@ -1476,6 +1485,9 @@ " Low C\n", " \n", " \n", + " \n", + " \n", + " \n", " The default note aligns over here.\n", " But you can move it to the middle!\n", " Or over here!\n", @@ -1724,6 +1736,9 @@ " \n", "\n", " \n", + " \n", + " \n", + " \n", " *p<0.1, **p<0.05, ***p<0.01\n", " \n", "" @@ -1885,6 +1900,9 @@ " 2.849*\n", "\n", " \n", + " \n", + " \n", + " \n", " *p<0.1, **p<0.05, ***p<0.01\n", " \n", "" @@ -2310,6 +2328,9 @@ " 27.959***\n", "\n", " \n", + " \n", + " \n", + " \n", " *p<0.1, **p<0.05, ***p<0.01\n", " \n", "" @@ -2598,6 +2619,9 @@ " 27.959***\n", "\n", " \n", + " \n", + " \n", + " \n", " *p<0.1, **p<0.05, ***p<0.01\n", " \n", "" @@ -3069,6 +3093,9 @@ " 27.959***\n", "\n", " \n", + " \n", + " \n", + " \n", " *p<0.1, **p<0.05, ***p<0.01\n", " \n", "" @@ -3220,7 +3247,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 31, "metadata": {}, "outputs": [], "source": [ @@ -3419,6 +3446,9 @@ " 27.959\n", "\n", " \n", + " \n", + " \n", + " \n", " \n", "" ], @@ -3596,6 +3626,9 @@ " \n", "\n", " \n", + " \n", + " \n", + " \n", " *p<0.1, **p<0.05, ***p<0.01\n", " \n", ""