diff --git a/statstables/renderers.py b/statstables/renderers.py index 317c130..7a7cca2 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 @@ -864,3 +865,189 @@ def padding(self, value): if value > 20: raise ValueError("Woah there buddy. That's a lot of space.") self._padding = 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.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, + figure_params: dict | None = None, + table_params: dict | None = None, + override_settings: dict | None = None, + ): + 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, has_override_settings=has_override_settings + ) + return header + body + footer + + def generate_header( + self, + in_figure: bool = True, + figure_params: dict | None = None, + table_params: dict | None = None, + 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" + # 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"] + 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): + rows = self.table._create_rows() + body = "" + for row in rows: + body += " " + 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( + self, in_figure: bool = True, has_override_settings: bool = False + ): + 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})" + label = "" + if self.table.label: + label = f"<{self.table.label}>" + footer += ( + "table.hline()\n)\n" + + (f"){label}\n" * in_figure) + + ("}" * has_override_settings) + ) + return footer + + 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..a4cf42d 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 @@ -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, @@ -592,11 +593,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 +647,67 @@ 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, + 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, + figure_params=figure_params, + table_params=table_params, + override_settings=override_settings, + ) + if not outfile: + return typst_str + Path(outfile).write_text(typst_str) + return None + def __str__(self) -> str: return self.render_ascii() @@ -1139,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, ), @@ -1155,6 +1242,7 @@ class ModelTable(Table): "latex": "Pseudo $R^2$", "html": "Pseudo R2", "ascii": "Pseudo R^2", + "typst": "Pseudo $R^2$", }, False, ),