diff --git a/.gitignore b/.gitignore
index 0433e5135..33b706045 100644
--- a/.gitignore
+++ b/.gitignore
@@ -121,3 +121,4 @@ latex_examples.pdf
# Do not track lockfile in package/lib setting
uv.lock
+scratch/
diff --git a/great_tables/__init__.py b/great_tables/__init__.py
index 7795f85bc..99850e5c1 100644
--- a/great_tables/__init__.py
+++ b/great_tables/__init__.py
@@ -17,6 +17,7 @@
pct,
md,
html,
+ typst,
google_font,
random_id,
system_fonts,
@@ -34,6 +35,7 @@
"pct",
"md",
"html",
+ "typst",
"google_font",
"system_fonts",
"define_units",
diff --git a/great_tables/_export.py b/great_tables/_export.py
index 8c8f4476b..682fe4270 100644
--- a/great_tables/_export.py
+++ b/great_tables/_export.py
@@ -15,6 +15,7 @@
from ._scss import compile_scss
from ._utils import _try_import
from ._utils_render_latex import _render_as_latex
+from ._utils_render_typst import _render_as_typst
if TYPE_CHECKING:
# Note that as_raw_html uses methods on the GT class, not just data
@@ -349,6 +350,66 @@ def as_latex(self: GT, use_longtable: bool = False, tbl_pos: str | None = None)
return latex_table
+def as_typst(self: GT) -> str:
+ """
+ Output a GT object as Typst.
+
+ The `as_typst()` method outputs a GT object as a Typst fragment. This method is useful for when
+ you need to include a table as part of a Typst document or in Quarto documents that render to
+ Typst/PDF output.
+
+ :::{.callout-warning}
+ `as_typst()` is still experimental.
+ :::
+
+ Returns
+ -------
+ str
+ A Typst fragment that contains the table.
+
+ Examples
+ --------
+ Let's use a subset of the `gtcars` dataset to create a new table.
+
+ ```{python}
+ from great_tables import GT
+ from great_tables.data import gtcars
+ import polars as pl
+
+ gtcars_mini = (
+ pl.from_pandas(gtcars)
+ .select(["mfr", "model", "msrp"])
+ .head(5)
+ )
+
+ gt_tbl = (
+ GT(gtcars_mini)
+ .tab_header(
+ title="Data Listing from the gtcars Dataset",
+ subtitle="Only five rows from the dataset are shown here."
+ )
+ .fmt_currency(columns="msrp")
+ )
+
+ gt_tbl
+ ```
+
+ Now we can return the table as a string of Typst code using the `as_typst()` method.
+
+ ```{python}
+ gt_tbl.as_typst()
+ ```
+
+ The Typst string contains the code just for the table (it's not a complete Typst document).
+ This output can be useful for embedding a GT table in an existing Typst document.
+ """
+ built_table = self._build_data(context="typst")
+
+ typst_table = _render_as_typst(data=built_table)
+
+ return typst_table
+
+
# Create a list of all selenium webdrivers
WebDrivers: TypeAlias = Literal[
"chrome",
@@ -436,6 +497,19 @@ def save(
```
"""
+ # Handle text-based output formats that don't need a browser
+ file_path = Path(file)
+
+ if file_path.suffix == ".typ":
+ typst_content = as_typst(self)
+ file_path.write_text(typst_content, encoding=encoding)
+ return self
+
+ if file_path.suffix == ".tex":
+ latex_content = as_latex(self)
+ file_path.write_text(latex_content, encoding=encoding)
+ return self
+
# Import the required packages
_try_import(name="selenium", pip_install_line="pip install selenium")
diff --git a/great_tables/_formats.py b/great_tables/_formats.py
index de66f78c1..9849c13c4 100644
--- a/great_tables/_formats.py
+++ b/great_tables/_formats.py
@@ -44,7 +44,7 @@
is_series,
to_list,
)
-from ._text import _md_html, escape_pattern_str_latex
+from ._text import _md_html, escape_pattern_str_latex, escape_pattern_str_typst
from ._utils import _str_detect, _str_replace, is_valid_http_schema
from ._utils_nanoplots import _generate_nanoplot
@@ -394,6 +394,8 @@ def fmt_number_context(
# Escape LaTeX special characters from literals in the pattern
if context == "latex":
pattern = escape_pattern_str_latex(pattern_str=pattern)
+ elif context == "typst":
+ pattern = escape_pattern_str_typst(pattern_str=pattern)
x_formatted = pattern.replace("{x}", x_formatted)
@@ -591,6 +593,8 @@ def fmt_integer_context(
# Escape LaTeX special characters from literals in the pattern
if context == "latex":
pattern = escape_pattern_str_latex(pattern_str=pattern)
+ elif context == "typst":
+ pattern = escape_pattern_str_typst(pattern_str=pattern)
x_formatted = pattern.replace("{x}", x_formatted)
@@ -860,6 +864,8 @@ def fmt_scientific_context(
# Escape LaTeX special characters from literals in the pattern
if context == "latex":
pattern = escape_pattern_str_latex(pattern_str=pattern)
+ elif context == "typst":
+ pattern = escape_pattern_str_typst(pattern_str=pattern)
x_formatted = pattern.replace("{x}", x_formatted)
@@ -1207,6 +1213,8 @@ def fmt_engineering_context(
# Escape LaTeX special characters from literals in the pattern
if context == "latex":
pattern = escape_pattern_str_latex(pattern_str=pattern)
+ elif context == "typst":
+ pattern = escape_pattern_str_typst(pattern_str=pattern)
x_formatted = pattern.replace("{x}", x_formatted)
@@ -1460,6 +1468,8 @@ def fmt_percent_context(
# Escape LaTeX special characters from literals in the pattern
if context == "latex":
pattern = escape_pattern_str_latex(pattern_str=pattern)
+ elif context == "typst":
+ pattern = escape_pattern_str_typst(pattern_str=pattern)
x_formatted = pattern.replace("{x}", x_formatted)
@@ -1746,6 +1756,8 @@ def fmt_currency_context(
# Escape LaTeX special characters from literals in the pattern
if context == "latex":
pattern = escape_pattern_str_latex(pattern_str=pattern)
+ elif context == "typst":
+ pattern = escape_pattern_str_typst(pattern_str=pattern)
x_formatted = pattern.replace("{x}", x_formatted)
@@ -1869,6 +1881,8 @@ def fmt_roman_context(
# Escape LaTeX special characters from literals in the pattern
if context == "latex":
pattern = escape_pattern_str_latex(pattern_str=pattern)
+ elif context == "typst":
+ pattern = escape_pattern_str_typst(pattern_str=pattern)
x_formatted = pattern.replace("{x}", x_formatted)
@@ -2126,6 +2140,8 @@ def fmt_bytes_context(
# Escape LaTeX special characters from literals in the pattern
if context == "latex":
pattern = escape_pattern_str_latex(pattern_str=pattern)
+ elif context == "typst":
+ pattern = escape_pattern_str_typst(pattern_str=pattern)
x_formatted = pattern.replace("{x}", x_formatted)
@@ -2281,6 +2297,8 @@ def fmt_date_context(
# Escape LaTeX special characters from literals in the pattern
if context == "latex":
pattern = escape_pattern_str_latex(pattern_str=pattern)
+ elif context == "typst":
+ pattern = escape_pattern_str_typst(pattern_str=pattern)
x_formatted = pattern.replace("{x}", x_formatted)
@@ -2425,6 +2443,8 @@ def fmt_time_context(
# Escape LaTeX special characters from literals in the pattern
if context == "latex":
pattern = escape_pattern_str_latex(pattern_str=pattern)
+ elif context == "typst":
+ pattern = escape_pattern_str_typst(pattern_str=pattern)
x_formatted = pattern.replace("{x}", x_formatted)
@@ -2618,6 +2638,8 @@ def fmt_datetime_context(
# Escape LaTeX special characters from literals in the pattern
if context == "latex":
pattern = escape_pattern_str_latex(pattern_str=pattern)
+ elif context == "typst":
+ pattern = escape_pattern_str_typst(pattern_str=pattern)
x_formatted = pattern.replace("{x}", x_formatted)
@@ -2826,6 +2848,8 @@ def fmt_tf_context(
# Escape LaTeX special characters from literals in the pattern
if context == "latex":
pattern = escape_pattern_str_latex(pattern_str=pattern)
+ elif context == "typst":
+ pattern = escape_pattern_str_typst(pattern_str=pattern)
x_out = pattern.replace("{x}", x_styled)
else:
@@ -3025,11 +3049,70 @@ def fmt_markdown_context(
x_str: str = str(x)
- x_formatted = _md_html(x_str)
+ if context == "typst":
+ from ._text import _md_typst
+
+ x_formatted = _md_typst(x_str)
+ else:
+ x_formatted = _md_html(x_str)
return x_formatted
+def fmt_typst(
+ self: GTSelf,
+ columns: SelectExpr = None,
+ rows: int | list[int] | None = None,
+) -> GTSelf:
+ """
+ Format cells as raw Typst markup.
+
+ The `fmt_typst()` method treats cell values as raw Typst markup, passing them through
+ without escaping in Typst output. This allows you to use Typst commands like
+ `#text(fill: red)[...]`, math mode `$ x^2 $`, or any other Typst syntax directly in cells.
+
+ Note that this formatter only works with Typst output (`as_typst()` or Quarto with
+ `format: typst`). It will raise `NotImplementedError` when rendering to HTML or LaTeX.
+
+ Parameters
+ ----------
+ columns
+ The columns to target for formatting.
+ rows
+ The rows to target for formatting.
+
+ Returns
+ -------
+ GT
+ The GT object is returned.
+ """
+
+ pf_format = partial(
+ fmt_typst_context,
+ data=self,
+ )
+
+ return fmt_by_context(self, pf_format=pf_format, columns=columns, rows=rows)
+
+
+def fmt_typst_context(
+ x: Any,
+ data: GTData,
+ context: str,
+) -> str:
+ if context == "html":
+ raise NotImplementedError("fmt_typst() is not supported in HTML output.")
+
+ if context == "latex":
+ raise NotImplementedError("fmt_typst() is not supported in LaTeX output.")
+
+ if is_na(data._tbl_data, x):
+ return x
+
+ # In typst context, pass through raw — no escaping
+ return str(x)
+
+
def fmt_units(
self: GTSelf,
columns: SelectExpr = None,
@@ -3592,6 +3675,8 @@ def _context_exp_marks(context: str) -> list[str]:
marks = [" \u00d7 10", ""]
elif context == "latex":
marks = [" $\\times$ 10\\textsuperscript{", "}"]
+ elif context == "typst":
+ marks = [" \u00d7 10#super[", "]"]
else:
marks = [" \u00d7 10^", ""]
@@ -3634,7 +3719,7 @@ def _context_percent_mark(context: str) -> str:
def _context_dollar_mark(context: str) -> str:
- if context == "latex":
+ if context in ("latex", "typst"):
mark = "\\$"
else:
mark = "$"
@@ -5542,6 +5627,7 @@ def fmt_by_context(
fns=FormatFns(
html=partial(pf_format, context="html"), # type: ignore
latex=partial(pf_format, context="latex"), # type: ignore
+ typst=partial(pf_format, context="typst"), # type: ignore
default=partial(pf_format, context="html"), # type: ignore
),
columns=columns,
diff --git a/great_tables/_gt_data.py b/great_tables/_gt_data.py
index 291da5d02..99baf2f9b 100644
--- a/great_tables/_gt_data.py
+++ b/great_tables/_gt_data.py
@@ -963,11 +963,12 @@ class FormatterSkipElement:
class FormatFns:
html: FormatFn | None
latex: FormatFn | None
+ typst: FormatFn | None
rtf: FormatFn | None
default: FormatFn | None
def __init__(self, **kwargs: FormatFn):
- for format in ("html", "latex", "rtf", "default"):
+ for format in ("html", "latex", "typst", "rtf", "default"):
if fmt := kwargs.get(format):
setattr(self, format, fmt)
diff --git a/great_tables/_helpers.py b/great_tables/_helpers.py
index d9a7a8759..2147b88b1 100644
--- a/great_tables/_helpers.py
+++ b/great_tables/_helpers.py
@@ -8,7 +8,7 @@
from typing_extensions import Self, TypeAlias
-from ._text import BaseText, Html, Md, _md_html
+from ._text import BaseText, Html, Md, Typst, _md_html
FontStackName: TypeAlias = Literal[
"system-ui",
@@ -243,6 +243,30 @@ def html(text: str) -> Html:
return Html(text=text)
+def typst(text: str) -> Typst:
+ """Interpret input text as Typst-formatted text.
+
+ For certain pieces of text (like in column labels, table headings, or cell values) you may want
+ to use raw Typst markup. The `typst()` function will pass the input through without escaping
+ when rendering to Typst output, allowing you to use Typst commands like `#text(fill: red)[...]`
+ or math mode `$ x^2 $`.
+
+ Note that `typst()` content will not render correctly in HTML or LaTeX output — it is
+ intended for use with Typst-targeted rendering (e.g., `as_typst()` or Quarto with
+ `format: typst`).
+
+ Parameters
+ ----------
+ text
+ The text that is understood to contain Typst formatting.
+
+ Examples
+ ------
+ See [`GT.tab_header()`](`great_tables.GT.tab_header`).
+ """
+ return Typst(text=text)
+
+
def random_id(n: int = 10) -> str:
"""Helper for creating a random `id` for an output table
diff --git a/great_tables/_styles.py b/great_tables/_styles.py
index 1f7018145..4daa89b21 100644
--- a/great_tables/_styles.py
+++ b/great_tables/_styles.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+import re
from dataclasses import dataclass, fields, replace
from typing import TYPE_CHECKING, Any, Callable, Literal, Union
@@ -12,6 +13,22 @@
from ._locations import Loc
+def _css_color_to_typst(color: str) -> str:
+ """Convert a CSS color string to Typst color syntax."""
+ color = color.strip()
+ if color.startswith("#"):
+ return f'rgb("{color}")'
+ if color.startswith("rgb"):
+ # Parse CSS rgb(r, g, b) into Typst rgb(r, g, b) with numeric arguments
+ match = re.match(r"rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)", color)
+ if match:
+ r, g, b = match.groups()
+ return f"rgb({r}, {g}, {b})"
+ return color
+ # Named colors — pass through, Typst supports many CSS names
+ return color
+
+
# Cell Styles ==========================================================================
# TODO: stubbed out the styles in helpers.R as dataclasses while I was reading it,
# but have no worked on any runtime validation, etc..
@@ -92,6 +109,13 @@ class CellStyle:
def _to_html_style(self) -> str:
raise NotImplementedError
+ def _to_typst_style(self) -> dict[str, str]:
+ """Return a dict of Typst style properties for this cell style.
+
+ Keys can be 'fill', 'stroke', or 'text_style' (a Typst #text() call to wrap content).
+ """
+ raise NotImplementedError
+
def _evaluate_expressions(self, data: TblData) -> Self:
new_fields: dict[str, FromValues] = {}
for field in fields(self):
@@ -169,6 +193,9 @@ class CellStyleCss(CellStyle):
def _to_html_style(self):
return self.rule
+ def _to_typst_style(self) -> dict[str, str]:
+ return {}
+
@dataclass
class CellStyleText(CellStyle):
@@ -302,6 +329,46 @@ def _to_html_style(self) -> str:
return rendered
+ def _to_typst_style(self) -> dict[str, str]:
+ parts: list[str] = []
+ if self.color:
+ parts.append(f"fill: {_css_color_to_typst(str(self.color))}")
+ if self.size:
+ parts.append(f"size: {self.size}")
+ if self.weight:
+ # Typst uses "regular" instead of CSS "normal"
+ w = str(self.weight)
+ if w == "normal":
+ w = "regular"
+ parts.append(f'weight: "{w}"')
+ if self.style:
+ parts.append(f'style: "{self.style}"')
+ result: dict[str, str] = {}
+ if parts:
+ result["text_style"] = ", ".join(parts)
+ # Text decorations → Typst outer wraps
+ if self.decorate:
+ d = str(self.decorate)
+ decorate_map = {
+ "underline": "underline",
+ "line-through": "strike",
+ "overline": "overline",
+ }
+ wraps = [decorate_map[x] for x in d.split() if x in decorate_map]
+ if wraps:
+ result["text_decorate"] = ",".join(wraps)
+ # Text transform → Typst outer wraps
+ if self.transform:
+ t = str(self.transform)
+ transform_map = {
+ "uppercase": "upper",
+ "lowercase": "lower",
+ "capitalize": "smallcaps", # closest Typst equivalent
+ }
+ if t in transform_map:
+ result["text_transform"] = transform_map[t]
+ return result
+
@dataclass
class CellStyleFill(CellStyle):
@@ -334,6 +401,9 @@ class CellStyleFill(CellStyle):
def _to_html_style(self) -> str:
return f"background-color: {self.color};"
+ def _to_typst_style(self) -> dict[str, str]:
+ return {"fill": _css_color_to_typst(str(self.color))}
+
@dataclass
class CellStyleBorders(CellStyle):
@@ -410,3 +480,30 @@ def _to_html_style(self) -> str:
border_css = "".join(border_css_list)
return border_css
+
+ def _to_typst_style(self) -> dict[str, str]:
+ if isinstance(self.sides, list) and not self.sides:
+ return {}
+
+ sides = self.sides
+ if isinstance(sides, str):
+ sides = [sides]
+
+ if "all" in sides:
+ sides = ["top", "bottom", "left", "right"]
+
+ color = _css_color_to_typst(str(self.color))
+ weight = str(self.weight)
+ # Convert CSS units to Typst (e.g., "2px" -> "1.5pt")
+ if weight.endswith("px"):
+ val = float(weight[:-2])
+ weight = f"{val * 0.75:.1f}pt"
+
+ # Typst stroke syntax: e.g., "1pt + black"
+ stroke_parts: list[str] = []
+ for side in sides:
+ stroke_parts.append(f"{side}: {weight} + {color}")
+
+ if stroke_parts:
+ return {"stroke": "(" + ", ".join(stroke_parts) + ")"}
+ return {}
diff --git a/great_tables/_substitution.py b/great_tables/_substitution.py
index e6f1a817b..4d95af039 100644
--- a/great_tables/_substitution.py
+++ b/great_tables/_substitution.py
@@ -4,16 +4,16 @@
from typing import TYPE_CHECKING, Any, Literal
from ._formats import fmt
-from ._gt_data import FormatterSkipElement
+from ._gt_data import FormatFns, FormatterSkipElement
from ._helpers import html
from ._tbl_data import DataFrameLike, SelectExpr, is_na
-from ._text import Text, _process_text
+from ._text import Html, Text, _process_text
if TYPE_CHECKING:
from ._types import GTSelf
-def _convert_missing(context: Literal["html"], el: str):
+def _convert_missing(context: Literal["html", "latex", "typst"], el: str):
"""Convert el to a context specific representation."""
# TODO: how is context passed? Could use a literal string (e.g. "html") for now?
@@ -25,6 +25,9 @@ def _convert_missing(context: Literal["html"], el: str):
if context == "html" and el == "":
return "
"
+ # In Typst, empty cells are fine — no special handling needed
+ # In LaTeX, empty cells are also fine
+
return el
@@ -91,7 +94,18 @@ def sub_missing(
"""
subber = SubMissing(self._tbl_data, missing_text)
- return fmt(self, fns=subber.to_html, columns=columns, rows=rows, is_substitution=True)
+ return fmt(
+ self,
+ fns=FormatFns(
+ html=subber.to_html,
+ latex=subber.to_html,
+ typst=subber.to_typst,
+ default=subber.to_html,
+ ),
+ columns=columns,
+ rows=rows,
+ is_substitution=True,
+ )
def sub_zero(
@@ -151,7 +165,18 @@ def sub_zero(
"""
subber = SubZero(zero_text)
- return fmt(self, fns=subber.to_html, columns=columns, rows=rows, is_substitution=True)
+ return fmt(
+ self,
+ fns=FormatFns(
+ html=subber.to_html,
+ latex=subber.to_html,
+ typst=subber.to_typst,
+ default=subber.to_html,
+ ),
+ columns=columns,
+ rows=rows,
+ is_substitution=True,
+ )
@dataclass
@@ -170,6 +195,16 @@ def to_html(self, x: Any) -> str | FormatterSkipElement:
return FormatterSkipElement()
+ def to_typst(self, x: Any) -> str | FormatterSkipElement:
+ if is_na(self.dispatch_frame, x):
+ # The default missing_text is html("—") which doesn't convert
+ # well to Typst. Use the Unicode em dash directly.
+ if self.missing_text is not None and isinstance(self.missing_text, Html):
+ return "\u2014" # em dash
+ return _process_text(self.missing_text, context="typst")
+
+ return FormatterSkipElement()
+
@dataclass
class SubZero:
@@ -180,3 +215,9 @@ def to_html(self, x: Any) -> str | FormatterSkipElement:
return _process_text(self.zero_text)
return FormatterSkipElement()
+
+ def to_typst(self, x: Any) -> str | FormatterSkipElement:
+ if x == 0:
+ return _process_text(self.zero_text, context="typst")
+
+ return FormatterSkipElement()
diff --git a/great_tables/_text.py b/great_tables/_text.py
index cd895ec70..f8820eb54 100644
--- a/great_tables/_text.py
+++ b/great_tables/_text.py
@@ -17,6 +17,9 @@ def to_html(self) -> str:
def to_latex(self) -> str:
raise NotImplementedError("Method not implemented")
+ def to_typst(self) -> str:
+ raise NotImplementedError("Method not implemented")
+
@dataclass
class Text(BaseText):
@@ -30,6 +33,9 @@ def to_html(self) -> str:
def to_latex(self) -> str:
return self.text
+ def to_typst(self) -> str:
+ return self.text
+
class Md(Text):
"""Markdown text"""
@@ -40,6 +46,39 @@ def to_html(self) -> str:
def to_latex(self) -> str:
return _md_latex(self.text)
+ def to_typst(self) -> str:
+ return _md_typst(self.text)
+
+
+class Typst(Text):
+ """Typst-formatted text. Content passes through as-is in Typst context."""
+
+ def to_html(self) -> str:
+ import warnings
+
+ warnings.warn(
+ "Using the `typst()` helper function won't convert Typst to HTML. "
+ "Escaping Typst string instead.",
+ stacklevel=2,
+ )
+
+ return _html_escape(self.text)
+
+ def to_latex(self) -> str:
+ import warnings
+
+ warnings.warn(
+ "Using the `typst()` helper function won't convert Typst to LaTeX. "
+ "Escaping Typst string instead.",
+ stacklevel=2,
+ )
+
+ return _latex_escape(self.text)
+
+ def to_typst(self) -> str:
+ # Pass through as-is — content is already valid Typst
+ return self.text
+
class Html(Text):
"""HTML text"""
@@ -56,6 +95,17 @@ def to_latex(self) -> str:
return _latex_escape(self.text)
+ def to_typst(self) -> str:
+ import warnings
+
+ warnings.warn(
+ "Using the `html()` helper function won't convert HTML to Typst. "
+ "Escaping HTML string instead.",
+ stacklevel=2,
+ )
+
+ return _typst_escape(self.text)
+
def _md_html(x: str) -> str:
str = commonmark.commonmark(x)
@@ -68,17 +118,86 @@ def _md_latex(x: str) -> str:
raise NotImplementedError("Markdown to LaTeX conversion is not supported yet")
+def _md_typst(x: str) -> str:
+ # Direct Markdown -> Typst conversion for inline formatting in table cells.
+ # Markdown and Typst share similar syntax, so we convert directly rather than
+ # going through HTML. Processing order matters to avoid conflicts between
+ # Markdown and Typst marker characters.
+ result = x
+
+ # 1. Extract code spans into placeholders (identical syntax in both)
+ code_spans: list[str] = []
+
+ def _save_code(m: re.Match[str]) -> str:
+ code_spans.append(m.group(0))
+ return f"\x00C{len(code_spans) - 1}\x00"
+
+ result = re.sub(r"`[^`]+`", _save_code, result)
+
+ # 2. Handle Markdown backslash escapes -> placeholders (restore as Typst escapes)
+ escapes: list[str] = []
+
+ def _save_escape(m: re.Match[str]) -> str:
+ escapes.append(m.group(1))
+ return f"\x00E{len(escapes) - 1}\x00"
+
+ result = re.sub(r"\\([\\*_`~\[\]()#$@<>])", _save_escape, result)
+
+ # 3. Escape Typst-special chars that are not Markdown syntax
+ for ch in ["\\", "#", "$", "@", "<", ">"]:
+ result = result.replace(ch, "\\" + ch)
+
+ # 4. Convert links: [text](url) -> #link("url")[text]
+ result = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r'#link("\2")[\1]', result)
+
+ # 5. Convert bold-italic: ***text*** -> placeholder for *_text_*
+ result = re.sub(r"\*{3}(.+?)\*{3}", lambda m: f"\x00BS\x00_{m.group(1)}_\x00BE\x00", result)
+
+ # 6. Convert bold: **text** -> placeholder for *text*
+ result = re.sub(r"\*{2}(.+?)\*{2}", lambda m: f"\x00BS\x00{m.group(1)}\x00BE\x00", result)
+
+ # 7. Convert remaining italic asterisks: *text* -> _text_
+ result = re.sub(r"(? #strike[text]
+ result = re.sub(r"~~(.+?)~~", r"#strike[\1]", result)
+
+ # 9. Restore bold marker placeholders
+ result = result.replace("\x00BS\x00", "*").replace("\x00BE\x00", "*")
+
+ # 10. Restore backslash-escape placeholders as Typst escapes
+ for i, ch in enumerate(escapes):
+ typst_escaped = "\\" + ch if ch in r"\#$@<>*_`~[]" else ch
+ result = result.replace(f"\x00E{i}\x00", typst_escaped)
+
+ # 11. Restore code spans
+ for i, code in enumerate(code_spans):
+ result = result.replace(f"\x00C{i}\x00", code)
+
+ return result
+
+
def _process_text(x: str | BaseText | None, context: str = "html") -> str:
if x is None:
return ""
- escape_fn = _html_escape if context == "html" else _latex_escape
+ if context == "html":
+ escape_fn = _html_escape
+ elif context == "typst":
+ escape_fn = _typst_escape
+ else:
+ escape_fn = _latex_escape
if isinstance(x, str):
return escape_fn(x)
elif isinstance(x, BaseText):
- return x.to_html() if context == "html" else x.to_latex()
+ if context == "html":
+ return x.to_html()
+ elif context == "typst":
+ return x.to_typst()
+ else:
+ return x.to_latex()
raise TypeError(f"Invalid type: {type(x)}")
@@ -98,12 +217,26 @@ def _latex_escape(text: str) -> str:
return text
+def _typst_escape(text: str) -> str:
+ # Typst special characters: \ # $ @ < > * _ ` ~ [ ]
+ typst_escape_regex = r"[\\#$@<>*_`~\[\]]"
+ text = re.sub(typst_escape_regex, lambda match: "\\" + match.group(), text)
+
+ return text
+
+
def escape_pattern_str_latex(pattern_str: str) -> str:
pattern = r"(\{[x0-9]+\})"
return process_string(pattern_str, pattern, _latex_escape)
+def escape_pattern_str_typst(pattern_str: str) -> str:
+ pattern = r"(\{[x0-9]+\})"
+
+ return process_string(pattern_str, pattern, _typst_escape)
+
+
def process_string(string: str, pattern: str, func: Callable[[str], str]) -> str:
"""
Apply a function to segments of a string that are unmatched by a regex pattern.
diff --git a/great_tables/_utils.py b/great_tables/_utils.py
index 6025ab881..3d8963be9 100644
--- a/great_tables/_utils.py
+++ b/great_tables/_utils.py
@@ -240,7 +240,7 @@ def _migrate_unformatted_to_output(
# TODO: This function will eventually be applied to all context types but for now
# it's just used for LaTeX output
- if context != "latex":
+ if context not in ("latex", "typst"):
return data
all_formatted_cells: list[list[tuple[str, int]]] = []
@@ -266,10 +266,17 @@ def _migrate_unformatted_to_output(
# in the future)
for col, row in all_unformatted_cells:
- # Get the cell value and cast as string
+ # Get the original cell value
cell_value = _get_cell(data_tbl, row, col)
cell_value_str = str(cell_value)
+ # Skip cells already modified by substitutions (sub_missing, sub_zero).
+ # Unformatted cells start as NA in the body; if a cell has been set to a
+ # string value, it was modified by a substitution and should not be overwritten.
+ current_value = _get_cell(data._body.body, row, col)
+ if isinstance(current_value, str):
+ continue
+
result = _process_text(cell_value_str, context=context)
_set_cell(data._body.body, row, col, result)
diff --git a/great_tables/_utils_render_typst.py b/great_tables/_utils_render_typst.py
new file mode 100644
index 000000000..f74760f39
--- /dev/null
+++ b/great_tables/_utils_render_typst.py
@@ -0,0 +1,1054 @@
+from __future__ import annotations
+
+import re
+from itertools import chain
+from typing import TYPE_CHECKING
+
+from ._spanners import spanners_print_matrix
+from ._tbl_data import _get_cell, cast_frame_to_string, replace_null_frame
+from ._text import _process_text
+from ._utils import heading_has_subtitle, heading_has_title, seq_groups
+from ._utils_render_html import _get_spanners_matrix_height
+
+if TYPE_CHECKING:
+ from ._gt_data import GroupRowInfo, GTData
+
+
+def _css_length_to_typst(length: str) -> str:
+ """Convert a CSS length string (e.g., '100px', '50%', '2cm') to Typst."""
+ length = length.strip()
+ if length.endswith("px"):
+ # Convert px to pt (Typst uses pt; 1px = 0.75pt)
+ val = float(length[:-2]) * 0.75
+ # Format: use minimal decimal places (e.g., 2.25pt not 2.2pt or 2.250pt)
+ formatted = f"{val:.2f}".rstrip("0").rstrip(".")
+ return f"{formatted}pt"
+ if length.endswith("%"):
+ val = float(length[:-1])
+ # Typst supports % for widths but not for text sizes
+ return f"{val}%"
+ # pt, cm, mm, in, em — Typst supports these directly
+ return length
+
+
+def _css_length_to_typst_text_size(length: str) -> str:
+ """Convert a CSS length to Typst text size. Converts % to em.
+
+ Like _css_length_to_typst() but converts percentages to em units since
+ Typst's text(size: ...) doesn't accept percentages. em is relative to
+ the parent font size, so 125% becomes 1.25em.
+ """
+ length = length.strip()
+ if length.endswith("%"):
+ val = float(length[:-1])
+ return f"{val / 100:.2f}em"
+ return _css_length_to_typst(length)
+
+
+def _format_cell_value(value) -> str:
+ """Format a cell value for display.
+
+ - Strips .0 from integer-valued floats (e.g., 647.0 → 647)
+ - Replaces ASCII hyphen-minus with Unicode minus for negative numbers
+ """
+ if value is None:
+ return ""
+ if isinstance(value, float) and value == int(value) and not (value != value):
+ # Integer-valued float (not NaN): display without decimal
+ return str(int(value))
+ s = str(value)
+ # Use Unicode minus (U+2212) for negative values — better typography
+ if s.startswith("-"):
+ s = "\u2212" + s[1:]
+ return s
+
+
+def _css_weight_to_typst(weight: str) -> str:
+ """Convert CSS font-weight to Typst weight string."""
+ # Typst accepts: "thin", "extralight", "light", "regular", "medium",
+ # "semibold", "bold", "extrabold", "black", or integer 100-900
+ if weight == "normal":
+ return "regular"
+ if weight == "initial" or weight == "inherit":
+ return "regular"
+ return weight
+
+
+def _has_stub_column(data: GTData) -> bool:
+ """Check if the table has a stub column (explicit or summary-only)."""
+ has_summary_rows = bool(data._summary_rows or data._summary_rows_grand)
+ stub_layout = data._stub._get_stub_layout(
+ has_summary_rows=has_summary_rows, options=data._options
+ )
+ return "rowname" in stub_layout
+
+
+def _option_border_to_typst(style: str, width: str, color: str) -> str | None:
+ """Convert GT border options to Typst stroke syntax. Returns None for style='none'."""
+ if style == "none" or style == "hidden":
+ return None
+ typst_width = _css_length_to_typst(width)
+ typst_color = f'rgb("{color}")' if color.startswith("#") else color
+ # Typst doesn't support "double" — render as solid with half width
+ if style == "double":
+ # CSS double border uses the width for the total including gap;
+ # approximate as a single solid line at ~1/3 the width
+ try:
+ val = float(typst_width.replace("pt", ""))
+ v = max(val / 3, 0.75)
+ typst_width = f"{v:.2f}".rstrip("0").rstrip(".") + "pt"
+ except ValueError:
+ pass
+ return f"{typst_width} + {typst_color}"
+
+
+def create_table_start_typst(data: GTData) -> str:
+ """Create the Typst table opening with column specifications and default styling."""
+
+ opts = data._options
+
+ # Check for stub column (includes summary-only stubs)
+ has_stub = _has_stub_column(data)
+
+ # Get column alignments
+ alignments = data._boxhead._get_default_alignments()
+
+ typst_align_map = {"left": "left", "center": "center", "right": "right"}
+ col_aligns = [typst_align_map.get(a, "left") for a in alignments]
+
+ # Prepend stub column alignment
+ if has_stub:
+ col_aligns = ["left"] + col_aligns
+
+ # Build column sizing from cols_width if set, otherwise auto
+ col_widths = data._boxhead._get_column_widths()
+ default_cols = data._boxhead._get_default_columns()
+ visible_widths = [
+ col_widths[i] if i < len(col_widths) else None for i, _ in enumerate(default_cols)
+ ]
+
+ if has_stub:
+ visible_widths = [None] + visible_widths
+
+ if any(w is not None for w in visible_widths):
+ typst_widths = [
+ _css_length_to_typst(w) if w is not None else "auto" for w in visible_widths
+ ]
+ columns_spec = "columns: (" + ", ".join(typst_widths) + ",)"
+ else:
+ columns_spec = f"columns: {len(col_aligns)}"
+
+ # Build alignment specification
+ if len(set(col_aligns)) == 1:
+ align_spec = f"align: {col_aligns[0]}"
+ else:
+ align_spec = "align: (" + ", ".join(col_aligns) + ",)"
+
+ # Cell padding from options
+ row_pad = _css_length_to_typst(opts.data_row_padding.value)
+ row_pad_h = _css_length_to_typst(opts.data_row_padding_horizontal.value)
+ inset_spec = f"inset: (x: {row_pad_h}, y: {row_pad})"
+
+ # Table background color
+ table_bg = opts.table_background_color.value
+ bg_spec = ""
+ if table_bg and table_bg != "#FFFFFF":
+ typst_bg = f'rgb("{table_bg}")' if table_bg.startswith("#") else table_bg
+ bg_spec = f"\n fill: {typst_bg},"
+
+ parts = [
+ f"#table(\n {columns_spec},\n {align_spec},\n stroke: none,\n {inset_spec},{bg_spec}"
+ ]
+
+ # Row striping (respects row_striping_include_stub)
+ # Compute header row count so we can offset the fill function to skip header rows.
+ # Header rows: heading (0 or 1) + spanner levels + column labels (0 or 1)
+ has_heading = heading_has_title(data._heading.title)
+ spanner_levels = _get_spanners_matrix_height(data=data, omit_columns_row=True)
+ col_labels_visible = not opts.column_labels_hidden.value
+ header_row_count = (1 if has_heading else 0) + spanner_levels + (1 if col_labels_visible else 0)
+
+ striping_opt = opts.row_striping_include_table_body.value
+ if striping_opt:
+ stripe_color = opts.row_striping_background_color.value
+ if stripe_color:
+ typst_color = f'rgb("{stripe_color}")' if stripe_color.startswith("#") else stripe_color
+ else:
+ # Alpha-based gray layers better with other cell fills than solid color
+ typst_color = 'rgb("#8080800C")'
+ include_stub = opts.row_striping_include_stub.value
+ # Offset y by header_row_count so striping starts at the first data row
+ if has_stub and not include_stub:
+ parts.append(
+ f" fill: (x, y) => if y >= {header_row_count} and calc.odd(y - {header_row_count}) and x > 0 {{ {typst_color} }},"
+ )
+ else:
+ parts.append(
+ f" fill: (_, y) => if y >= {header_row_count} and calc.odd(y - {header_row_count}) {{ {typst_color} }},"
+ )
+
+ # Column labels vertical lines
+ col_vlines = _option_border_to_typst(
+ opts.column_labels_vlines_style.value,
+ opts.column_labels_vlines_width.value,
+ opts.column_labels_vlines_color.value,
+ )
+
+ # Vertical and horizontal lines between cells (override stroke: none)
+ vlines = _option_border_to_typst(
+ opts.table_body_vlines_style.value,
+ opts.table_body_vlines_width.value,
+ opts.table_body_vlines_color.value,
+ )
+ hlines_body = _option_border_to_typst(
+ opts.table_body_hlines_style.value,
+ opts.table_body_hlines_width.value,
+ opts.table_body_hlines_color.value,
+ )
+ # Column label vlines apply to header; use body vlines as fallback
+ effective_vlines = vlines or col_vlines
+ if effective_vlines or hlines_body:
+ x_stroke = effective_vlines or "none"
+ y_stroke = hlines_body or "none"
+ parts[0] = parts[0].replace("stroke: none", f"stroke: (x: {x_stroke}, y: {y_stroke})")
+
+ # Top table border
+ top_border = _option_border_to_typst(
+ opts.table_border_top_style.value,
+ opts.table_border_top_width.value,
+ opts.table_border_top_color.value,
+ )
+ if top_border and opts.table_border_top_include.value:
+ parts.append(f" table.hline(stroke: {top_border}),")
+
+ # Left/right table borders
+ left_border = _option_border_to_typst(
+ opts.table_border_left_style.value,
+ opts.table_border_left_width.value,
+ opts.table_border_left_color.value,
+ )
+ right_border = _option_border_to_typst(
+ opts.table_border_right_style.value,
+ opts.table_border_right_width.value,
+ opts.table_border_right_color.value,
+ )
+ if left_border:
+ parts.append(f" table.vline(x: 0, stroke: {left_border}),")
+ if right_border:
+ n_cols = len(col_aligns)
+ parts.append(f" table.vline(x: {n_cols}, stroke: {right_border}),")
+
+ # Stub vertical separator line (replaces per-cell stroke: (right: ...))
+ if has_stub:
+ stub_border = _option_border_to_typst(
+ opts.stub_border_style.value,
+ opts.stub_border_width.value,
+ opts.stub_border_color.value,
+ )
+ if stub_border:
+ parts.append(f" table.vline(x: 1, stroke: {stub_border}),")
+
+ return "\n".join(parts)
+
+
+def create_heading_component_typst(data: GTData, n_cols: int) -> str:
+ """Create the Typst heading as table cells that span all columns.
+
+ Returns table.cell rows to be placed inside the #table() before the header,
+ so the heading inherits the table's natural width.
+ """
+
+ opts = data._options
+ title = data._heading.title
+ subtitle = data._heading.subtitle
+
+ has_title = heading_has_title(title)
+
+ if not has_title:
+ return ""
+
+ title_str = _process_text(title, context="typst")
+
+ # Heading options
+
+ heading_align = opts.heading_align.value or "center"
+ title_size = _css_length_to_typst_text_size(opts.heading_title_font_size.value)
+ title_weight = opts.heading_title_font_weight.value
+ title_text_props = f"size: {title_size}"
+ if title_weight and title_weight != "initial":
+ title_text_props += f', weight: "{_css_weight_to_typst(title_weight)}"'
+ else:
+ title_text_props += ', weight: "bold"'
+
+ bg_color = opts.heading_background_color.value
+ padding = _css_length_to_typst(opts.heading_padding.value)
+ padding_h = _css_length_to_typst(opts.heading_padding_horizontal.value)
+
+ # Use light text color when heading has a dark background color
+ heading_text_fill = ""
+ if bg_color and bg_color.startswith("#") and len(bg_color) >= 7:
+ # Simple luminance check: if background is dark, use light text
+ try:
+ r = int(bg_color[1:3], 16)
+ g = int(bg_color[3:5], 16)
+ b = int(bg_color[5:7], 16)
+ luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
+ if luminance < 0.5:
+ font_color_light = opts.table_font_color_light.value or "#FFFFFF"
+ heading_text_fill = f', fill: rgb("{font_color_light}")' if font_color_light.startswith("#") else f", fill: {font_color_light}"
+ title_text_props += heading_text_fill
+ except (ValueError, IndexError):
+ pass
+
+ # Build cell properties
+ cell_props = [f"colspan: {n_cols}", f"align: {heading_align}"]
+ if bg_color:
+ typst_bg = f'rgb("{bg_color}")' if bg_color.startswith("#") else bg_color
+ cell_props.append(f"fill: {typst_bg}")
+ cell_props.append(f"inset: (x: {padding_h}, y: {padding})")
+
+ parts: list[str] = []
+
+ # Title as a table.cell spanning all columns
+ title_content = f"#text({title_text_props})[{title_str}]"
+ if heading_has_subtitle(subtitle):
+ subtitle_str = _process_text(subtitle, context="typst")
+ subtitle_size = _css_length_to_typst_text_size(opts.heading_subtitle_font_size.value)
+ subtitle_weight = opts.heading_subtitle_font_weight.value
+ subtitle_text_props = f"size: {subtitle_size}"
+ if subtitle_weight and subtitle_weight != "initial":
+ subtitle_text_props += f', weight: "{_css_weight_to_typst(subtitle_weight)}"'
+ subtitle_text_props += heading_text_fill
+ title_content += f" \\ #text({subtitle_text_props})[{subtitle_str}]"
+
+ parts.append(f" table.cell({', '.join(cell_props)})[{title_content}],")
+
+ # Heading border bottom
+ heading_border = _option_border_to_typst(
+ opts.heading_border_bottom_style.value,
+ opts.heading_border_bottom_width.value,
+ opts.heading_border_bottom_color.value,
+ )
+ if heading_border:
+ parts.append(f" table.hline(stroke: {heading_border}),")
+
+ return "\n".join(parts)
+
+
+def create_columns_component_typst(data: GTData) -> str:
+ """Create the Typst column headers and spanners with GT-style borders."""
+
+ opts = data._options
+ spanner_row_count = _get_spanners_matrix_height(data=data, omit_columns_row=True)
+
+ # Check for stub column (includes summary-only stubs)
+ has_stub = _has_stub_column(data)
+
+ # Get column headings
+ headings_labels = data._boxhead._get_default_column_labels()
+ headings_labels = [_process_text(x, context="typst") for x in headings_labels]
+
+ rows: list[str] = []
+
+ # Build spanner rows if they exist
+ if spanner_row_count > 0:
+ boxhead = data._boxhead
+
+ spanners, _ = spanners_print_matrix(
+ spanners=data._spanners,
+ boxhead=boxhead,
+ include_hidden=False,
+ ids=False,
+ omit_columns_row=True,
+ )
+
+ for spanners_row in spanners:
+ spanners_row = {k: "" if v is None else v for k, v in spanners_row.items()}
+
+ spanner_ids_index = spanners_row.values()
+ spanners_rle = seq_groups(seq=spanner_ids_index)
+
+ group_spans = [[x[1]] + [0] * (x[1] - 1) for x in spanners_rle]
+ colspans = chain.from_iterable(group_spans)
+
+ level_i_spanners = (
+ _process_text(span_label, context="typst") if span_label else None
+ for colspan, span_label in zip(colspans, spanners_row.values())
+ if colspan > 0
+ )
+
+ spanner_cells = []
+ spanner_hlines = []
+ col_accumulator = 1 if has_stub else 0 # offset for stub column
+
+ for j, level_i_spanner_j in enumerate(level_i_spanners):
+ span = group_spans[j][0]
+ if level_i_spanner_j is None:
+ # Empty cells for non-spanned columns
+ spanner_cells.extend(["[]"] * span)
+ else:
+ # Spanners inherit column_labels_font_weight
+ span_weight = opts.column_labels_font_weight.value
+ span_content = (
+ f"*{level_i_spanner_j}*" if span_weight == "bold" else level_i_spanner_j
+ )
+ spanner_cells.append(
+ f"table.cell(colspan: {span}, align: center)[{span_content}]"
+ )
+ # Track position for partial hline under this spanner
+ spanner_hlines.append(
+ f' table.hline(start: {col_accumulator}, end: {col_accumulator + span}, stroke: 0.75pt + rgb("#D3D3D3")),'
+ )
+ col_accumulator += span
+
+ rows.append(" " + ", ".join(spanner_cells) + ",")
+ rows.extend(spanner_hlines)
+
+ # Column label font weight — only bold-wrap if weight is "bold"
+ col_label_weight = opts.column_labels_font_weight.value
+ use_bold_labels = col_label_weight == "bold"
+
+ # Column labels hidden
+ if opts.column_labels_hidden.value:
+ return ""
+
+ # Column label text transform
+ col_text_transform = opts.column_labels_text_transform.value
+
+ # Build header row with column labels
+ header_cells: list[str] = []
+ if has_stub:
+ header_cells.append("[]") # blank cell for stub column
+
+ # Column label background color and padding
+ col_label_bg = opts.column_labels_background_color.value
+ col_label_pad = opts.column_labels_padding.value
+ col_label_pad_h = opts.column_labels_padding_horizontal.value
+ # Check if padding differs from default data row padding
+ has_custom_pad = col_label_pad != "5px" or col_label_pad_h != "5px"
+
+ for label in headings_labels:
+ # Apply text transform
+ if col_text_transform == "uppercase":
+ label = label.upper()
+ elif col_text_transform == "lowercase":
+ label = label.lower()
+ elif col_text_transform == "capitalize":
+ label = label.title()
+
+ # Column label font size
+ col_label_font_size = opts.column_labels_font_size.value
+ if col_label_font_size and col_label_font_size != "100%":
+ label = f"#text(size: {_css_length_to_typst_text_size(col_label_font_size)})[{label}]"
+ cell_content = f"*{label}*" if use_bold_labels else label
+ cell_props: list[str] = []
+ if col_label_bg:
+ typst_bg = f'rgb("{col_label_bg}")' if col_label_bg.startswith("#") else col_label_bg
+ cell_props.append(f"fill: {typst_bg}")
+ if has_custom_pad:
+ cell_props.append(
+ f"inset: (x: {_css_length_to_typst(col_label_pad_h)}, y: {_css_length_to_typst(col_label_pad)})"
+ )
+ if cell_props:
+ header_cells.append(f"table.cell({', '.join(cell_props)})[{cell_content}]")
+ else:
+ header_cells.append(f"[{cell_content}]")
+
+ rows.append(" " + ", ".join(header_cells) + ",")
+
+ # Column label borders
+ col_border_top = _option_border_to_typst(
+ opts.column_labels_border_top_style.value,
+ opts.column_labels_border_top_width.value,
+ opts.column_labels_border_top_color.value,
+ )
+ col_border_bottom = _option_border_to_typst(
+ opts.column_labels_border_bottom_style.value,
+ opts.column_labels_border_bottom_width.value,
+ opts.column_labels_border_bottom_color.value,
+ )
+ col_border_lr = _option_border_to_typst(
+ opts.column_labels_border_lr_style.value,
+ opts.column_labels_border_lr_width.value,
+ opts.column_labels_border_lr_color.value,
+ )
+
+ # Wrap in table.header() with borders
+ header_content = "\n".join(rows)
+ result = " table.header(\n"
+ if col_border_top:
+ result += f" table.hline(stroke: {col_border_top}),\n"
+ if col_border_lr:
+ result += f" table.vline(x: 0, stroke: {col_border_lr}),\n"
+ n_header_cols = len(headings_labels) + (1 if has_stub else 0)
+ result += f" table.vline(x: {n_header_cols}, stroke: {col_border_lr}),\n"
+ result += f"{header_content}\n ),"
+ if col_border_bottom:
+ result += f"\n table.hline(stroke: {col_border_bottom}),"
+ return result
+
+
+def _get_cell_styles_typst(data: GTData, rownum: int, colname: str) -> dict[str, str]:
+ """Collect Typst style properties for a specific cell from all matching StyleInfo entries."""
+ from ._locations import LocBody
+
+ merged: dict[str, str] = {}
+ for style_info in data._styles:
+ # Match body styles by row and column
+ if not isinstance(style_info.locname, LocBody):
+ continue
+ if style_info.rownum is not None and style_info.rownum != rownum:
+ continue
+ if style_info.colname is not None and style_info.colname != colname:
+ continue
+ for cell_style in style_info.styles:
+ try:
+ merged.update(cell_style._to_typst_style())
+ except NotImplementedError:
+ pass
+ return merged
+
+
+def _get_grand_summary_cell_styles_typst(
+ data: GTData, rownum: int, colname: str | None, is_stub: bool
+) -> dict[str, str]:
+ """Collect Typst style properties for a grand summary cell."""
+ from ._locations import LocGrandSummary, LocGrandSummaryStub
+
+ loc_cls = LocGrandSummaryStub if is_stub else LocGrandSummary
+ merged: dict[str, str] = {}
+ for style_info in data._styles:
+ if not isinstance(style_info.locname, loc_cls):
+ continue
+ if style_info.rownum is not None and style_info.rownum != rownum:
+ continue
+ if not is_stub and style_info.colname is not None and style_info.colname != colname:
+ continue
+ for cell_style in style_info.styles:
+ try:
+ merged.update(cell_style._to_typst_style())
+ except NotImplementedError:
+ pass
+ return merged
+
+
+def _typst_styled_cell(content: str, styles: dict[str, str]) -> str:
+ """Wrap a cell content string with Typst styling."""
+ if not styles:
+ return f"[{content}]"
+
+ props: list[str] = []
+ if "fill" in styles:
+ props.append(f"fill: {styles['fill']}")
+ if "stroke" in styles:
+ props.append(f"stroke: {styles['stroke']}")
+ if "inset" in styles:
+ props.append(f"inset: {styles['inset']}")
+
+ # Apply text decorations and transforms as outer wraps
+ wrapped_content = content
+ if "text_decorate" in styles:
+ for deco in styles["text_decorate"].split(","):
+ wrapped_content = f"#{deco}[{wrapped_content}]"
+ if "text_transform" in styles:
+ wrapped_content = f"#{styles['text_transform']}[{wrapped_content}]"
+
+ if "text_style" in styles:
+ # Inside [...] we're in markup mode, so need # prefix for code expressions
+ inner = f"[#text({styles['text_style']})[{wrapped_content}]]"
+ else:
+ inner = f"[{wrapped_content}]"
+
+ if props:
+ # table.cell(fill: ..., stroke: ...)[content]
+ return f"table.cell({', '.join(props)}){inner}"
+
+ # When only text styling (no cell-level fill/stroke/inset), use inline #text()
+ # inside content brackets — no table.cell() wrapper needed
+ return inner
+
+
+def _create_grand_summary_rows_typst(
+ data: GTData,
+ summary_rows: list,
+ column_vars: list,
+ has_row_stub: bool,
+ row_index_offset: int,
+) -> list[str]:
+ """Render grand summary rows as Typst table cells."""
+ opts = data._options
+ gs_bg = opts.grand_summary_row_background_color.value
+ gs_text_transform = opts.grand_summary_row_text_transform.value
+ gs_padding = opts.grand_summary_row_padding.value
+ gs_padding_h = opts.grand_summary_row_padding_horizontal.value
+ has_custom_gs_pad = gs_padding != "8px" or gs_padding_h != "5px"
+ rows: list[str] = []
+ for i, summary_row in enumerate(summary_rows):
+ row_index = row_index_offset + i
+ cells: list[str] = []
+
+ # Stub column: summary row label
+ if has_row_stub:
+ stub_styles = _get_grand_summary_cell_styles_typst(
+ data, rownum=row_index, colname=None, is_stub=True
+ )
+ if gs_bg and "fill" not in stub_styles:
+ typst_bg = f'rgb("{gs_bg}")' if gs_bg.startswith("#") else gs_bg
+ stub_styles["fill"] = typst_bg
+ if has_custom_gs_pad:
+ stub_styles["inset"] = (
+ f"(x: {_css_length_to_typst(gs_padding_h)}, y: {_css_length_to_typst(gs_padding)})"
+ )
+ label = summary_row.id
+ if gs_text_transform == "uppercase":
+ label = label.upper()
+ cells.append(_typst_styled_cell(f"*{label}*", stub_styles))
+
+ # Data columns
+ for colinfo in column_vars:
+ cell_value = summary_row.values.get(colinfo.var)
+ cell_str = _format_cell_value(cell_value)
+ cell_styles = _get_grand_summary_cell_styles_typst(
+ data, rownum=row_index, colname=colinfo.var, is_stub=False
+ )
+ if gs_bg and "fill" not in cell_styles:
+ typst_bg = f'rgb("{gs_bg}")' if gs_bg.startswith("#") else gs_bg
+ cell_styles["fill"] = typst_bg
+ if has_custom_gs_pad:
+ cell_styles["inset"] = (
+ f"(x: {_css_length_to_typst(gs_padding_h)}, y: {_css_length_to_typst(gs_padding)})"
+ )
+ cells.append(_typst_styled_cell(cell_str, cell_styles))
+
+ rows.append(" " + ", ".join(cells) + ",")
+ return rows
+
+
+def create_body_component_typst(data: GTData) -> str:
+ """Create the Typst table body rows."""
+
+ _str_orig_data = cast_frame_to_string(data._tbl_data)
+ tbl_data = replace_null_frame(data._body.body, _str_orig_data)
+
+ column_vars = data._boxhead._get_default_columns()
+ n_data_cols = len(column_vars)
+
+ # Check for stub and group features
+ has_row_stub = _has_stub_column(data)
+ has_groups = len(data._stub.group_ids) > 0
+
+ # Total columns including stub
+ total_cols = n_data_cols + (1 if has_row_stub else 0)
+
+ body_rows: list[str] = []
+ opts = data._options
+
+ # Grand summary border (used for top and bottom separators)
+ gs_border_early = _option_border_to_typst(
+ opts.grand_summary_row_border_style.value,
+ opts.grand_summary_row_border_width.value,
+ opts.grand_summary_row_border_color.value,
+ )
+ gs_hline_early = gs_border_early or '0.75pt + rgb("#A8A8A8")'
+
+ # Render top-side grand summary rows
+ top_g_summary_rows = data._summary_rows_grand.get_summary_rows(side="top")
+ if top_g_summary_rows:
+ body_rows.extend(
+ _create_grand_summary_rows_typst(
+ data, top_g_summary_rows, column_vars, has_row_stub, row_index_offset=0
+ )
+ )
+ body_rows.append(f" table.hline(stroke: {gs_hline_early}),")
+
+ ordered_index: list[tuple[int, GroupRowInfo | None]] = data._stub.group_indices_map()
+
+ prev_group_info = None
+ stub_col = data._boxhead._get_stub_column()
+
+ # Row group styling options
+ rg_bg = opts.row_group_background_color.value
+ rg_weight = opts.row_group_font_weight.value
+ rg_use_bold = rg_weight == "bold" or rg_weight == "initial" # default is bold-like
+ rg_font_size = opts.row_group_font_size.value
+ rg_text_transform = opts.row_group_text_transform.value
+ rg_padding = opts.row_group_padding.value
+ rg_padding_h = opts.row_group_padding_horizontal.value
+
+ # Row group border options
+ rg_border_top = _option_border_to_typst(
+ opts.row_group_border_top_style.value,
+ opts.row_group_border_top_width.value,
+ opts.row_group_border_top_color.value,
+ )
+ rg_border_bottom = _option_border_to_typst(
+ opts.row_group_border_bottom_style.value,
+ opts.row_group_border_bottom_width.value,
+ opts.row_group_border_bottom_color.value,
+ )
+ rg_border_left = _option_border_to_typst(
+ opts.row_group_border_left_style.value,
+ opts.row_group_border_left_width.value,
+ opts.row_group_border_left_color.value,
+ )
+ rg_border_right = _option_border_to_typst(
+ opts.row_group_border_right_style.value,
+ opts.row_group_border_right_width.value,
+ opts.row_group_border_right_color.value,
+ )
+
+ # row_group_as_column
+ _rg_as_column = opts.row_group_as_column.value # noqa: F841
+
+ # Stub styling options
+ stub_bg = opts.stub_background_color.value
+ stub_weight = opts.stub_font_weight.value
+ stub_use_bold = stub_weight == "bold"
+ stub_font_size = opts.stub_font_size.value
+ stub_text_transform = opts.stub_text_transform.value
+ # stub_border is now a table.vline in create_table_start_typst
+ # row_striping_include_stub is handled in create_table_start_typst
+
+ # Stub row group styling (used when row_group_as_column=True, not yet implemented)
+ _stub_rg_bg = opts.stub_row_group_background_color.value # noqa: F841
+ _stub_rg_font_size = opts.stub_row_group_font_size.value # noqa: F841
+ _stub_rg_weight = opts.stub_row_group_font_weight.value # noqa: F841
+ _stub_rg_text_transform = opts.stub_row_group_text_transform.value # noqa: F841
+ _stub_rg_border = _option_border_to_typst( # noqa: F841
+ opts.stub_row_group_border_style.value,
+ opts.stub_row_group_border_width.value,
+ opts.stub_row_group_border_color.value,
+ )
+
+ # Table body borders
+ body_border_top = _option_border_to_typst(
+ opts.table_body_border_top_style.value,
+ opts.table_body_border_top_width.value,
+ opts.table_body_border_top_color.value,
+ )
+ body_border_bottom = _option_border_to_typst(
+ opts.table_body_border_bottom_style.value,
+ opts.table_body_border_bottom_width.value,
+ opts.table_body_border_bottom_color.value,
+ )
+
+ if body_border_top:
+ body_rows.append(f" table.hline(stroke: {body_border_top}),")
+
+ for i, group_info in ordered_index:
+ # Insert group label row if this is a new group
+ if has_groups and group_info is not prev_group_info and group_info is not None:
+ group_label = group_info.defaulted_label()
+ group_label = _process_text(group_label, context="typst")
+ # Apply text transform
+ if rg_text_transform == "uppercase":
+ group_label = group_label.upper()
+ elif rg_text_transform == "lowercase":
+ group_label = group_label.lower()
+ elif rg_text_transform == "capitalize":
+ group_label = group_label.title()
+ label_content = f'#text(weight: "bold")[{group_label}]' if rg_use_bold else group_label
+ # Wrap in text() if custom font size
+ if rg_font_size and rg_font_size != "100%":
+ label_content = (
+ f"#text(size: {_css_length_to_typst_text_size(rg_font_size)})[{label_content}]"
+ )
+ cell_props = [f"colspan: {total_cols}", "align: left"]
+ if rg_bg:
+ typst_bg = f'rgb("{rg_bg}")' if rg_bg.startswith("#") else rg_bg
+ cell_props.append(f"fill: {typst_bg}")
+ has_custom_rg_pad = rg_padding != "8px" or rg_padding_h != "5px"
+ if has_custom_rg_pad:
+ cell_props.append(
+ f"inset: (x: {_css_length_to_typst(rg_padding_h)}, y: {_css_length_to_typst(rg_padding)})"
+ )
+ # Row group stroke (left/right applied to the cell itself)
+ rg_stroke_parts: list[str] = []
+ if rg_border_left:
+ rg_stroke_parts.append(f"left: {rg_border_left}")
+ if rg_border_right:
+ rg_stroke_parts.append(f"right: {rg_border_right}")
+ if rg_stroke_parts:
+ cell_props.append(f"stroke: ({', '.join(rg_stroke_parts)})")
+ if rg_border_top:
+ body_rows.append(f" table.hline(stroke: {rg_border_top}),")
+ body_rows.append(f" table.cell({', '.join(cell_props)})[{label_content}],")
+ if rg_border_bottom:
+ body_rows.append(f" table.hline(stroke: {rg_border_bottom}),")
+
+ prev_group_info = group_info
+
+ body_cells: list[str] = []
+
+ # Add stub (row name) cell if present
+ if has_row_stub:
+ if stub_col is not None:
+ row_label = str(_get_cell(tbl_data, i, stub_col.var))
+ # Apply text transform
+ if stub_text_transform == "uppercase":
+ row_label = row_label.upper()
+ elif stub_text_transform == "lowercase":
+ row_label = row_label.lower()
+ elif stub_text_transform == "capitalize":
+ row_label = row_label.title()
+ label_content = f"*{row_label}*" if stub_use_bold else row_label
+ # Apply font size
+ if stub_font_size and stub_font_size != "100%":
+ label_content = f"#text(size: {_css_length_to_typst_text_size(stub_font_size)})[{label_content}]"
+ stub_cell_props: list[str] = []
+ if stub_bg:
+ typst_bg = f'rgb("{stub_bg}")' if stub_bg.startswith("#") else stub_bg
+ stub_cell_props.append(f"fill: {typst_bg}")
+ # stub border is now a table.vline in create_table_start_typst
+ if stub_cell_props:
+ body_cells.append(f"table.cell({', '.join(stub_cell_props)})[{label_content}]")
+ else:
+ body_cells.append(f"[{label_content}]")
+ else:
+ # Summary-only stub: emit empty cell for data rows
+ body_cells.append("[]")
+
+ for colinfo in column_vars:
+ cell_content = _get_cell(tbl_data, i, colinfo.var)
+ cell_str: str = str(cell_content)
+
+ # Get styles for this cell
+ cell_styles = _get_cell_styles_typst(data, rownum=i, colname=colinfo.var)
+ body_cells.append(_typst_styled_cell(cell_str, cell_styles))
+
+ body_rows.append(" " + ", ".join(body_cells) + ",")
+
+ # Grand summary border
+ gs_border = _option_border_to_typst(
+ opts.grand_summary_row_border_style.value,
+ opts.grand_summary_row_border_width.value,
+ opts.grand_summary_row_border_color.value,
+ )
+ gs_hline = gs_border or '0.75pt + rgb("#A8A8A8")'
+
+ # Render bottom-side grand summary rows
+ bottom_g_summary_rows = data._summary_rows_grand.get_summary_rows(side="bottom")
+ if bottom_g_summary_rows:
+ # Grand summary border replaces body_border_bottom
+ body_rows.append(f" table.hline(stroke: {gs_hline}),")
+ body_rows.extend(
+ _create_grand_summary_rows_typst(
+ data,
+ bottom_g_summary_rows,
+ column_vars,
+ has_row_stub,
+ row_index_offset=len(top_g_summary_rows),
+ )
+ )
+ # body_border_bottom is emitted by the footer component (as separator before source notes)
+ # or as part of the grand summary hline above. Only emit here if neither applies.
+ elif body_border_bottom and not bool(data._source_notes):
+ body_rows.append(f" table.hline(stroke: {body_border_bottom}),")
+
+ return "\n".join(body_rows)
+
+
+def create_footer_component_typst(data: GTData, n_cols: int) -> str:
+ """Create the Typst footer as table.cell rows inside the table.
+
+ Returns table.cell(colspan: N) entries for source notes, placed inside the
+ #table() after body rows. This way the footer inherits the table's width
+ and an explicit fill prevents table-level striping from leaking in.
+ """
+
+ opts = data._options
+ source_notes = data._source_notes
+
+ if len(source_notes) == 0:
+ return ""
+
+ parts: list[str] = []
+
+ # Separator line before footer (uses table_body_border_bottom)
+ body_bottom_border = _option_border_to_typst(
+ opts.table_body_border_bottom_style.value,
+ opts.table_body_border_bottom_width.value,
+ opts.table_body_border_bottom_color.value,
+ )
+ if body_bottom_border:
+ parts.append(f" table.hline(stroke: {body_bottom_border}),")
+
+ source_notes_strs = [_process_text(x, context="typst") for x in source_notes]
+
+ # Source notes options
+ sn_font_size = _css_length_to_typst_text_size(opts.source_notes_font_size.value)
+ sn_bg = opts.source_notes_background_color.value
+ sn_multiline = opts.source_notes_multiline.value
+ sn_sep = opts.source_notes_sep.value or " "
+ sn_padding = _css_length_to_typst(opts.source_notes_padding.value)
+ sn_pad_h = _css_length_to_typst(opts.source_notes_padding_horizontal.value)
+
+ if sn_multiline:
+ notes_parts = [f"#text(size: {sn_font_size})[{note}]" for note in source_notes_strs]
+ notes_content = r" \ ".join(notes_parts)
+ else:
+ joined = sn_sep.join(source_notes_strs)
+ notes_content = f"#text(size: {sn_font_size})[{joined}]"
+
+ # Build table.cell properties
+ cell_props = [f"colspan: {n_cols}", "align: left"]
+
+ # Always set fill to prevent table-level striping from leaking into footer
+ if sn_bg:
+ typst_bg = f'rgb("{sn_bg}")' if sn_bg.startswith("#") else sn_bg
+ else:
+ tbl_bg = opts.table_background_color.value or "#FFFFFF"
+ typst_bg = f'rgb("{tbl_bg}")' if tbl_bg.startswith("#") else tbl_bg
+ cell_props.append(f"fill: {typst_bg}")
+
+ cell_props.append(f"inset: (x: {sn_pad_h}, y: {sn_padding})")
+
+ # Borders via stroke on the cell
+ sn_border_bottom = _option_border_to_typst(
+ opts.source_notes_border_bottom_style.value,
+ opts.source_notes_border_bottom_width.value,
+ opts.source_notes_border_bottom_color.value,
+ )
+ sn_border_lr = _option_border_to_typst(
+ opts.source_notes_border_lr_style.value,
+ opts.source_notes_border_lr_width.value,
+ opts.source_notes_border_lr_color.value,
+ )
+ stroke_parts: list[str] = []
+ if sn_border_bottom:
+ stroke_parts.append(f"bottom: {sn_border_bottom}")
+ if sn_border_lr:
+ stroke_parts.append(f"left: {sn_border_lr}")
+ stroke_parts.append(f"right: {sn_border_lr}")
+ if stroke_parts:
+ cell_props.append(f"stroke: ({', '.join(stroke_parts)})")
+
+ parts.append(f" table.cell({', '.join(cell_props)})[{notes_content}],")
+ return "\n".join(parts)
+
+
+def _render_as_typst(data: GTData) -> str:
+ """Render a GTData object as a Typst string."""
+
+ opts = data._options
+
+ # Create table start with column specs
+ table_start = create_table_start_typst(data=data)
+
+ # Compute total column count for heading colspan
+ has_stub = _has_stub_column(data)
+ n_data_cols = len(data._boxhead._get_default_columns())
+ n_cols = n_data_cols + (1 if has_stub else 0)
+
+ # Create heading as table cells (inside table, before header)
+ heading_component = create_heading_component_typst(data=data, n_cols=n_cols)
+
+ # Create column headers (inside table)
+ columns_component = create_columns_component_typst(data=data)
+
+ # Create body rows (inside table)
+ body_component = create_body_component_typst(data=data)
+
+ # Create footer as table.cell rows (inside table, after body)
+ footer_component = create_footer_component_typst(data=data, n_cols=n_cols)
+
+ # Bottom table border (inside table, at the end)
+ bottom_border = _option_border_to_typst(
+ opts.table_border_bottom_style.value,
+ opts.table_border_bottom_width.value,
+ opts.table_border_bottom_color.value,
+ )
+ bottom_hline = ""
+ if bottom_border and opts.table_border_bottom_include.value:
+ bottom_hline = f"\n table.hline(stroke: {bottom_border}),"
+
+ # Assemble the table
+ parts: list[str] = []
+
+ # Text set rules (font color, size, weight, style, family)
+ text_props: list[str] = []
+ font_color = opts.table_font_color.value
+ if font_color:
+ text_props.append(f'fill: rgb("{font_color}")' if font_color.startswith("#") else f"fill: {font_color}")
+
+ table_font_size = opts.table_font_size.value
+ if table_font_size and table_font_size != "16px":
+ text_props.append(f"size: {_css_length_to_typst(table_font_size)}")
+
+ table_font_weight = opts.table_font_weight.value
+ if table_font_weight and table_font_weight != "normal":
+ text_props.append(f'weight: "{_css_weight_to_typst(table_font_weight)}"')
+
+ table_font_style = opts.table_font_style.value
+ if table_font_style and table_font_style != "normal":
+ text_props.append(f'style: "{table_font_style}"')
+
+ # Typst font stack: filter out CSS-only names that Typst can't resolve,
+ # but keep generic families (sans-serif, serif, monospace) which Typst supports.
+ _css_only_fonts = {"-apple-system", "BlinkMacSystemFont"}
+
+ table_font_names = opts.table_font_names.value
+ if table_font_names:
+ typst_fonts = [f for f in table_font_names if f not in _css_only_fonts]
+ if typst_fonts:
+ font_list = ", ".join(f'"{f}"' for f in typst_fonts)
+ parts.append(f"#set text(font: ({font_list},))")
+ # If table_font_names is empty/None, don't emit #set text(font:) —
+ # this lets the table inherit the document's font settings
+
+ if text_props:
+ parts.append(f"#set text({', '.join(text_props)})")
+
+ # table_font_color_light is used by data_color for auto-contrast text,
+ # not directly in the renderer — it's consumed by _data_color/base.py
+ _font_color_light = opts.table_font_color_light.value # noqa: F841
+
+ # Table width and margins
+ table_width = opts.table_width.value
+ table_margin_left = opts.table_margin_left.value
+ table_margin_right = opts.table_margin_right.value
+
+ # Assemble table: heading cells go inside table.header() so they repeat on page breaks.
+ # The columns_component already wraps content in table.header(...), so inject heading there.
+ if heading_component and columns_component:
+ # Insert heading cells at the start of table.header()
+ columns_component = columns_component.replace(
+ " table.header(\n", f" table.header(\n{heading_component}\n", 1
+ )
+ elif heading_component:
+ # No column headers (hidden), wrap heading in its own table.header()
+ columns_component = f" table.header(\n{heading_component}\n ),"
+
+ # Footer goes inside the table (as table.cell rows) before the bottom border
+ footer_section = f"\n{footer_component}" if footer_component else ""
+ table_content = (
+ f"{table_start}\n{columns_component}\n" f"{body_component}{footer_section}{bottom_hline}\n)"
+ )
+
+ # Wrap in block if table_width or margins are set
+ block_props: list[str] = []
+ if table_width and table_width != "auto":
+ block_props.append(f"width: {_css_length_to_typst(table_width)}")
+ if table_margin_left and table_margin_left != "auto":
+ block_props.append(f"inset: (left: {_css_length_to_typst(table_margin_left)})")
+ if table_margin_right and table_margin_right != "auto":
+ if table_margin_left and table_margin_left != "auto":
+ block_props[-1] = (
+ f"inset: (left: {_css_length_to_typst(table_margin_left)}, "
+ f"right: {_css_length_to_typst(table_margin_right)})"
+ )
+ else:
+ block_props.append(f"inset: (right: {_css_length_to_typst(table_margin_right)})")
+ if block_props:
+ table_content = f"#block({', '.join(block_props)})[\n{table_content}\n]"
+
+ parts.append(table_content)
+
+ # Deduplicate consecutive identical hlines (e.g., heading border + col label border)
+ result = "\n".join(parts)
+ result = re.sub(
+ r"( table\.hline\([^)]+\),)\n\1",
+ r"\1",
+ result,
+ )
+ return result
diff --git a/great_tables/gt.py b/great_tables/gt.py
index 4030187e6..5521d3963 100644
--- a/great_tables/gt.py
+++ b/great_tables/gt.py
@@ -9,7 +9,7 @@
from ._boxhead import cols_align, cols_label, cols_label_rotate, cols_label_with
from ._cols_merge import perform_col_merge
from ._data_color import data_color
-from ._export import as_latex, as_raw_html, save, show, write_raw_html
+from ._export import as_latex, as_raw_html, as_typst, save, show, write_raw_html
from ._formats import (
fmt,
fmt_bytes,
@@ -22,6 +22,7 @@
fmt_image,
fmt_integer,
fmt_markdown,
+ fmt_typst,
fmt_nanoplot,
fmt_number,
fmt_percent,
@@ -239,6 +240,7 @@ def __init__(
fmt_time = fmt_time
fmt_datetime = fmt_datetime
fmt_markdown = fmt_markdown
+ fmt_typst = fmt_typst
fmt_image = fmt_image
fmt_icon = fmt_icon
fmt_flag = fmt_flag
@@ -292,11 +294,25 @@ def __init__(
as_raw_html = as_raw_html
write_raw_html = write_raw_html
as_latex = as_latex
+ as_typst = as_typst
pipe = pipe
# -----
+ def _repr_mimebundle_(self, **kwargs):
+ from .quarto import is_quarto_typst_render
+
+ # When Quarto is rendering to Typst, emit native Typst via text/plain
+ # wrapped in a raw Typst block that Quarto's Pandoc pipeline will process
+ if is_quarto_typst_render():
+ typst_content = self.as_typst()
+ raw_block = f"```{{=typst}}\n{typst_content}\n```"
+ return {"text/markdown": raw_block}, {}
+
+ # Otherwise return HTML for standard rendering
+ return {"text/html": self._repr_html_()}, {}
+
def _repr_html_(self):
# Some rendering environments expect that the HTML provided is a full page; however, quite
# a few others accept a fragment of HTML. Within `as_raw_html()` can use the `make_page=`
@@ -331,7 +347,7 @@ def _build_data(self, context: str) -> Self:
# of lists with cells initially set to nan values
built = self._render_formats(context)
- if context == "latex":
+ if context in ("latex", "typst"):
built = _migrate_unformatted_to_output(
data=built, data_tbl=self._tbl_data, formats=self._formats, context=context
)
@@ -362,8 +378,13 @@ def render(
# Note ideally, this function will forward to things like .as_raw_html(), using a
# context dataclass to set the options on those functions. E.g. a LatexContext
# would have the options for a .as_latex() method, etc..
- html_table = self._build_data(context=context)._render_as_html()
- return html_table
+ if context == "typst":
+ return self.as_typst()
+ elif context == "latex":
+ return self.as_latex()
+ else:
+ html_table = self._build_data(context=context)._render_as_html()
+ return html_table
# =============================================================================
# HTML Rendering
diff --git a/great_tables/quarto.py b/great_tables/quarto.py
index 5f4e05b16..05d7313a0 100644
--- a/great_tables/quarto.py
+++ b/great_tables/quarto.py
@@ -1,3 +1,4 @@
+import json
import os
@@ -11,3 +12,53 @@ def is_quarto_render() -> bool:
"""
return "QUARTO_BIN_PATH" in os.environ
+
+
+_quarto_pandoc_to: str | None = None
+
+
+def _get_quarto_pandoc_to() -> str:
+ """Read the Pandoc target format from Quarto's execution info JSON file.
+
+ Quarto sets QUARTO_EXECUTE_INFO to a path pointing to a JSON file containing
+ format.pandoc.to (e.g., "typst", "html", "latex"). The file is a temp file
+ that may be cleaned up during rendering, so we cache the result on first read.
+ """
+ global _quarto_pandoc_to
+
+ if _quarto_pandoc_to is not None:
+ return _quarto_pandoc_to
+
+ result = ""
+ info_path = os.environ.get("QUARTO_EXECUTE_INFO", "")
+ if info_path and os.path.isfile(info_path):
+ try:
+ with open(info_path) as f:
+ info = json.load(f)
+ result = info.get("format", {}).get("pandoc", {}).get("to", "")
+ except (json.JSONDecodeError, OSError):
+ pass
+
+ _quarto_pandoc_to = result
+ return result
+
+
+# Eagerly read at import time since the temp file may be gone later
+if "QUARTO_EXECUTE_INFO" in os.environ:
+ _get_quarto_pandoc_to()
+
+
+def is_quarto_typst_render() -> bool:
+ """
+ Check if the current Quarto render targets Typst output.
+
+ Reads the QUARTO_EXECUTE_INFO JSON file to determine the Pandoc target format.
+
+ Note: Quarto already translates CSS properties on HTML tables to Typst properties
+ automatically. Native Typst output produces cleaner, more idiomatic results.
+ """
+
+ if not is_quarto_render():
+ return False
+
+ return _get_quarto_pandoc_to() == "typst"
diff --git a/tests/__snapshots__/test_repr.ambr b/tests/__snapshots__/test_repr.ambr
index 649d5c4a0..a3e939356 100644
--- a/tests/__snapshots__/test_repr.ambr
+++ b/tests/__snapshots__/test_repr.ambr
@@ -72,85 +72,6 @@
-
-
-
-
- '''
-# ---
-# name: test_repr_html_default
- '''
-
-
-
-
-
-
- | x |
- y |
-
-
-
-
- | 1 |
- 4 |
-
-
- | 2 |
- 5 |
-
-
-
-
@@ -318,6 +239,85 @@
+
+
+
+
+ '''
+# ---
+# name: test_repr_html_quarto_html
+ '''
+
+
+
+
+
+
+ | x |
+ y |
+
+
+
+
+ | 1 |
+ 4 |
+
+
+ | 2 |
+ 5 |
+
+
+
+
diff --git a/tests/__snapshots__/test_utils_render_latex.ambr b/tests/__snapshots__/test_utils_render_latex.ambr
index 813970c3c..44f9824bc 100644
--- a/tests/__snapshots__/test_utils_render_latex.ambr
+++ b/tests/__snapshots__/test_utils_render_latex.ambr
@@ -1,37 +1,4 @@
# serializer version: 1
-# name: test_snap_render_as_latex
- '''
- \begingroup
- \setlength\LTleft{\dimexpr(0.5\linewidth - 225pt)}
- \setlength\LTright{\dimexpr(0.5\linewidth - 225pt)}
- \fontsize{9.0pt}{10.8pt}\selectfont
-
- \setlength{\LTpost}{0mm}
- \begin{longtable}{@{\extracolsep{\fill}}llrrr}
- \caption*{
- {\large The \_title\_} \\
- {\small The subtitle}
- } \\
- \toprule
- \multicolumn{2}{c}{Make \_and\_ Model} & \multicolumn{2}{c}{Performance} & \\
- \cmidrule(lr){1-2} \cmidrule(lr){3-4}
- mfr & model & hp & trq & msrp \\
- \midrule\addlinespace[2.5pt]
- Ford & GT & 647.0 & 550.0 & \$447,000.00 \\
- Ferrari & 458 Speciale & 597.0 & 398.0 & \$291,744.00 \\
- Ferrari & 458 Spider & 562.0 & 398.0 & \$263,553.00 \\
- Ferrari & 458 Italia & 562.0 & 398.0 & \$233,509.00 \\
- Ferrari & 488 GTB & 661.0 & 561.0 & \$245,400.00 \\
- \bottomrule
- \end{longtable}
- \begin{minipage}{\linewidth}
- Note 1\\
- Note 2\\
- \end{minipage}
- \endgroup
-
- '''
-# ---
# name: test_snap_render_as_latex_floating_table
'''
\begin{table}[!t]
diff --git a/tests/__snapshots__/test_utils_render_typst.ambr b/tests/__snapshots__/test_utils_render_typst.ambr
new file mode 100644
index 000000000..528d7492e
--- /dev/null
+++ b/tests/__snapshots__/test_utils_render_typst.ambr
@@ -0,0 +1,33 @@
+# serializer version: 1
+# name: TestAsTypstMethod.test_snap_as_typst
+ '''
+ #set text(font: ("Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Helvetica Neue", "Fira Sans", "Droid Sans", "Arial", "sans-serif",))
+ #set text(fill: rgb("#333333"))
+ #table(
+ columns: 5,
+ align: (left, left, right, right, right,),
+ stroke: (x: none, y: 0.75pt + rgb("#D3D3D3")),
+ inset: (x: 3.75pt, y: 6pt),
+ table.hline(stroke: 1.5pt + rgb("#A8A8A8")),
+ table.header(
+ table.cell(colspan: 5, align: center, inset: (x: 3.75pt, y: 3pt))[#text(size: 1.25em, weight: "bold")[The \_title\_] \ #text(size: 0.85em)[The subtitle]],
+ table.hline(stroke: 1.5pt + rgb("#D3D3D3")),
+ table.hline(stroke: 1.5pt + rgb("#D3D3D3")),
+ table.cell(colspan: 2, align: center)[Make and Model], table.cell(colspan: 2, align: center)[Performance], [],
+ table.hline(start: 0, end: 2, stroke: 0.75pt + rgb("#D3D3D3")),
+ table.hline(start: 2, end: 4, stroke: 0.75pt + rgb("#D3D3D3")),
+ [mfr], [model], [hp], [trq], [msrp],
+ ),
+ table.hline(stroke: 1.5pt + rgb("#D3D3D3")),
+ table.hline(stroke: 1.5pt + rgb("#D3D3D3")),
+ [Ford], [GT], [647.0], [550.0], [\$447,000.00],
+ [Ferrari], [458 Speciale], [597.0], [398.0], [\$291,744.00],
+ [Ferrari], [458 Spider], [562.0], [398.0], [\$263,553.00],
+ [Ferrari], [458 Italia], [562.0], [398.0], [\$233,509.00],
+ [Ferrari], [488 GTB], [661.0], [561.0], [\$245,400.00],
+ table.hline(stroke: 1.5pt + rgb("#D3D3D3")),
+ table.cell(colspan: 5, align: left, fill: rgb("#FFFFFF"), inset: (x: 3.75pt, y: 3pt))[#text(size: 0.90em)[Note 1] \ #text(size: 0.90em)[Note 2]],
+ table.hline(stroke: 1.5pt + rgb("#A8A8A8")),
+ )
+ '''
+# ---
diff --git a/tests/test_repr.py b/tests/test_repr.py
index 55a61bfbe..657ede8af 100644
--- a/tests/test_repr.py
+++ b/tests/test_repr.py
@@ -1,7 +1,9 @@
-import pytest
+import json
+import os
from unittest import mock
+
import pandas as pd
-import os
+import pytest
from great_tables import GT
from great_tables._render import infer_render_env
@@ -49,3 +51,56 @@ def test_repr_html_default(gt, snapshot):
assert infer_render_env() == "default"
assert_rendered_html_repr(snapshot, gt)
+
+
+def _make_quarto_execute_info(tmp_path, pandoc_to):
+ """Create a QUARTO_EXECUTE_INFO JSON file mimicking Quarto's execution context."""
+ info = {"format": {"pandoc": {"to": pandoc_to}}}
+ info_file = tmp_path / "execute-info.json"
+ info_file.write_text(json.dumps(info))
+ return str(info_file)
+
+
+def test_repr_html_quarto_typst(gt, tmp_path):
+ """When Quarto renders to Typst, _repr_html_ should emit a raw Typst block."""
+ import great_tables.quarto as quarto_mod
+
+ info_path = _make_quarto_execute_info(tmp_path, "typst")
+
+ # Reset the cached value so our test file is read
+ quarto_mod._quarto_pandoc_to = None
+
+ with mock.patch.dict(
+ os.environ,
+ {"QUARTO_BIN_PATH": "1", "QUARTO_EXECUTE_INFO": info_path},
+ clear=True,
+ ):
+ assert quarto_mod.is_quarto_typst_render()
+
+ bundle, _ = gt._repr_mimebundle_()
+ assert "text/markdown" in bundle
+ result = bundle["text/markdown"]
+ assert "```{=typst}" in result
+ assert "#table(" in result
+
+ # Clean up cache
+ quarto_mod._quarto_pandoc_to = None
+
+
+def test_repr_html_quarto_html(gt, snapshot, tmp_path):
+ """When Quarto renders to HTML, _repr_html_ should emit normal HTML."""
+ import great_tables.quarto as quarto_mod
+
+ info_path = _make_quarto_execute_info(tmp_path, "html")
+
+ quarto_mod._quarto_pandoc_to = None
+
+ with mock.patch.dict(
+ os.environ,
+ {"QUARTO_BIN_PATH": "1", "QUARTO_EXECUTE_INFO": info_path},
+ clear=True,
+ ):
+ assert not quarto_mod.is_quarto_typst_render()
+ assert_rendered_html_repr(snapshot, gt)
+
+ quarto_mod._quarto_pandoc_to = None
diff --git a/tests/test_utils_render_typst.py b/tests/test_utils_render_typst.py
new file mode 100644
index 000000000..205e65726
--- /dev/null
+++ b/tests/test_utils_render_typst.py
@@ -0,0 +1,747 @@
+import pytest
+import pandas as pd
+
+from great_tables import GT, exibble, loc, style
+from great_tables.data import gtcars
+from great_tables._text import (
+ _typst_escape,
+ _md_typst,
+ _process_text,
+ escape_pattern_str_typst,
+ Md,
+ Html,
+ Text,
+)
+from great_tables._utils_render_typst import (
+ create_table_start_typst,
+ create_heading_component_typst,
+ create_columns_component_typst,
+ create_body_component_typst,
+ create_footer_component_typst,
+ _render_as_typst,
+)
+
+
+# ---------------------------------------------------------------------------
+# Fixtures
+# ---------------------------------------------------------------------------
+
+
+@pytest.fixture
+def gt_tbl():
+ return GT(pd.DataFrame({"x": [1, 2], "y": [4, 5]}))
+
+
+@pytest.fixture
+def gt_tbl_dec():
+ return GT(pd.DataFrame({"x": [1.52, 2.23], "y": [4.75, 5.23]}))
+
+
+@pytest.fixture
+def gt_tbl_sci():
+ return GT(pd.DataFrame({"x": [465633.46, -0.00000000345], "y": [4.509, 176.23]}))
+
+
+@pytest.fixture
+def gt_tbl_pct():
+ return GT(pd.DataFrame({"x": [0.53, 0.0674], "y": [0.17, 0.32]}))
+
+
+@pytest.fixture
+def gt_tbl_dttm():
+ return GT(
+ pd.DataFrame(
+ {
+ "date": ["2023-08-12", "2020-11-17"],
+ "time": ["09:21:23", "22:45:02"],
+ "dttm": ["2023-08-12 09:21:23", "2020-11-17 22:45:02"],
+ }
+ )
+ )
+
+
+# ---------------------------------------------------------------------------
+# Text escaping tests
+# ---------------------------------------------------------------------------
+
+
+class TestTypstEscape:
+ def test_escape_hash(self):
+ assert _typst_escape("use #func") == "use \\#func"
+
+ def test_escape_dollar(self):
+ assert _typst_escape("$100") == "\\$100"
+
+ def test_escape_at(self):
+ assert _typst_escape("@ref") == "\\@ref"
+
+ def test_escape_angle_brackets(self):
+ assert _typst_escape("