texer 0.5.12__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
texer/tables.py ADDED
@@ -0,0 +1,338 @@
1
+ """Table classes for LaTeX table generation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any
7
+
8
+ from texer.specs import Spec, resolve_value, Iter
9
+ from texer.utils import escape_latex, format_options, indent
10
+
11
+
12
+ @dataclass
13
+ class Cell:
14
+ """A table cell with optional formatting.
15
+
16
+ Examples:
17
+ Cell(Ref("value"))
18
+ Cell(Ref("value"), bold=True)
19
+ Cell(Format(Ref("price"), ".2f"), align="r")
20
+ Cell(Ref("name"), bold=Cond(Ref("important"), True, False))
21
+ """
22
+
23
+ content: Any
24
+ bold: bool | Spec = False
25
+ italic: bool | Spec = False
26
+ align: str | None = None
27
+
28
+ def render(self, data: Any, scope: dict[str, Any] | None = None) -> str:
29
+ """Render the cell to LaTeX."""
30
+ from texer.eval import _evaluate_impl, evaluate_value
31
+
32
+ content = _evaluate_impl(self.content, data, scope or {}, escape=False)
33
+
34
+ # Resolve bold/italic if they are Specs
35
+ bold = evaluate_value(self.bold, data, scope) if isinstance(self.bold, Spec) else self.bold
36
+ italic = evaluate_value(self.italic, data, scope) if isinstance(self.italic, Spec) else self.italic
37
+
38
+ if bold:
39
+ content = f"\\textbf{{{content}}}"
40
+ if italic:
41
+ content = f"\\textit{{{content}}}"
42
+
43
+ return content
44
+
45
+
46
+ @dataclass
47
+ class MultiColumn:
48
+ """A cell spanning multiple columns.
49
+
50
+ Examples:
51
+ MultiColumn(3, "c", "Header Title")
52
+ """
53
+
54
+ ncols: int
55
+ align: str
56
+ content: Any
57
+
58
+ def render(self, data: Any, scope: dict[str, Any] | None = None) -> str:
59
+ """Render the multicolumn cell."""
60
+ from texer.eval import _evaluate_impl
61
+
62
+ content = _evaluate_impl(self.content, data, scope or {}, escape=False)
63
+ return f"\\multicolumn{{{self.ncols}}}{{{self.align}}}{{{content}}}"
64
+
65
+
66
+ @dataclass
67
+ class MultiRow:
68
+ """A cell spanning multiple rows.
69
+
70
+ Examples:
71
+ MultiRow(2, "Category")
72
+ """
73
+
74
+ nrows: int
75
+ content: Any
76
+ width: str = "*"
77
+
78
+ def render(self, data: Any, scope: dict[str, Any] | None = None) -> str:
79
+ """Render the multirow cell."""
80
+ from texer.eval import _evaluate_impl
81
+
82
+ content = _evaluate_impl(self.content, data, scope or {}, escape=False)
83
+ return f"\\multirow{{{self.nrows}}}{{{self.width}}}{{{content}}}"
84
+
85
+
86
+ @dataclass
87
+ class Row:
88
+ """A table row containing cells.
89
+
90
+ Examples:
91
+ Row("Name", "Value", "Unit")
92
+ Row(Ref("name"), Ref("value"), Ref("unit"))
93
+ Row(Cell(Ref("x"), bold=True), Ref("y"))
94
+ Row("A", "B", "C", end=r"\\[4pt]") # Extra vertical space
95
+ Row("A", "B", "C", end="") # No line ending
96
+ """
97
+
98
+ cells: tuple[Any, ...] = field(default_factory=tuple)
99
+ end: str = field(default=r"\\")
100
+ _raw_options: str | None = None
101
+
102
+ def __init__(self, *cells: Any, end: str = r"\\", _raw_options: str | None = None):
103
+ object.__setattr__(self, "cells", cells)
104
+ object.__setattr__(self, "end", end)
105
+ object.__setattr__(self, "_raw_options", _raw_options)
106
+
107
+ def render(self, data: Any, scope: dict[str, Any] | None = None) -> str:
108
+ """Render the row to LaTeX."""
109
+ from texer.eval import _evaluate_impl
110
+
111
+ rendered_cells = []
112
+ for cell in self.cells:
113
+ if isinstance(cell, (Cell, MultiColumn, MultiRow)):
114
+ rendered_cells.append(cell.render(data, scope))
115
+ else:
116
+ rendered_cells.append(_evaluate_impl(cell, data, scope or {}, escape=False))
117
+
118
+ row_content = " & ".join(rendered_cells)
119
+ if self.end:
120
+ return f"{row_content} {self.end}"
121
+ return row_content
122
+
123
+
124
+ @dataclass
125
+ class HLine:
126
+ """A horizontal line in a table."""
127
+
128
+ def render(self, data: Any, scope: dict[str, Any] | None = None) -> str:
129
+ return "\\hline"
130
+
131
+
132
+ @dataclass
133
+ class CLine:
134
+ """A partial horizontal line (cline)."""
135
+
136
+ start: int
137
+ end: int
138
+
139
+ def render(self, data: Any, scope: dict[str, Any] | None = None) -> str:
140
+ return f"\\cline{{{self.start}-{self.end}}}"
141
+
142
+
143
+ @dataclass
144
+ class Tabular:
145
+ """A LaTeX tabular environment.
146
+
147
+ Examples:
148
+ Tabular(
149
+ columns="lcc",
150
+ header=Row("Name", "Value 1", "Value 2"),
151
+ rows=Iter(Ref("data"), template=Row(Ref("name"), Ref("v1"), Ref("v2")))
152
+ )
153
+ """
154
+
155
+ columns: str
156
+ header: Row | list[Row] | None = None
157
+ rows: Any = None # Iter, list of Rows, or single Row
158
+ toprule: bool = False
159
+ midrule: bool = False
160
+ bottomrule: bool = False
161
+ _raw_options: str | None = None
162
+
163
+ def render(self, data: Any, scope: dict[str, Any] | None = None) -> str:
164
+ """Render the tabular environment."""
165
+ if scope is None:
166
+ scope = {}
167
+
168
+ lines = []
169
+
170
+ # Opening
171
+ lines.append(f"\\begin{{tabular}}{{{self.columns}}}")
172
+
173
+ # Top rule (booktabs)
174
+ if self.toprule:
175
+ lines.append(" \\toprule")
176
+
177
+ # Header
178
+ if self.header is not None:
179
+ if isinstance(self.header, list):
180
+ for h in self.header:
181
+ lines.append(f" {h.render(data, scope)}")
182
+ else:
183
+ lines.append(f" {self.header.render(data, scope)}")
184
+
185
+ # Mid rule after header
186
+ if self.midrule or self.toprule:
187
+ lines.append(" \\midrule")
188
+
189
+ # Body rows
190
+ if self.rows is not None:
191
+ rendered_rows = self._render_rows(data, scope)
192
+ for row in rendered_rows:
193
+ lines.append(f" {row}")
194
+
195
+ # Bottom rule
196
+ if self.bottomrule:
197
+ lines.append(" \\bottomrule")
198
+
199
+ # Closing
200
+ lines.append("\\end{tabular}")
201
+
202
+ return "\n".join(lines)
203
+
204
+ def _render_iter(self, iter_obj: Iter, data: Any, scope: dict[str, Any]) -> list[str]:
205
+ """Render an Iter object to a list of row strings."""
206
+ # Get the source items
207
+ if isinstance(iter_obj.source, str):
208
+ import glom # type: ignore[import-untyped]
209
+ items = glom.glom(data, iter_obj.source)
210
+ else:
211
+ items = iter_obj.source.resolve(data, scope)
212
+
213
+ if iter_obj.template is not None:
214
+ # Render the template for each item
215
+ results = []
216
+ for item in items:
217
+ if isinstance(iter_obj.template, Row):
218
+ results.append(iter_obj.template.render(item, scope))
219
+ elif hasattr(iter_obj.template, "render"):
220
+ results.append(iter_obj.template.render(item, scope))
221
+ else:
222
+ from texer.eval import _evaluate_impl
223
+ results.append(_evaluate_impl(iter_obj.template, item, scope, escape=False))
224
+ return results
225
+ else:
226
+ # No template, just resolve
227
+ return [str(item) for item in items]
228
+
229
+ def _render_rows(self, data: Any, scope: dict[str, Any]) -> list[str]:
230
+ """Render the body rows."""
231
+ if isinstance(self.rows, Iter):
232
+ return self._render_iter(self.rows, data, scope)
233
+ elif isinstance(self.rows, list):
234
+ results = []
235
+ for row in self.rows:
236
+ if isinstance(row, Iter):
237
+ results.extend(self._render_iter(row, data, scope))
238
+ else:
239
+ results.append(row.render(data, scope))
240
+ return results
241
+ elif isinstance(self.rows, Row):
242
+ return [self.rows.render(data, scope)]
243
+ elif isinstance(self.rows, Spec):
244
+ resolved = self.rows.resolve(data, scope)
245
+ if isinstance(resolved, list):
246
+ return [str(r) for r in resolved]
247
+ return [str(resolved)]
248
+ return []
249
+
250
+
251
+ @dataclass
252
+ class Table:
253
+ """A LaTeX table environment (floating).
254
+
255
+ Examples:
256
+ Table(
257
+ Tabular(...),
258
+ caption="My Table",
259
+ label="tab:mytable",
260
+ position="htbp"
261
+ )
262
+ """
263
+
264
+ content: Tabular
265
+ caption: Any = None
266
+ label: str | None = None
267
+ position: str = "htbp"
268
+ centering: bool = True
269
+ _raw_options: str | None = None
270
+
271
+ def render(self, data: Any, scope: dict[str, Any] | None = None) -> str:
272
+ """Render the table environment."""
273
+ if scope is None:
274
+ scope = {}
275
+
276
+ lines = []
277
+
278
+ # Opening
279
+ lines.append(f"\\begin{{table}}[{self.position}]")
280
+
281
+ if self.centering:
282
+ lines.append(" \\centering")
283
+
284
+ # Caption (before table for some styles)
285
+ if self.caption is not None:
286
+ from texer.eval import _evaluate_impl
287
+ caption_text = _evaluate_impl(self.caption, data, scope, escape=False)
288
+ lines.append(f" \\caption{{{caption_text}}}")
289
+
290
+ # Label
291
+ if self.label is not None:
292
+ lines.append(f" \\label{{{self.label}}}")
293
+
294
+ # Tabular content
295
+ tabular_lines = self.content.render(data, scope)
296
+ for line in tabular_lines.split("\n"):
297
+ lines.append(f" {line}" if line else line)
298
+
299
+ # Closing
300
+ lines.append("\\end{table}")
301
+
302
+ return "\n".join(lines)
303
+
304
+
305
+ # Convenience function for simple tables
306
+ def simple_table(
307
+ headers: list[str],
308
+ rows: list[list[Any]],
309
+ caption: str | None = None,
310
+ label: str | None = None,
311
+ ) -> Table:
312
+ """Create a simple table from headers and row data.
313
+
314
+ Args:
315
+ headers: List of column headers.
316
+ rows: List of row data (each row is a list of values).
317
+ caption: Optional table caption.
318
+ label: Optional label for referencing.
319
+
320
+ Returns:
321
+ A Table object ready for rendering.
322
+ """
323
+ ncols = len(headers)
324
+ columns = "l" + "c" * (ncols - 1) # First column left, rest centered
325
+
326
+ header_row = Row(*headers)
327
+ data_rows = [Row(*row) for row in rows]
328
+
329
+ tabular = Tabular(
330
+ columns=columns,
331
+ header=header_row,
332
+ rows=data_rows,
333
+ toprule=True,
334
+ midrule=True,
335
+ bottomrule=True,
336
+ )
337
+
338
+ return Table(tabular, caption=caption, label=label)
texer/utils.py ADDED
@@ -0,0 +1,311 @@
1
+ """Utility functions for LaTeX generation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import Any
7
+
8
+
9
+ # Regex pattern for hex color codes (with or without #)
10
+ HEX_COLOR_PATTERN = re.compile(r"^#?([0-9A-Fa-f]{6})$")
11
+
12
+
13
+ def hex_to_pgf_rgb(color: str) -> str:
14
+ """Convert a hex color code to PGF/TikZ RGB format.
15
+
16
+ Args:
17
+ color: A hex color code like "#5D8AA8" or "5D8AA8".
18
+
19
+ Returns:
20
+ The color in PGF format: "{rgb,255:red,93; green,138; blue,168}"
21
+
22
+ Raises:
23
+ ValueError: If the color is not a valid 6-character hex code.
24
+
25
+ Examples:
26
+ >>> hex_to_pgf_rgb("#5D8AA8")
27
+ '{rgb,255:red,93; green,138; blue,168}'
28
+ >>> hex_to_pgf_rgb("#FF0000")
29
+ '{rgb,255:red,255; green,0; blue,0}'
30
+ >>> hex_to_pgf_rgb("00FF00")
31
+ '{rgb,255:red,0; green,255; blue,0}'
32
+ """
33
+ match = HEX_COLOR_PATTERN.match(color)
34
+ if not match:
35
+ raise ValueError(
36
+ f"Invalid hex color code: {color!r}. "
37
+ "Expected format: '#RRGGBB' or 'RRGGBB'"
38
+ )
39
+
40
+ hex_str = match.group(1)
41
+ red = int(hex_str[0:2], 16)
42
+ green = int(hex_str[2:4], 16)
43
+ blue = int(hex_str[4:6], 16)
44
+
45
+ return f"{{rgb,255:red,{red}; green,{green}; blue,{blue}}}"
46
+
47
+
48
+ def is_hex_color(color: str) -> bool:
49
+ """Check if a string is a valid hex color code.
50
+
51
+ Args:
52
+ color: A string to check.
53
+
54
+ Returns:
55
+ True if the string is a valid hex color code (with or without #).
56
+
57
+ Examples:
58
+ >>> is_hex_color("#5D8AA8")
59
+ True
60
+ >>> is_hex_color("FF0000")
61
+ True
62
+ >>> is_hex_color("blue")
63
+ False
64
+ >>> is_hex_color("#GGG")
65
+ False
66
+ """
67
+ return HEX_COLOR_PATTERN.match(color) is not None
68
+
69
+ # Characters that need escaping in LaTeX
70
+ LATEX_SPECIAL_CHARS = {
71
+ "&": r"\&",
72
+ "%": r"\%",
73
+ "$": r"\$",
74
+ "#": r"\#",
75
+ "_": r"\_",
76
+ "{": r"\{",
77
+ "}": r"\}",
78
+ "~": r"\textasciitilde{}",
79
+ "^": r"\textasciicircum{}",
80
+ "\\": r"\textbackslash{}",
81
+ }
82
+
83
+ # Regex pattern for special characters
84
+ LATEX_ESCAPE_PATTERN = re.compile(
85
+ "|".join(re.escape(char) for char in LATEX_SPECIAL_CHARS.keys())
86
+ )
87
+
88
+
89
+ def escape_latex(text: str) -> str:
90
+ """Escape special LaTeX characters in text.
91
+
92
+ Args:
93
+ text: The text to escape.
94
+
95
+ Returns:
96
+ The escaped text safe for LaTeX.
97
+
98
+ Examples:
99
+ >>> escape_latex("10% off")
100
+ '10\\% off'
101
+ >>> escape_latex("$100")
102
+ '\\$100'
103
+ """
104
+ return LATEX_ESCAPE_PATTERN.sub(
105
+ lambda m: LATEX_SPECIAL_CHARS[m.group()], text
106
+ )
107
+
108
+
109
+ def format_option_value(value: Any) -> str:
110
+ """Format a Python value for use in LaTeX options.
111
+
112
+ Args:
113
+ value: The value to format.
114
+
115
+ Returns:
116
+ LaTeX-formatted string.
117
+
118
+ Examples:
119
+ >>> format_option_value("north west")
120
+ '{north west}'
121
+ >>> format_option_value(True)
122
+ 'true'
123
+ >>> format_option_value(3.14)
124
+ '3.14'
125
+ """
126
+ if value is True:
127
+ return "true"
128
+ if value is False:
129
+ return "false"
130
+ if value is None:
131
+ return ""
132
+ if isinstance(value, (int, float)):
133
+ return str(value)
134
+ # Strings get wrapped in braces if they contain spaces or special chars
135
+ s = str(value)
136
+ # Don't double-wrap if already wrapped in braces
137
+ if s.startswith("{") and s.endswith("}"):
138
+ return s
139
+ if " " in s or any(c in s for c in ",=[]"):
140
+ return f"{{{s}}}"
141
+ return s
142
+
143
+
144
+ def format_options(options: dict[str, Any], raw_options: str | None = None) -> str:
145
+ """Format a dictionary of options for LaTeX.
146
+
147
+ Args:
148
+ options: Dictionary of option key-value pairs.
149
+ raw_options: Raw LaTeX options string to append.
150
+
151
+ Returns:
152
+ Formatted options string (without surrounding brackets).
153
+
154
+ Examples:
155
+ >>> format_options({"xlabel": "Time", "ylabel": "Value"})
156
+ 'xlabel={Time}, ylabel={Value}'
157
+ """
158
+ parts = []
159
+ for key, value in options.items():
160
+ if value is None:
161
+ continue
162
+ if value is True:
163
+ # Boolean true options are just the key
164
+ parts.append(key)
165
+ elif value is False:
166
+ continue # Skip false options
167
+ else:
168
+ formatted = format_option_value(value)
169
+ # Convert Python-style names to LaTeX style (legend_pos -> legend pos)
170
+ latex_key = key.replace("_", " ")
171
+ parts.append(f"{latex_key}={formatted}")
172
+
173
+ if raw_options:
174
+ parts.append(raw_options)
175
+
176
+ return ", ".join(parts)
177
+
178
+
179
+ def indent(text: str, spaces: int = 2) -> str:
180
+ """Indent each line of text.
181
+
182
+ Args:
183
+ text: The text to indent.
184
+ spaces: Number of spaces to indent.
185
+
186
+ Returns:
187
+ Indented text.
188
+ """
189
+ prefix = " " * spaces
190
+ return "\n".join(prefix + line if line else line for line in text.split("\n"))
191
+
192
+
193
+ def wrap_environment(name: str, content: str, options: str = "") -> str:
194
+ """Wrap content in a LaTeX environment.
195
+
196
+ Args:
197
+ name: Environment name.
198
+ content: Content to wrap.
199
+ options: Optional options string.
200
+
201
+ Returns:
202
+ Complete environment string.
203
+ """
204
+ if options:
205
+ begin = f"\\begin{{{name}}}[{options}]"
206
+ else:
207
+ begin = f"\\begin{{{name}}}"
208
+ end = f"\\end{{{name}}}"
209
+ return f"{begin}\n{indent(content)}\n{end}"
210
+
211
+
212
+ def _single_cmidrule(
213
+ start: int,
214
+ end: int,
215
+ trim_left: str | bool = False,
216
+ trim_right: str | bool = False,
217
+ ) -> str:
218
+ """Generate a single \\cmidrule command."""
219
+ trim = ""
220
+ if trim_left or trim_right:
221
+ left_part = ""
222
+ right_part = ""
223
+ if trim_left is True:
224
+ left_part = "l"
225
+ elif trim_left:
226
+ left_part = f"l{{{trim_left}}}"
227
+ if trim_right is True:
228
+ right_part = "r"
229
+ elif trim_right:
230
+ right_part = f"r{{{trim_right}}}"
231
+ trim = f"({left_part}{right_part})"
232
+
233
+ return f"\\cmidrule{trim}{{{start}-{end}}}"
234
+
235
+
236
+ def cmidrule(
237
+ start: int | list[tuple[int, int]],
238
+ end: int | None = None,
239
+ trim_left: str | bool = False,
240
+ trim_right: str | bool = False,
241
+ trim_between: bool = False,
242
+ ) -> str:
243
+ """Generate \\cmidrule command(s) from the booktabs package.
244
+
245
+ Can generate a single cmidrule or multiple cmidrules from a list of ranges.
246
+
247
+ Args:
248
+ start: Either:
249
+ - Starting column number (1-indexed) for a single rule, OR
250
+ - List of (start, end) tuples for multiple rules
251
+ end: Ending column number (1-indexed). Required if start is an int.
252
+ trim_left: Left trim specification. Can be:
253
+ - False: no left trim
254
+ - True: default left trim ("l")
255
+ - str: custom trim width (e.g., "0.5em")
256
+ trim_right: Right trim specification. Can be:
257
+ - False: no right trim
258
+ - True: default right trim ("r")
259
+ - str: custom trim width (e.g., "0.5em")
260
+ trim_between: If True and multiple ranges given, automatically add
261
+ trim_right to all but the last rule and trim_left to all but
262
+ the first rule, creating gaps between adjacent rules.
263
+
264
+ Returns:
265
+ The \\cmidrule command string(s), space-separated if multiple.
266
+
267
+ Examples:
268
+ >>> cmidrule(1, 3)
269
+ '\\\\cmidrule{1-3}'
270
+ >>> cmidrule(2, 4, trim_left=True, trim_right=True)
271
+ '\\\\cmidrule(lr){2-4}'
272
+ >>> cmidrule([(2, 4), (5, 7)])
273
+ '\\\\cmidrule{2-4} \\\\cmidrule{5-7}'
274
+ >>> cmidrule([(2, 4), (5, 7)], trim_between=True)
275
+ '\\\\cmidrule(r){2-4} \\\\cmidrule(l){5-7}'
276
+ >>> cmidrule([(1, 2), (3, 4), (5, 6)], trim_between=True)
277
+ '\\\\cmidrule(r){1-2} \\\\cmidrule(lr){3-4} \\\\cmidrule(l){5-6}'
278
+ """
279
+ # Handle list of ranges
280
+ if isinstance(start, list):
281
+ ranges = start
282
+ if not ranges:
283
+ return ""
284
+
285
+ results = []
286
+ for i, (s, e) in enumerate(ranges):
287
+ # Determine trim for this rule
288
+ left = trim_left
289
+ right = trim_right
290
+
291
+ if trim_between and len(ranges) > 1:
292
+ # First rule: no left trim (unless specified), add right trim
293
+ # Middle rules: add both trims
294
+ # Last rule: add left trim, no right trim (unless specified)
295
+ if i == 0:
296
+ right = right or True
297
+ elif i == len(ranges) - 1:
298
+ left = left or True
299
+ else:
300
+ left = left or True
301
+ right = right or True
302
+
303
+ results.append(_single_cmidrule(s, e, left, right))
304
+
305
+ return " ".join(results)
306
+
307
+ # Single range (original API)
308
+ if end is None:
309
+ raise ValueError("end is required when start is an integer")
310
+
311
+ return _single_cmidrule(start, end, trim_left, trim_right)