From d2da15c35eec88854dec5b1f4058e2e9d673e6ea Mon Sep 17 00:00:00 2001 From: andersonfrailey Date: Wed, 10 Dec 2025 09:25:05 -0500 Subject: [PATCH 1/4] 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/4] 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/4] 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/4] 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, ),