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 - ''' -
- - - - - - - - - - - - - - - - - - - - -
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..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("