Repository: pandas-dev/pandas

Issue:
ENH: `(Styler|DataFrame).to_typst()`
### Feature Type

- [X] Adding new functionality to pandas

- [ ] Changing existing functionality in pandas

- [ ] Removing existing functionality in pandas


### Problem Description

typst is a new markup language for scientific documents https://github.com/typst/typst. i think of it as latex for the 21 century.

### Feature Description

i've used `(df|styler).to_latex` many times and would find an equivalent `(df|styler).to_typst` equally useful now that i've transitioned to Typst.

### Alternative Solutions

export to JSON/CSV and [use Typst native JSON/CSV import feature](https://typst.app/docs/reference/data-loading) and generate table in Typst rather than in pandas. drawback: looses styling and all customization options available with `Styler`

### Additional Context

_No response_

Discussion:
attack68: I think this is a nice, minimalist implementation of `Styler.to_typst`, honouring some of the ideas in the original discussion. The coding mirrors the implementation for other formats in terms of signature and template composition.

Provided the tests pass I am supportive of adding this in. Would need a "whatsnew"
3w36zj6: Hi @attack68,
I have updated the "What's new". Could you please review it?
attack68: @mroeschke want to have a second look?

Patch:
diff --git a/doc/source/reference/style.rst b/doc/source/reference/style.rst
index 0e1d93841d..742263c788 100644
--- a/doc/source/reference/style.rst
+++ b/doc/source/reference/style.rst
@@ -27,6 +27,7 @@ Styler properties
    Styler.template_html_style
    Styler.template_html_table
    Styler.template_latex
+   Styler.template_typst
    Styler.template_string
    Styler.loader
 
@@ -77,6 +78,7 @@ Style export and import
 
    Styler.to_html
    Styler.to_latex
+   Styler.to_typst
    Styler.to_excel
    Styler.to_string
    Styler.export
diff --git a/doc/source/whatsnew/v3.0.0.rst b/doc/source/whatsnew/v3.0.0.rst
index a7f63d75a0..64f4a66a10 100644
--- a/doc/source/whatsnew/v3.0.0.rst
+++ b/doc/source/whatsnew/v3.0.0.rst
@@ -31,6 +31,7 @@ Other enhancements
 - :class:`pandas.api.typing.FrozenList` is available for typing the outputs of :attr:`MultiIndex.names`, :attr:`MultiIndex.codes` and :attr:`MultiIndex.levels` (:issue:`58237`)
 - :class:`pandas.api.typing.SASReader` is available for typing the output of :func:`read_sas` (:issue:`55689`)
 - :meth:`pandas.api.interchange.from_dataframe` now uses the `PyCapsule Interface <https://arrow.apache.org/docs/format/CDataInterface/PyCapsuleInterface.html>`_ if available, only falling back to the Dataframe Interchange Protocol if that fails (:issue:`60739`)
+- Added :meth:`.Styler.to_typst` to write Styler objects to file, buffer or string in Typst format (:issue:`57617`)
 - :class:`pandas.api.typing.NoDefault` is available for typing ``no_default``
 - :func:`DataFrame.to_excel` now raises an ``UserWarning`` when the character count in a cell exceeds Excel's limitation of 32767 characters (:issue:`56954`)
 - :func:`pandas.merge` now validates the ``how`` parameter input (merge type) (:issue:`59435`)
diff --git a/pandas/io/formats/style.py b/pandas/io/formats/style.py
index 6f164c4b97..3f37556867 100644
--- a/pandas/io/formats/style.py
+++ b/pandas/io/formats/style.py
@@ -1228,6 +1228,111 @@ class Styler(StylerRenderer):
         )
         return save_to_buffer(latex, buf=buf, encoding=encoding)
 
