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/__init__.py +40 -0
- texer/eval.py +310 -0
- texer/pgfplots.py +1381 -0
- texer/specs.py +513 -0
- texer/tables.py +338 -0
- texer/utils.py +311 -0
- texer-0.5.12.dist-info/METADATA +263 -0
- texer-0.5.12.dist-info/RECORD +11 -0
- texer-0.5.12.dist-info/WHEEL +5 -0
- texer-0.5.12.dist-info/licenses/LICENSE +21 -0
- texer-0.5.12.dist-info/top_level.txt +1 -0
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)
|