Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
187 changes: 187 additions & 0 deletions statstables/renderers.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import math
import warnings
import statstables as st
import textwrap
from abc import ABC, abstractmethod
Expand Down Expand Up @@ -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
96 changes: 92 additions & 4 deletions statstables/tables.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -1139,13 +1216,23 @@ class ModelTable(Table):
model_stats = [
("observations", "Observations", False),
("ngroups", "N. Groups", False),
("r2", {"latex": "$R^2$", "html": "R<sup>2</sup>", "ascii": "R^2"}, False),
(
"r2",
{
"latex": "$R^2$",
"html": "R<sup>2</sup>",
"ascii": "R^2",
"typst": "$R^2$",
},
False,
),
(
"adjusted_r2",
{
"latex": "Adjusted $R^2$",
"html": "Adjusted R<sup>2</sup>",
"ascii": "Adjusted R^2",
"typst": "Adjusted $R^2$",
},
False,
),
Expand All @@ -1155,6 +1242,7 @@ class ModelTable(Table):
"latex": "Pseudo $R^2$",
"html": "Pseudo R<sup>2</sup>",
"ascii": "Pseudo R^2",
"typst": "Pseudo $R^2$",
},
False,
),
Expand Down