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/__init__.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Texer: Generate LaTeX tables and figures with glom-style specs."""
|
|
2
|
+
|
|
3
|
+
from texer.specs import Ref, Iter, Format, FormatNumber, Cond, Literal, Raw, Spec
|
|
4
|
+
from texer.tables import Table, Tabular, Row, Cell, MultiColumn, MultiRow
|
|
5
|
+
from texer.pgfplots import PGFPlot, Axis, AddPlot, Coordinates, Legend, GroupPlot, NextGroupPlot, scatter_plot
|
|
6
|
+
from texer.eval import evaluate
|
|
7
|
+
from texer.utils import cmidrule
|
|
8
|
+
|
|
9
|
+
__version__ = "0.2.0"
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
# Specs
|
|
13
|
+
"Ref",
|
|
14
|
+
"Iter",
|
|
15
|
+
"Format",
|
|
16
|
+
"FormatNumber",
|
|
17
|
+
"Cond",
|
|
18
|
+
"Literal",
|
|
19
|
+
"Raw",
|
|
20
|
+
"Spec",
|
|
21
|
+
# Tables
|
|
22
|
+
"Table",
|
|
23
|
+
"Tabular",
|
|
24
|
+
"Row",
|
|
25
|
+
"Cell",
|
|
26
|
+
"MultiColumn",
|
|
27
|
+
"MultiRow",
|
|
28
|
+
"cmidrule",
|
|
29
|
+
# PGFPlots
|
|
30
|
+
"PGFPlot",
|
|
31
|
+
"Axis",
|
|
32
|
+
"AddPlot",
|
|
33
|
+
"Coordinates",
|
|
34
|
+
"Legend",
|
|
35
|
+
"GroupPlot",
|
|
36
|
+
"NextGroupPlot",
|
|
37
|
+
"scatter_plot",
|
|
38
|
+
# Evaluation
|
|
39
|
+
"evaluate",
|
|
40
|
+
]
|
texer/eval.py
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"""Evaluation engine for texer specs and LaTeX elements."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Any, Protocol, runtime_checkable
|
|
8
|
+
|
|
9
|
+
from texer.specs import Spec, resolve_value, Raw
|
|
10
|
+
from texer.utils import escape_latex
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _get_git_sha() -> str | None:
|
|
14
|
+
"""Get the full SHA of the current git commit.
|
|
15
|
+
|
|
16
|
+
Returns:
|
|
17
|
+
Full git SHA string, or None if not in a git repo or git unavailable.
|
|
18
|
+
"""
|
|
19
|
+
try:
|
|
20
|
+
result = subprocess.run(
|
|
21
|
+
["git", "rev-parse", "HEAD"],
|
|
22
|
+
capture_output=True,
|
|
23
|
+
text=True,
|
|
24
|
+
check=True,
|
|
25
|
+
)
|
|
26
|
+
return result.stdout.strip()
|
|
27
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _get_version() -> str:
|
|
32
|
+
"""Get the texer version.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
Version string.
|
|
36
|
+
"""
|
|
37
|
+
from texer import __version__
|
|
38
|
+
|
|
39
|
+
return __version__
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _generate_header() -> str:
|
|
43
|
+
"""Generate a LaTeX comment header with version, creation date, and git SHA.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
LaTeX comment string with metadata.
|
|
47
|
+
"""
|
|
48
|
+
version = _get_version()
|
|
49
|
+
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
50
|
+
git_sha = _get_git_sha()
|
|
51
|
+
|
|
52
|
+
lines = [f"% Generated by texer v{version} on {timestamp}"]
|
|
53
|
+
if git_sha:
|
|
54
|
+
lines.append(f"% Git commit: {git_sha}")
|
|
55
|
+
|
|
56
|
+
return "\n".join(lines) + "\n"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@runtime_checkable
|
|
60
|
+
class Renderable(Protocol):
|
|
61
|
+
"""Protocol for objects that can render to LaTeX."""
|
|
62
|
+
|
|
63
|
+
def render(self, data: Any, scope: dict[str, Any] | None = None) -> str:
|
|
64
|
+
"""Render this object to LaTeX string."""
|
|
65
|
+
...
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def evaluate(
|
|
69
|
+
element: Any,
|
|
70
|
+
data: Any | None = None,
|
|
71
|
+
scope: dict[str, Any] | None = None,
|
|
72
|
+
escape: bool = True,
|
|
73
|
+
header: bool = True,
|
|
74
|
+
output_file: str | None = None,
|
|
75
|
+
with_preamble: bool = False,
|
|
76
|
+
compile: bool = False,
|
|
77
|
+
output_dir: str | None = None,
|
|
78
|
+
) -> str:
|
|
79
|
+
"""Evaluate an element and return LaTeX string.
|
|
80
|
+
|
|
81
|
+
This is the main entry point for converting texer elements to LaTeX.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
element: The element to evaluate (Spec, Renderable, or plain value).
|
|
85
|
+
data: The data context for resolving specs.
|
|
86
|
+
scope: Additional scope variables.
|
|
87
|
+
escape: Whether to escape LaTeX special characters in strings.
|
|
88
|
+
header: Whether to include a header comment with creation date and git SHA.
|
|
89
|
+
output_file: Optional path to save the LaTeX output to a file.
|
|
90
|
+
with_preamble: Whether to include document preamble for standalone compilation.
|
|
91
|
+
compile: Whether to compile the output to PDF using pdflatex (requires output_file).
|
|
92
|
+
output_dir: Optional output directory for PDF compilation (default: same as output_file).
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
LaTeX string representation (or path to PDF if compile=True).
|
|
96
|
+
|
|
97
|
+
Raises:
|
|
98
|
+
RuntimeError: If compile=True and pdflatex is not available or compilation fails.
|
|
99
|
+
ValueError: If compile=True but output_file is not specified.
|
|
100
|
+
|
|
101
|
+
Examples:
|
|
102
|
+
>>> from texer import Ref, Table, Tabular, Row
|
|
103
|
+
>>> data = {"name": "Alice", "value": 42}
|
|
104
|
+
>>> evaluate(Ref("name"), data)
|
|
105
|
+
'Alice'
|
|
106
|
+
|
|
107
|
+
# Save to file with preamble
|
|
108
|
+
>>> evaluate(table, output_file="my_table.tex", with_preamble=True)
|
|
109
|
+
|
|
110
|
+
# Save and compile to PDF
|
|
111
|
+
>>> pdf_path = evaluate(table, output_file="my_table.tex", with_preamble=True, compile=True)
|
|
112
|
+
"""
|
|
113
|
+
import shutil
|
|
114
|
+
from pathlib import Path
|
|
115
|
+
|
|
116
|
+
if compile and output_file is None:
|
|
117
|
+
raise ValueError("output_file is required when compile=True")
|
|
118
|
+
|
|
119
|
+
if compile and not with_preamble:
|
|
120
|
+
# Automatically enable preamble when compiling
|
|
121
|
+
with_preamble = True
|
|
122
|
+
|
|
123
|
+
if data is None:
|
|
124
|
+
data = {}
|
|
125
|
+
|
|
126
|
+
if scope is None:
|
|
127
|
+
scope = {}
|
|
128
|
+
|
|
129
|
+
if with_preamble:
|
|
130
|
+
# For elements with with_preamble method (like PGFPlot), use it
|
|
131
|
+
if hasattr(element, "with_preamble"):
|
|
132
|
+
result: str = element.with_preamble(data)
|
|
133
|
+
else:
|
|
134
|
+
# Render content and wrap with preamble
|
|
135
|
+
content = _evaluate_impl(element, data, scope, escape)
|
|
136
|
+
result = _wrap_with_preamble(element, content, data)
|
|
137
|
+
else:
|
|
138
|
+
result = _evaluate_impl(element, data, scope, escape)
|
|
139
|
+
|
|
140
|
+
if header:
|
|
141
|
+
result = _generate_header() + result
|
|
142
|
+
|
|
143
|
+
if output_file is not None:
|
|
144
|
+
with open(output_file, "w", encoding="utf-8") as f:
|
|
145
|
+
f.write(result)
|
|
146
|
+
|
|
147
|
+
if compile:
|
|
148
|
+
# Check if pdflatex is available
|
|
149
|
+
if shutil.which("pdflatex") is None:
|
|
150
|
+
raise RuntimeError(
|
|
151
|
+
"pdflatex not found. Please install a LaTeX distribution (e.g., TeX Live, MiKTeX)."
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
# Determine paths
|
|
155
|
+
tex_path = Path(output_file).resolve() # type: ignore[arg-type]
|
|
156
|
+
compile_output_path: Path
|
|
157
|
+
if output_dir is None:
|
|
158
|
+
compile_output_path = tex_path.parent
|
|
159
|
+
else:
|
|
160
|
+
compile_output_path = Path(output_dir).resolve()
|
|
161
|
+
|
|
162
|
+
# Run pdflatex
|
|
163
|
+
try:
|
|
164
|
+
subprocess.run(
|
|
165
|
+
[
|
|
166
|
+
"pdflatex",
|
|
167
|
+
"-interaction=nonstopmode",
|
|
168
|
+
f"-output-directory={compile_output_path}",
|
|
169
|
+
str(tex_path),
|
|
170
|
+
],
|
|
171
|
+
capture_output=True,
|
|
172
|
+
text=True,
|
|
173
|
+
check=True,
|
|
174
|
+
)
|
|
175
|
+
except subprocess.CalledProcessError as e:
|
|
176
|
+
raise RuntimeError(
|
|
177
|
+
f"pdflatex compilation failed:\n{e.stderr}\n\nOutput:\n{e.stdout}"
|
|
178
|
+
) from e
|
|
179
|
+
|
|
180
|
+
# Return path to PDF
|
|
181
|
+
pdf_path = compile_output_path / tex_path.with_suffix(".pdf").name
|
|
182
|
+
return str(pdf_path)
|
|
183
|
+
|
|
184
|
+
return result
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _evaluate_impl(
|
|
188
|
+
element: Any,
|
|
189
|
+
data: Any,
|
|
190
|
+
scope: dict[str, Any],
|
|
191
|
+
escape: bool,
|
|
192
|
+
) -> str:
|
|
193
|
+
"""Internal implementation of evaluate."""
|
|
194
|
+
# Handle None
|
|
195
|
+
if element is None:
|
|
196
|
+
return ""
|
|
197
|
+
|
|
198
|
+
# Handle Raw specs (don't escape)
|
|
199
|
+
if isinstance(element, Raw):
|
|
200
|
+
return element.resolve(data, scope)
|
|
201
|
+
|
|
202
|
+
# Handle Specs
|
|
203
|
+
if isinstance(element, Spec):
|
|
204
|
+
resolved = element.resolve(data, scope)
|
|
205
|
+
return _evaluate_impl(resolved, data, scope, escape)
|
|
206
|
+
|
|
207
|
+
# Handle Renderables (Table, Tabular, Row, etc.)
|
|
208
|
+
if isinstance(element, Renderable):
|
|
209
|
+
return element.render(data, scope)
|
|
210
|
+
|
|
211
|
+
# Handle lists/tuples
|
|
212
|
+
if isinstance(element, (list, tuple)):
|
|
213
|
+
return "".join(_evaluate_impl(item, data, scope, escape) for item in element)
|
|
214
|
+
|
|
215
|
+
# Handle plain values
|
|
216
|
+
result = str(element)
|
|
217
|
+
if escape:
|
|
218
|
+
return escape_latex(result)
|
|
219
|
+
return result
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def evaluate_value(
|
|
223
|
+
value: Any,
|
|
224
|
+
data: Any,
|
|
225
|
+
scope: dict[str, Any] | None = None,
|
|
226
|
+
) -> Any:
|
|
227
|
+
"""Evaluate a value without converting to string.
|
|
228
|
+
|
|
229
|
+
This resolves Specs but doesn't convert to LaTeX string.
|
|
230
|
+
Useful for getting raw values (lists, numbers, etc.)
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
value: The value to evaluate.
|
|
234
|
+
data: The data context.
|
|
235
|
+
scope: Additional scope variables.
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
The resolved value (may be any type).
|
|
239
|
+
"""
|
|
240
|
+
if scope is None:
|
|
241
|
+
scope = {}
|
|
242
|
+
|
|
243
|
+
return resolve_value(value, data, scope)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _get_preamble(element: Any) -> list[str]:
|
|
247
|
+
"""Get the appropriate preamble for an element type.
|
|
248
|
+
|
|
249
|
+
Args:
|
|
250
|
+
element: The element to get preamble for.
|
|
251
|
+
|
|
252
|
+
Returns:
|
|
253
|
+
List of preamble lines.
|
|
254
|
+
"""
|
|
255
|
+
# Check if element has a with_preamble method (like PGFPlot)
|
|
256
|
+
if hasattr(element, "with_preamble"):
|
|
257
|
+
# PGFPlot handles its own preamble
|
|
258
|
+
return []
|
|
259
|
+
|
|
260
|
+
# Check for table-related imports
|
|
261
|
+
from texer.tables import Table, Tabular
|
|
262
|
+
|
|
263
|
+
if isinstance(element, Table):
|
|
264
|
+
# Table uses floating environment, needs article class
|
|
265
|
+
return [
|
|
266
|
+
"\\documentclass{article}",
|
|
267
|
+
"\\usepackage{booktabs}",
|
|
268
|
+
"\\pagestyle{empty}",
|
|
269
|
+
"",
|
|
270
|
+
"\\begin{document}",
|
|
271
|
+
]
|
|
272
|
+
elif isinstance(element, Tabular):
|
|
273
|
+
# Tabular is non-floating, can use standalone
|
|
274
|
+
return [
|
|
275
|
+
"\\documentclass{standalone}",
|
|
276
|
+
"\\usepackage{booktabs}",
|
|
277
|
+
"",
|
|
278
|
+
"\\begin{document}",
|
|
279
|
+
]
|
|
280
|
+
|
|
281
|
+
# Default preamble for unknown elements
|
|
282
|
+
return [
|
|
283
|
+
"\\documentclass{standalone}",
|
|
284
|
+
"",
|
|
285
|
+
"\\begin{document}",
|
|
286
|
+
]
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _wrap_with_preamble(element: Any, content: str, data: Any) -> str:
|
|
290
|
+
"""Wrap content with appropriate preamble.
|
|
291
|
+
|
|
292
|
+
Args:
|
|
293
|
+
element: The original element (for type detection).
|
|
294
|
+
content: The rendered LaTeX content.
|
|
295
|
+
data: Data dict for rendering.
|
|
296
|
+
|
|
297
|
+
Returns:
|
|
298
|
+
Complete LaTeX document string.
|
|
299
|
+
"""
|
|
300
|
+
# If element has with_preamble, use it directly
|
|
301
|
+
if hasattr(element, "with_preamble"):
|
|
302
|
+
result: str = element.with_preamble(data)
|
|
303
|
+
return result
|
|
304
|
+
|
|
305
|
+
preamble = _get_preamble(element)
|
|
306
|
+
closing = ["\\end{document}"]
|
|
307
|
+
|
|
308
|
+
return "\n".join(preamble + [content] + closing)
|
|
309
|
+
|
|
310
|
+
|