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 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
+