From 915831ca1ecb39f5111e78a82f24371b283560d4 Mon Sep 17 00:00:00 2001 From: Malcolm Barrett Date: Thu, 26 Mar 2026 08:22:29 -0400 Subject: [PATCH 1/8] add typst support --- .gitignore | 1 + great_tables/__init__.py | 2 + great_tables/_export.py | 74 ++ great_tables/_formats.py | 92 ++- great_tables/_gt_data.py | 3 +- great_tables/_helpers.py | 26 +- great_tables/_styles.py | 65 ++ great_tables/_substitution.py | 51 +- great_tables/_text.py | 137 +++- great_tables/_utils.py | 11 +- great_tables/_utils_render_typst.py | 484 ++++++++++++ great_tables/gt.py | 29 +- great_tables/quarto.py | 51 ++ tests/__snapshots__/test_repr.ambr | 158 ++-- .../test_utils_render_latex.ambr | 33 - .../test_utils_render_typst.ambr | 30 + tests/test_repr.py | 59 +- tests/test_utils_render_typst.py | 727 ++++++++++++++++++ 18 files changed, 1901 insertions(+), 132 deletions(-) create mode 100644 great_tables/_utils_render_typst.py create mode 100644 tests/__snapshots__/test_utils_render_typst.ambr create mode 100644 tests/test_utils_render_typst.py 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..4ebe030fe 100644 --- a/great_tables/_styles.py +++ b/great_tables/_styles.py @@ -12,6 +12,17 @@ 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"): + return f'rgb("{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 +103,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 +187,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 +323,20 @@ 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: + parts.append(f'weight: "{self.weight}"') + if self.style: + parts.append(f'style: "{self.style}"') + if parts: + return {"text_style": ", ".join(parts)} + return {} + @dataclass class CellStyleFill(CellStyle): @@ -334,6 +369,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 +448,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..c4ef7976f --- /dev/null +++ b/great_tables/_utils_render_typst.py @@ -0,0 +1,484 @@ +from __future__ import annotations + +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]) + return f"{val * 0.75:.1f}pt" + if length.endswith("%"): + val = float(length[:-1]) + return f"{val}%" + # pt, cm, mm, in, em — Typst supports these directly + return length + + +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": + return None + typst_width = _css_length_to_typst(width) + typst_color = f'rgb("{color}")' if color.startswith("#") else color + 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})" + + parts = [f"#table(\n {columns_spec},\n {align_spec},\n stroke: none,\n {inset_spec},"] + + # Row striping + 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: + typst_color = "luma(244)" + parts.append(f" fill: (_, y) => if calc.odd(y) {{ {typst_color} }},") + + # 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}),") + + return "\n".join(parts) + + +def create_heading_component_typst(data: GTData) -> str: + """Create the Typst heading (title/subtitle) above the table.""" + + 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") + + parts = [f'#align(center, text(size: 14pt, weight: "bold")[{title_str}])'] + + if heading_has_subtitle(subtitle): + subtitle_str = _process_text(subtitle, context="typst") + parts.append(f"#align(center, text(size: 10pt)[{subtitle_str}])") + + 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: + spanner_cells.append( + f"table.cell(colspan: {span}, align: center)[{level_i_spanner_j}]" + ) + # 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) + + # Build header row with column labels + header_cells: list[str] = [] + if has_stub: + header_cells.append("[]") # blank cell for stub column + header_cells.extend(f"[*{label}*]" for label in headings_labels) + rows.append(" " + ", ".join(header_cells) + ",") + + # Column label borders + 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, + ) + + # Wrap in table.header() with border after + header_content = "\n".join(rows) + result = f" table.header(\n{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 "text_style" in styles: + # Inside [...] we're in markup mode, so need # prefix for code expressions + inner = f"[#text({styles['text_style']})[{content}]]" + else: + inner = f"[{content}]" + + if props: + # table.cell(fill: ..., stroke: ...)[content] + return f"table.cell({', '.join(props)}){inner}" + + if "text_style" in styles: + # Even without fill/stroke, text styling requires table.cell() wrapper + # because bare #text() in table args is code context where # is invalid + return f"table.cell(){inner}" + + 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.""" + 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 + ) + cells.append(_typst_styled_cell(f"*{summary_row.id}*", stub_styles)) + + # Data columns + for colinfo in column_vars: + cell_value = summary_row.values.get(colinfo.var) + cell_str = str(cell_value) if cell_value is not None else "" + cell_styles = _get_grand_summary_cell_styles_typst( + data, rownum=row_index, colname=colinfo.var, is_stub=False + ) + 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] = [] + + # 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(' table.hline(stroke: 0.75pt + rgb("#D3D3D3")),') + + ordered_index: list[tuple[int, GroupRowInfo | None]] = data._stub.group_indices_map() + + prev_group_info = None + stub_col = data._boxhead._get_stub_column() + + 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") + body_rows.append( + f" table.cell(colspan: {total_cols}, align: left)" f"[*{group_label}*]," + ) + + 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)) + body_cells.append(f"[*{row_label}*]") + 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) + ",") + + # Render bottom-side grand summary rows + bottom_g_summary_rows = data._summary_rows_grand.get_summary_rows(side="bottom") + if bottom_g_summary_rows: + body_rows.append(' table.hline(stroke: 0.75pt + rgb("#D3D3D3")),') + 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), + ) + ) + + return "\n".join(body_rows) + + +def create_footer_component_typst(data: GTData) -> str: + """Create the Typst footer with source notes.""" + + source_notes = data._source_notes + + if len(source_notes) == 0: + return "" + + source_notes_strs = [_process_text(x, context="typst") for x in source_notes] + + # Render source notes as text block below the table + notes_lines = [f"#text(size: 8pt)[{note}]" for note in source_notes_strs] + + return "\n".join(notes_lines) + + +def _render_as_typst(data: GTData) -> str: + """Render a GTData object as a Typst string.""" + + opts = data._options + + # Create heading above the table + heading_component = create_heading_component_typst(data=data) + + # Create table start with column specs + table_start = create_table_start_typst(data=data) + + # 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 (outside table) + footer_component = create_footer_component_typst(data=data) + + # 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 color and font size set rules + font_color = opts.table_font_color.value + if font_color: + parts.append(f'#set text(fill: rgb("{font_color}"))') + + table_font_size = opts.table_font_size.value + if table_font_size and table_font_size != "16px": + typst_size = _css_length_to_typst(table_font_size) + parts.append(f"#set text(size: {typst_size})") + + if heading_component: + parts.append(heading_component) + + # Table body with bottom border + table_content = f"{table_start}\n{columns_component}\n{body_component}{bottom_hline}\n)" + parts.append(table_content) + + if footer_component: + parts.append(footer_component) + + return "\n".join(parts) 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 - ''' -
- - - - - - - - - - - - - - - - - - - - -
xy
14
25
@@ -318,6 +239,85 @@ + + + + + ''' +# --- +# name: test_repr_html_quarto_html + ''' +
+ + + + + + + + + + + + + + + + + + + + +
xy
14
25
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..be11dc0d3 --- /dev/null +++ b/tests/__snapshots__/test_utils_render_typst.ambr @@ -0,0 +1,30 @@ +# serializer version: 1 +# name: TestAsTypstMethod.test_snap_as_typst + ''' + #set text(fill: rgb("#333333")) + #align(center, text(size: 14pt, weight: "bold")[The \_title\_]) + #align(center, text(size: 10pt)[The subtitle]) + #table( + columns: 5, + align: (left, left, right, right, right,), + stroke: none, + inset: (x: 3.8pt, y: 6.0pt), + table.hline(stroke: 1.5pt + rgb("#A8A8A8")), + table.header( + 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")), + [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("#A8A8A8")), + ) + #text(size: 8pt)[Note 1] + #text(size: 8pt)[Note 2] + ''' +# --- 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..338b0c39f --- /dev/null +++ b/tests/test_utils_render_typst.py @@ -0,0 +1,727 @@ +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("