+    @overload
+    def to_typst(
+        self,
+        buf: FilePath | WriteBuffer[str],
+        *,
+        encoding: str | None = ...,
+        sparse_index: bool | None = ...,
+        sparse_columns: bool | None = ...,
+        max_rows: int | None = ...,
+        max_columns: int | None = ...,
+    ) -> None: ...
+
+    @overload
+    def to_typst(
+        self,
+        buf: None = ...,
+        *,
+        encoding: str | None = ...,
+        sparse_index: bool | None = ...,
+        sparse_columns: bool | None = ...,
+        max_rows: int | None = ...,
+        max_columns: int | None = ...,
+    ) -> str: ...
+
+    @Substitution(buf=buffering_args, encoding=encoding_args)
+    def to_typst(
+        self,
+        buf: FilePath | WriteBuffer[str] | None = None,
+        *,
+        encoding: str | None = None,
+        sparse_index: bool | None = None,
+        sparse_columns: bool | None = None,
+        max_rows: int | None = None,
+        max_columns: int | None = None,
+    ) -> str | None:
+        """
+        Write Styler to a file, buffer or string in Typst format.
+
+        .. versionadded:: 3.0.0
+
+        Parameters
+        ----------
+        %(buf)s
+        %(encoding)s
+        sparse_index : bool, optional
+            Whether to sparsify the display of a hierarchical index. Setting to False
+            will display each explicit level element in a hierarchical key for each row.
+            Defaults to ``pandas.options.styler.sparse.index`` value.
+        sparse_columns : bool, optional
+            Whether to sparsify the display of a hierarchical index. Setting to False
+            will display each explicit level element in a hierarchical key for each
+            column. Defaults to ``pandas.options.styler.sparse.columns`` value.
+        max_rows : int, optional
+            The maximum number of rows that will be rendered. Defaults to
+            ``pandas.options.styler.render.max_rows``, which is None.
+        max_columns : int, optional
+            The maximum number of columns that will be rendered. Defaults to
+            ``pandas.options.styler.render.max_columns``, which is None.
+
+            Rows and columns may be reduced if the number of total elements is
+            large. This value is set to ``pandas.options.styler.render.max_elements``,
+            which is 262144 (18 bit browser rendering).
+
+        Returns
+        -------
+        str or None
+            If `buf` is None, returns the result as a string. Otherwise returns `None`.
+
+        See Also
+        --------
+        DataFrame.to_typst : Write a DataFrame to a file,
+            buffer or string in Typst format.
+
+        Examples
+        --------
+        >>> df = pd.DataFrame({"A": [1, 2], "B": [3, 4]})
+        >>> df.style.to_typst()  # doctest: +SKIP
+
+        .. code-block:: typst
+
+            #table(
+              columns: 3,
+              [], [A], [B],
+
+              [0], [1], [3],
+              [1], [2], [4],
+            )
+        """
+        obj = self._copy(deepcopy=True)
+
+        if sparse_index is None:
+            sparse_index = get_option("styler.sparse.index")
+        if sparse_columns is None:
+            sparse_columns = get_option("styler.sparse.columns")
+
+        text = obj._render_typst(
+            sparse_columns=sparse_columns,
+            sparse_index=sparse_index,
+            max_rows=max_rows,
+            max_cols=max_columns,
+        )
+        return save_to_buffer(
+            text, buf=buf, encoding=(encoding if buf is not None else None)
+        )
+
     @overload
     def to_html(
         self,
diff --git a/pandas/io/formats/style_render.py b/pandas/io/formats/style_render.py
index c0f0608f1a..2d1218b007 100644
--- a/pandas/io/formats/style_render.py
+++ b/pandas/io/formats/style_render.py
@@ -77,6 +77,7 @@ class StylerRenderer:
     template_html_table = env.get_template("html_table.tpl")
     template_html_style = env.get_template("html_style.tpl")
     template_latex = env.get_template("latex.tpl")
+    template_typst = env.get_template("typst.tpl")
     template_string = env.get_template("string.tpl")
 
     def __init__(
@@ -232,6 +233,21 @@ class StylerRenderer:
         d.update(kwargs)
         return self.template_latex.render(**d)
 
+    def _render_typst(
+        self,
+        sparse_index: bool,
+        sparse_columns: bool,
+        max_rows: int | None = None,
+        max_cols: int | None = None,
+        **kwargs,
+    ) -> str:
+        """
+        Render a Styler in typst format
+        """
+        d = self._render(sparse_index, sparse_columns, max_rows, max_cols)
+        d.update(kwargs)
+        return self.template_typst.render(**d)
+
     def _render_string(
         self,
         sparse_index: bool,
diff --git a/pandas/io/formats/templates/typst.tpl b/pandas/io/formats/templates/typst.tpl
new file mode 100644
index 0000000000..66de8f31b4
--- /dev/null
+++ b/pandas/io/formats/templates/typst.tpl
@@ -0,0 +1,12 @@
+#table(
+  columns: {{ head[0] | length }},
+{% for r in head %}
+  {% for c in r %}[{% if c["is_visible"] %}{{ c["display_value"] }}{% endif %}],{% if not loop.last %} {% endif%}{% endfor %}
+
+{% endfor %}
+
+{% for r in body %}
+  {% for c in r %}[{% if c["is_visible"] %}{{ c["display_value"] }}{% endif %}],{% if not loop.last %} {% endif%}{% endfor %}
+
+{% endfor %}
+)
diff --git a/scripts/validate_docstrings.py b/scripts/validate_docstrings.py
index 55acfaac4d..944575dcc8 100755
--- a/scripts/validate_docstrings.py
+++ b/scripts/validate_docstrings.py
@@ -45,6 +45,7 @@ IGNORE_VALIDATION = {
     "Styler.template_html_style",
     "Styler.template_html_table",
     "Styler.template_latex",
+    "Styler.template_typst",
     "Styler.template_string",
     "Styler.loader",
     "errors.InvalidComparison",


Test patch:
diff --git a/pandas/tests/io/formats/style/test_to_typst.py b/pandas/tests/io/formats/style/test_to_typst.py
new file mode 100644
index 0000000000..2365119c9c
--- /dev/null
+++ b/pandas/tests/io/formats/style/test_to_typst.py
@@ -0,0 +1,96 @@
+from textwrap import dedent
+
+import pytest
+
+from pandas import (
+    DataFrame,
+    Series,
+)
+
+pytest.importorskip("jinja2")
+from pandas.io.formats.style import Styler
+
+
+@pytest.fixture
+def df():
+    return DataFrame(
+        {"A": [0, 1], "B": [-0.61, -1.22], "C": Series(["ab", "cd"], dtype=object)}
+    )
+
+
+@pytest.fixture
+def styler(df):
+    return Styler(df, uuid_len=0, precision=2)
+
+
+def test_basic_table(styler):
+    result = styler.to_typst()
+    expected = dedent(
+        """\
+    #table(
+      columns: 4,
+      [], [A], [B], [C],
+
+      [0], [0], [-0.61], [ab],
+      [1], [1], [-1.22], [cd],
+    )"""
+    )
+    assert result == expected
+
+
+def test_concat(styler):
+    result = styler.concat(styler.data.agg(["sum"]).style).to_typst()
+    expected = dedent(
+        """\
+    #table(
+      columns: 4,
+      [], [A], [B], [C],
+
+      [0], [0], [-0.61], [ab],
+      [1], [1], [-1.22], [cd],
+      [sum], [1], [-1.830000], [abcd],
+    )"""
+    )
+    assert result == expected
+
+
+def test_concat_recursion(styler):
+    df = styler.data
+    styler1 = styler
+    styler2 = Styler(df.agg(["sum"]), uuid_len=0, precision=3)
+    styler3 = Styler(df.agg(["sum"]), uuid_len=0, precision=4)
+    result = styler1.concat(styler2.concat(styler3)).to_typst()
+    expected = dedent(
+        """\
+    #table(
+      columns: 4,
+      [], [A], [B], [C],
+
+      [0], [0], [-0.61], [ab],
+      [1], [1], [-1.22], [cd],
+      [sum], [1], [-1.830], [abcd],
+      [sum], [1], [-1.8300], [abcd],
+    )"""
+    )
+    assert result == expected
+
+
+def test_concat_chain(styler):
+    df = styler.data
+    styler1 = styler
+    styler2 = Styler(df.agg(["sum"]), uuid_len=0, precision=3)
+    styler3 = Styler(df.agg(["sum"]), uuid_len=0, precision=4)
+    result = styler1.concat(styler2).concat(styler3).to_typst()
+    expected = dedent(
+        """\
+    #table(
+      columns: 4,
+      [], [A], [B], [C],
+
+      [0], [0], [-0.61], [ab],
+      [1], [1], [-1.22], [cd],
+      [sum], [1], [-1.830], [abcd],
+      [sum], [1], [-1.8300], [abcd],
+    )"""
+    )
+    assert result == expected