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,
),