vectex 0.1.0__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.
- vectex/__init__.py +63 -0
- vectex/adapters.py +39 -0
- vectex/api.py +578 -0
- vectex/cache.py +106 -0
- vectex/cli.py +213 -0
- vectex/compiler.py +314 -0
- vectex/converter.py +121 -0
- vectex/exceptions.py +76 -0
- vectex/fragment.py +91 -0
- vectex/normalizer.py +526 -0
- vectex/process.py +93 -0
- vectex-0.1.0.dist-info/METADATA +332 -0
- vectex-0.1.0.dist-info/RECORD +16 -0
- vectex-0.1.0.dist-info/WHEEL +4 -0
- vectex-0.1.0.dist-info/entry_points.txt +2 -0
- vectex-0.1.0.dist-info/licenses/LICENSE +21 -0
vectex/__init__.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Render LaTeX into portable SVG group fragments."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
from .api import RenderItem, clear_cache, render, render_many
|
|
6
|
+
from .compiler import (
|
|
7
|
+
CompilationResult,
|
|
8
|
+
Compiler,
|
|
9
|
+
CompileRequest,
|
|
10
|
+
TeXCompiler,
|
|
11
|
+
)
|
|
12
|
+
from .converter import (
|
|
13
|
+
ConversionResult,
|
|
14
|
+
Converter,
|
|
15
|
+
ConvertRequest,
|
|
16
|
+
DvisvgmConverter,
|
|
17
|
+
)
|
|
18
|
+
from .exceptions import (
|
|
19
|
+
CompilationError,
|
|
20
|
+
ConfigurationError,
|
|
21
|
+
ConversionError,
|
|
22
|
+
InvalidSVGError,
|
|
23
|
+
MissingExecutableError,
|
|
24
|
+
UnsafeSVGError,
|
|
25
|
+
UnsupportedBackendError,
|
|
26
|
+
VectexError,
|
|
27
|
+
)
|
|
28
|
+
from .fragment import VectexFragment
|
|
29
|
+
from .normalizer import METADATA_VERSION, TEXTEXT_NS, VECTEX_NS, Normalizer
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"METADATA_VERSION",
|
|
33
|
+
"TEXTEXT_NS",
|
|
34
|
+
"VECTEX_NS",
|
|
35
|
+
"CompilationError",
|
|
36
|
+
"CompilationResult",
|
|
37
|
+
"CompileRequest",
|
|
38
|
+
"Compiler",
|
|
39
|
+
"ConfigurationError",
|
|
40
|
+
"ConversionError",
|
|
41
|
+
"ConversionResult",
|
|
42
|
+
"ConvertRequest",
|
|
43
|
+
"Converter",
|
|
44
|
+
"DvisvgmConverter",
|
|
45
|
+
"InvalidSVGError",
|
|
46
|
+
"MissingExecutableError",
|
|
47
|
+
"Normalizer",
|
|
48
|
+
"RenderItem",
|
|
49
|
+
"TeXCompiler",
|
|
50
|
+
"UnsafeSVGError",
|
|
51
|
+
"UnsupportedBackendError",
|
|
52
|
+
"VectexError",
|
|
53
|
+
"VectexFragment",
|
|
54
|
+
"__version__",
|
|
55
|
+
"clear_cache",
|
|
56
|
+
"render",
|
|
57
|
+
"render_many",
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
__version__ = version("vectex")
|
|
62
|
+
except PackageNotFoundError: # pragma: no cover - source checkout without install
|
|
63
|
+
__version__ = "0+unknown"
|
vectex/adapters.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Lossless wrappers for optional SVG object-model backends."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .exceptions import UnsupportedBackendError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def to_svg_py(serialized: str) -> Any:
|
|
11
|
+
"""Return an ``svg.Element`` that serializes the canonical group verbatim."""
|
|
12
|
+
try:
|
|
13
|
+
import svg
|
|
14
|
+
except ImportError as exc:
|
|
15
|
+
raise UnsupportedBackendError(
|
|
16
|
+
"svg.py is not installed; install vectex[svg-py]"
|
|
17
|
+
) from exc
|
|
18
|
+
|
|
19
|
+
class VectexSvgPyGroup(svg.Element):
|
|
20
|
+
element_name = "g"
|
|
21
|
+
|
|
22
|
+
def __init__(self, content: str) -> None:
|
|
23
|
+
self._vectex_content = content
|
|
24
|
+
|
|
25
|
+
def as_str(self) -> str:
|
|
26
|
+
return self._vectex_content
|
|
27
|
+
|
|
28
|
+
return VectexSvgPyGroup(serialized)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def to_drawsvg(serialized: str) -> Any:
|
|
32
|
+
"""Return a ``drawsvg.Raw`` element containing the canonical group."""
|
|
33
|
+
try:
|
|
34
|
+
import drawsvg # type: ignore[import-untyped]
|
|
35
|
+
except ImportError as exc:
|
|
36
|
+
raise UnsupportedBackendError(
|
|
37
|
+
"drawsvg is not installed; install vectex[drawsvg]"
|
|
38
|
+
) from exc
|
|
39
|
+
return drawsvg.Raw(serialized)
|
vectex/api.py
ADDED
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
"""High-level rendering API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import math
|
|
8
|
+
import re
|
|
9
|
+
import tempfile
|
|
10
|
+
import uuid
|
|
11
|
+
from collections.abc import Mapping, Sequence
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from os import PathLike
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, cast
|
|
16
|
+
|
|
17
|
+
from . import cache
|
|
18
|
+
from .compiler import (
|
|
19
|
+
Compiler,
|
|
20
|
+
CompileRequest,
|
|
21
|
+
TeXCompiler,
|
|
22
|
+
_resolve_tex_preamble,
|
|
23
|
+
normalize_args,
|
|
24
|
+
tex_base_size,
|
|
25
|
+
)
|
|
26
|
+
from .converter import Converter, ConvertRequest, converter_from_name
|
|
27
|
+
from .exceptions import ConfigurationError
|
|
28
|
+
from .fragment import VectexFragment
|
|
29
|
+
from .normalizer import Normalizer
|
|
30
|
+
from .process import tool_identity
|
|
31
|
+
|
|
32
|
+
_TEXTEXT_ENGINES = frozenset({"pdflatex", "xelatex", "lualatex"})
|
|
33
|
+
_PACKAGE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class RenderItem:
|
|
38
|
+
"""One source together with the options that shape its fragment.
|
|
39
|
+
|
|
40
|
+
Every option that determines what is produced can be set here. Options left
|
|
41
|
+
as ``None`` take the value passed to :func:`render` or :func:`render_many`.
|
|
42
|
+
``cache_dir``, ``refresh`` and ``unique_ids`` describe how a call is
|
|
43
|
+
executed rather than what it produces, and stay on the call itself.
|
|
44
|
+
|
|
45
|
+
:func:`render_many` groups items that share a compilation -- the same
|
|
46
|
+
engine, converter, preamble, extra packages, timeout, process arguments and
|
|
47
|
+
executable overrides -- and runs one compiler and converter invocation per group.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
source: str
|
|
51
|
+
engine: str | Compiler | None = None
|
|
52
|
+
converter: str | Converter | None = None
|
|
53
|
+
scale: float | None = None
|
|
54
|
+
size_pt: float | None = None
|
|
55
|
+
timeout: float | None = None
|
|
56
|
+
preamble: str | None = None
|
|
57
|
+
extra_packages: Sequence[str] | None = None
|
|
58
|
+
compiler_args: Sequence[str] | None = None
|
|
59
|
+
converter_args: Sequence[str] | None = None
|
|
60
|
+
executable_overrides: Mapping[str, str] | None = None
|
|
61
|
+
id_prefix: str | None = None
|
|
62
|
+
baseline: float | None = None
|
|
63
|
+
textext_compatible: bool | None = None
|
|
64
|
+
textext_preamble_file: str | None = None
|
|
65
|
+
textext_alignment: str | None = None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class _Resolved:
|
|
70
|
+
"""One item with every option resolved to a concrete value."""
|
|
71
|
+
|
|
72
|
+
source: str
|
|
73
|
+
compiler: Compiler
|
|
74
|
+
converter: Converter
|
|
75
|
+
timeout: float
|
|
76
|
+
preamble: str
|
|
77
|
+
preamble_input: str
|
|
78
|
+
extra_packages: tuple[str, ...]
|
|
79
|
+
scale: float
|
|
80
|
+
size_pt: float | None
|
|
81
|
+
baseline: float | None
|
|
82
|
+
id_prefix: str | None
|
|
83
|
+
compiler_args: tuple[str, ...]
|
|
84
|
+
converter_args: tuple[str, ...]
|
|
85
|
+
overrides: Mapping[str, str]
|
|
86
|
+
textext_compatible: bool
|
|
87
|
+
textext_preamble_file: str
|
|
88
|
+
textext_alignment: str
|
|
89
|
+
compilation: tuple[Any, ...]
|
|
90
|
+
cacheable: bool
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def render(
|
|
94
|
+
source: str | RenderItem,
|
|
95
|
+
*,
|
|
96
|
+
engine: str | Compiler = "pdflatex",
|
|
97
|
+
converter: str | Converter = "dvisvgm",
|
|
98
|
+
scale: float | None = None,
|
|
99
|
+
size_pt: float | None = None,
|
|
100
|
+
timeout: float = 30.0,
|
|
101
|
+
preamble: str = "",
|
|
102
|
+
extra_packages: Sequence[str] = (),
|
|
103
|
+
compiler_args: Sequence[str] = (),
|
|
104
|
+
converter_args: Sequence[str] = (),
|
|
105
|
+
executable_overrides: Mapping[str, str] | None = None,
|
|
106
|
+
id_prefix: str | None = None,
|
|
107
|
+
unique_ids: bool = False,
|
|
108
|
+
baseline: float | None = None,
|
|
109
|
+
cache_dir: str | PathLike[str] | None = None,
|
|
110
|
+
refresh: bool = False,
|
|
111
|
+
textext_compatible: bool = True,
|
|
112
|
+
textext_preamble_file: str = "",
|
|
113
|
+
textext_alignment: str = "middle center",
|
|
114
|
+
) -> VectexFragment:
|
|
115
|
+
"""Compile *source* as a literal TeX document body and return one SVG fragment.
|
|
116
|
+
|
|
117
|
+
Use normal TeX delimiters such as ``$...$`` or ``\\[...\\]`` for mathematics.
|
|
118
|
+
*source* may also be a :class:`RenderItem`, whose options override the
|
|
119
|
+
keywords here. A nonempty *preamble* is complete and contains
|
|
120
|
+
``\\documentclass``; *extra_packages* is its mutually exclusive convenience
|
|
121
|
+
alternative.
|
|
122
|
+
"""
|
|
123
|
+
return render_many(
|
|
124
|
+
(source,),
|
|
125
|
+
engine=engine,
|
|
126
|
+
converter=converter,
|
|
127
|
+
scale=scale,
|
|
128
|
+
size_pt=size_pt,
|
|
129
|
+
timeout=timeout,
|
|
130
|
+
preamble=preamble,
|
|
131
|
+
extra_packages=extra_packages,
|
|
132
|
+
compiler_args=compiler_args,
|
|
133
|
+
converter_args=converter_args,
|
|
134
|
+
executable_overrides=executable_overrides,
|
|
135
|
+
id_prefix=id_prefix,
|
|
136
|
+
unique_ids=unique_ids,
|
|
137
|
+
baseline=baseline,
|
|
138
|
+
cache_dir=cache_dir,
|
|
139
|
+
refresh=refresh,
|
|
140
|
+
textext_compatible=textext_compatible,
|
|
141
|
+
textext_preamble_file=textext_preamble_file,
|
|
142
|
+
textext_alignment=textext_alignment,
|
|
143
|
+
)[0]
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def render_many(
|
|
147
|
+
sources: Sequence[str | RenderItem],
|
|
148
|
+
*,
|
|
149
|
+
engine: str | Compiler = "pdflatex",
|
|
150
|
+
converter: str | Converter = "dvisvgm",
|
|
151
|
+
scale: float | None = None,
|
|
152
|
+
size_pt: float | None = None,
|
|
153
|
+
timeout: float = 30.0,
|
|
154
|
+
preamble: str = "",
|
|
155
|
+
extra_packages: Sequence[str] = (),
|
|
156
|
+
compiler_args: Sequence[str] = (),
|
|
157
|
+
converter_args: Sequence[str] = (),
|
|
158
|
+
executable_overrides: Mapping[str, str] | None = None,
|
|
159
|
+
id_prefix: str | None = None,
|
|
160
|
+
unique_ids: bool = False,
|
|
161
|
+
baseline: float | None = None,
|
|
162
|
+
cache_dir: str | PathLike[str] | None = None,
|
|
163
|
+
refresh: bool = False,
|
|
164
|
+
textext_compatible: bool = True,
|
|
165
|
+
textext_preamble_file: str = "",
|
|
166
|
+
textext_alignment: str = "middle center",
|
|
167
|
+
) -> tuple[VectexFragment, ...]:
|
|
168
|
+
"""Render several sources, sharing invocations wherever the options allow.
|
|
169
|
+
|
|
170
|
+
A source may be a string or a :class:`RenderItem` carrying its own options.
|
|
171
|
+
Items that share a compilation are compiled together, so a batch of labels
|
|
172
|
+
differing only in size still costs one compiler and one converter run,
|
|
173
|
+
while an item with its own preamble simply forms its own group. Fragments
|
|
174
|
+
are returned in input order.
|
|
175
|
+
"""
|
|
176
|
+
if not isinstance(unique_ids, bool):
|
|
177
|
+
raise ConfigurationError("unique_ids must be a boolean")
|
|
178
|
+
if not isinstance(refresh, bool):
|
|
179
|
+
raise ConfigurationError("refresh must be a boolean")
|
|
180
|
+
defaults = RenderItem(
|
|
181
|
+
source="",
|
|
182
|
+
engine=engine,
|
|
183
|
+
converter=converter,
|
|
184
|
+
scale=scale,
|
|
185
|
+
size_pt=size_pt,
|
|
186
|
+
timeout=timeout,
|
|
187
|
+
preamble=preamble,
|
|
188
|
+
extra_packages=extra_packages,
|
|
189
|
+
compiler_args=compiler_args,
|
|
190
|
+
converter_args=converter_args,
|
|
191
|
+
executable_overrides=executable_overrides,
|
|
192
|
+
id_prefix=id_prefix,
|
|
193
|
+
baseline=baseline,
|
|
194
|
+
textext_compatible=textext_compatible,
|
|
195
|
+
textext_preamble_file=textext_preamble_file,
|
|
196
|
+
textext_alignment=textext_alignment,
|
|
197
|
+
)
|
|
198
|
+
entries = _entries(sources)
|
|
199
|
+
components: dict[Any, Compiler | Converter] = {}
|
|
200
|
+
items = tuple(
|
|
201
|
+
_resolve(entry, defaults, index, len(entries), components)
|
|
202
|
+
for index, entry in enumerate(entries)
|
|
203
|
+
)
|
|
204
|
+
if unique_ids and any(item.id_prefix is not None for item in items):
|
|
205
|
+
raise ConfigurationError("id_prefix and unique_ids are alternatives")
|
|
206
|
+
|
|
207
|
+
root = cache.cache_root(cache_dir)
|
|
208
|
+
if root is not None:
|
|
209
|
+
if any(not item.cacheable for item in items):
|
|
210
|
+
raise ConfigurationError("disk caching requires named built-in components")
|
|
211
|
+
if unique_ids:
|
|
212
|
+
raise ConfigurationError("unique_ids cannot be combined with disk caching")
|
|
213
|
+
keys = tuple(_cache_key(item, cached=root is not None) for item in items)
|
|
214
|
+
|
|
215
|
+
results: list[VectexFragment | None] = [None] * len(items)
|
|
216
|
+
misses: list[int] = []
|
|
217
|
+
for index, key in enumerate(keys):
|
|
218
|
+
cached = cache.load(root, key) if root is not None and not refresh else None
|
|
219
|
+
if cached is None:
|
|
220
|
+
misses.append(index)
|
|
221
|
+
else:
|
|
222
|
+
results[index] = cached
|
|
223
|
+
|
|
224
|
+
for indices in _compilation_groups(items, misses):
|
|
225
|
+
rendered = _render_uncached(
|
|
226
|
+
tuple(items[index] for index in indices),
|
|
227
|
+
tuple(
|
|
228
|
+
_prefix(items[index].id_prefix, unique_ids, keys[index])
|
|
229
|
+
for index in indices
|
|
230
|
+
),
|
|
231
|
+
)
|
|
232
|
+
for index, fragment in zip(indices, rendered, strict=True):
|
|
233
|
+
results[index] = fragment
|
|
234
|
+
if root is not None:
|
|
235
|
+
cache.store(root, keys[index], fragment)
|
|
236
|
+
if any(fragment is None for fragment in results): # pragma: no cover
|
|
237
|
+
raise AssertionError("internal render result was not populated")
|
|
238
|
+
return cast(tuple[VectexFragment, ...], tuple(results))
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def clear_cache(cache_dir: str | PathLike[str] | None = None) -> int:
|
|
242
|
+
"""Clear Vectex entries from the configured cache and return their count."""
|
|
243
|
+
return cache.clear(cache_dir)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _render_uncached(
|
|
247
|
+
items: tuple[_Resolved, ...], prefixes: tuple[str, ...]
|
|
248
|
+
) -> tuple[VectexFragment, ...]:
|
|
249
|
+
"""Compile and normalize one group of items sharing a compilation."""
|
|
250
|
+
shared = items[0]
|
|
251
|
+
compiler_impl = shared.compiler
|
|
252
|
+
converter_impl = shared.converter
|
|
253
|
+
with tempfile.TemporaryDirectory(prefix="vectex-") as temporary:
|
|
254
|
+
workdir = Path(temporary)
|
|
255
|
+
requests = tuple(
|
|
256
|
+
CompileRequest(
|
|
257
|
+
source=item.source,
|
|
258
|
+
workdir=workdir,
|
|
259
|
+
timeout=shared.timeout,
|
|
260
|
+
preamble=shared.preamble,
|
|
261
|
+
extra_args=shared.compiler_args,
|
|
262
|
+
executable=shared.overrides.get(compiler_impl.name),
|
|
263
|
+
)
|
|
264
|
+
for item in items
|
|
265
|
+
)
|
|
266
|
+
if len(requests) == 1:
|
|
267
|
+
compiled = compiler_impl.compile(requests[0])
|
|
268
|
+
else:
|
|
269
|
+
compile_many = getattr(compiler_impl, "compile_many", None)
|
|
270
|
+
if not callable(compile_many):
|
|
271
|
+
raise ConfigurationError("compiler does not support render_many")
|
|
272
|
+
compiled = compile_many(requests)
|
|
273
|
+
convert_request = ConvertRequest(
|
|
274
|
+
compiled=compiled,
|
|
275
|
+
workdir=workdir,
|
|
276
|
+
timeout=shared.timeout,
|
|
277
|
+
extra_args=shared.converter_args,
|
|
278
|
+
executable=shared.overrides.get(converter_impl.name),
|
|
279
|
+
)
|
|
280
|
+
if len(requests) == 1:
|
|
281
|
+
converted = (converter_impl.convert(convert_request),)
|
|
282
|
+
else:
|
|
283
|
+
convert_many = getattr(converter_impl, "convert_many", None)
|
|
284
|
+
if not callable(convert_many):
|
|
285
|
+
raise ConfigurationError("converter does not support render_many")
|
|
286
|
+
converted = tuple(convert_many(convert_request))
|
|
287
|
+
if len(converted) != len(items):
|
|
288
|
+
raise ConfigurationError("converter returned the wrong number of pages")
|
|
289
|
+
ratios = compiled.baseline_ratios or (None,) * len(items)
|
|
290
|
+
fragments = []
|
|
291
|
+
for item, prefix, page, ratio in zip(
|
|
292
|
+
items, prefixes, converted, ratios, strict=True
|
|
293
|
+
):
|
|
294
|
+
try:
|
|
295
|
+
svg = page.path.read_bytes()
|
|
296
|
+
except OSError as exc:
|
|
297
|
+
raise ConfigurationError(
|
|
298
|
+
f"converter result cannot be read: {page.path}"
|
|
299
|
+
) from exc
|
|
300
|
+
fragments.append(
|
|
301
|
+
Normalizer().normalize(
|
|
302
|
+
svg,
|
|
303
|
+
source=item.source,
|
|
304
|
+
engine=compiler_impl.name,
|
|
305
|
+
converter=converter_impl.name,
|
|
306
|
+
scale=item.scale,
|
|
307
|
+
baseline=item.baseline,
|
|
308
|
+
baseline_ratio=ratio,
|
|
309
|
+
compiler_options={
|
|
310
|
+
"args": list(shared.compiler_args),
|
|
311
|
+
"extra_packages": list(item.extra_packages),
|
|
312
|
+
"preamble": item.preamble_input,
|
|
313
|
+
"size_pt": item.size_pt,
|
|
314
|
+
},
|
|
315
|
+
converter_options={"args": list(shared.converter_args)},
|
|
316
|
+
id_prefix=prefix,
|
|
317
|
+
textext_compatible=item.textext_compatible,
|
|
318
|
+
textext_source=item.source,
|
|
319
|
+
textext_preamble_file=item.textext_preamble_file,
|
|
320
|
+
textext_alignment=item.textext_alignment,
|
|
321
|
+
)
|
|
322
|
+
)
|
|
323
|
+
return tuple(fragments)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _entries(value: Sequence[str | RenderItem]) -> tuple[RenderItem, ...]:
|
|
327
|
+
"""Normalize a batch of plain sources or per-item records."""
|
|
328
|
+
if isinstance(value, (str, bytes, RenderItem)):
|
|
329
|
+
raise ConfigurationError("sources must be a non-empty sequence of strings")
|
|
330
|
+
entries = tuple(
|
|
331
|
+
entry if isinstance(entry, RenderItem) else RenderItem(entry) for entry in value
|
|
332
|
+
)
|
|
333
|
+
if not entries or not all(
|
|
334
|
+
isinstance(entry.source, str) and entry.source.strip() for entry in entries
|
|
335
|
+
):
|
|
336
|
+
raise ConfigurationError("sources must be a non-empty sequence of strings")
|
|
337
|
+
return entries
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _resolve(
|
|
341
|
+
entry: RenderItem,
|
|
342
|
+
defaults: RenderItem,
|
|
343
|
+
index: int,
|
|
344
|
+
count: int,
|
|
345
|
+
components: dict[Any, Compiler | Converter],
|
|
346
|
+
) -> _Resolved:
|
|
347
|
+
"""Merge one entry with the batch defaults and validate the result."""
|
|
348
|
+
|
|
349
|
+
def chosen(name: str) -> Any:
|
|
350
|
+
value = getattr(entry, name)
|
|
351
|
+
return getattr(defaults, name) if value is None else value
|
|
352
|
+
|
|
353
|
+
preamble_input = chosen("preamble")
|
|
354
|
+
if not isinstance(preamble_input, str):
|
|
355
|
+
raise ConfigurationError("preamble must be a string")
|
|
356
|
+
extra_packages = _extra_package_names(chosen("extra_packages"))
|
|
357
|
+
textext_compatible = chosen("textext_compatible")
|
|
358
|
+
if not isinstance(textext_compatible, bool):
|
|
359
|
+
raise ConfigurationError("textext_compatible must be a boolean")
|
|
360
|
+
textext_preamble_file = chosen("textext_preamble_file")
|
|
361
|
+
if not isinstance(textext_preamble_file, str):
|
|
362
|
+
raise ConfigurationError("textext_preamble_file must be a string")
|
|
363
|
+
textext_alignment = chosen("textext_alignment")
|
|
364
|
+
if not isinstance(textext_alignment, str) or not textext_alignment.strip():
|
|
365
|
+
raise ConfigurationError("textext_alignment must be a non-empty string")
|
|
366
|
+
|
|
367
|
+
engine_spec = chosen("engine")
|
|
368
|
+
converter_spec = chosen("converter")
|
|
369
|
+
compiler_component, engine_key = _component_for(
|
|
370
|
+
engine_spec, TeXCompiler, components
|
|
371
|
+
)
|
|
372
|
+
converter_component, converter_key = _component_for(
|
|
373
|
+
converter_spec, converter_from_name, components
|
|
374
|
+
)
|
|
375
|
+
compiler_impl = cast(Compiler, compiler_component)
|
|
376
|
+
converter_impl = cast(Converter, converter_component)
|
|
377
|
+
_component(compiler_impl, "compiler")
|
|
378
|
+
_component(converter_impl, "converter")
|
|
379
|
+
if textext_compatible and compiler_impl.name not in _TEXTEXT_ENGINES:
|
|
380
|
+
raise ConfigurationError(
|
|
381
|
+
f"TexText does not support compiler {compiler_impl.name!r}; "
|
|
382
|
+
"pass textext_compatible=False for custom engines"
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
# An item that sets either sizing option replaces the pair, so a batch
|
|
386
|
+
# size_pt does not collide with a per-item scale.
|
|
387
|
+
if entry.scale is None and entry.size_pt is None:
|
|
388
|
+
scale_input, size_pt = defaults.scale, defaults.size_pt
|
|
389
|
+
else:
|
|
390
|
+
scale_input, size_pt = entry.scale, entry.size_pt
|
|
391
|
+
overrides = _executable_overrides(chosen("executable_overrides"))
|
|
392
|
+
compiler_args = normalize_args(chosen("compiler_args"), option="compiler_args")
|
|
393
|
+
converter_args = normalize_args(chosen("converter_args"), option="converter_args")
|
|
394
|
+
timeout = _positive_number(chosen("timeout"), "timeout")
|
|
395
|
+
preamble = _resolve_tex_preamble(preamble_input, extra_packages)
|
|
396
|
+
id_prefix: str | None
|
|
397
|
+
if entry.id_prefix is not None:
|
|
398
|
+
id_prefix = entry.id_prefix
|
|
399
|
+
elif defaults.id_prefix is None or count == 1:
|
|
400
|
+
id_prefix = defaults.id_prefix
|
|
401
|
+
else:
|
|
402
|
+
id_prefix = f"{defaults.id_prefix}-{index}"
|
|
403
|
+
return _Resolved(
|
|
404
|
+
source=entry.source,
|
|
405
|
+
compiler=compiler_impl,
|
|
406
|
+
converter=converter_impl,
|
|
407
|
+
timeout=timeout,
|
|
408
|
+
preamble=preamble,
|
|
409
|
+
preamble_input=preamble_input,
|
|
410
|
+
extra_packages=extra_packages,
|
|
411
|
+
scale=_effective_scale(scale_input, size_pt, preamble),
|
|
412
|
+
size_pt=size_pt,
|
|
413
|
+
baseline=chosen("baseline"),
|
|
414
|
+
id_prefix=id_prefix,
|
|
415
|
+
compiler_args=compiler_args,
|
|
416
|
+
converter_args=converter_args,
|
|
417
|
+
overrides=overrides,
|
|
418
|
+
textext_compatible=textext_compatible,
|
|
419
|
+
textext_preamble_file=textext_preamble_file,
|
|
420
|
+
textext_alignment=textext_alignment,
|
|
421
|
+
compilation=(
|
|
422
|
+
engine_key,
|
|
423
|
+
converter_key,
|
|
424
|
+
timeout,
|
|
425
|
+
preamble,
|
|
426
|
+
compiler_args,
|
|
427
|
+
converter_args,
|
|
428
|
+
tuple(sorted(overrides.items())),
|
|
429
|
+
),
|
|
430
|
+
cacheable=isinstance(engine_spec, str) and isinstance(converter_spec, str),
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _component_for(
|
|
435
|
+
spec: Any,
|
|
436
|
+
factory: Any,
|
|
437
|
+
components: dict[Any, Compiler | Converter],
|
|
438
|
+
) -> tuple[Compiler | Converter, Any]:
|
|
439
|
+
"""Reuse one component instance per distinct engine or converter spec."""
|
|
440
|
+
key = spec if isinstance(spec, str) else id(spec)
|
|
441
|
+
if key not in components:
|
|
442
|
+
components[key] = factory(spec) if isinstance(spec, str) else spec
|
|
443
|
+
return components[key], key
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _compilation_groups(
|
|
447
|
+
items: tuple[_Resolved, ...], indices: Sequence[int]
|
|
448
|
+
) -> tuple[tuple[int, ...], ...]:
|
|
449
|
+
"""Group item positions that can share one compiler and converter run."""
|
|
450
|
+
groups: dict[tuple[Any, ...], list[int]] = {}
|
|
451
|
+
for index in indices:
|
|
452
|
+
groups.setdefault(items[index].compilation, []).append(index)
|
|
453
|
+
return tuple(tuple(group) for group in groups.values())
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _cache_key(item: _Resolved, *, cached: bool) -> str:
|
|
457
|
+
"""Fingerprint every input that determines the fragment."""
|
|
458
|
+
fields: dict[str, Any] = {
|
|
459
|
+
"baseline": item.baseline,
|
|
460
|
+
"compiler_args": list(item.compiler_args),
|
|
461
|
+
"converter": item.converter.name,
|
|
462
|
+
"converter_args": list(item.converter_args),
|
|
463
|
+
"engine": item.compiler.name,
|
|
464
|
+
"executable_overrides": dict(item.overrides),
|
|
465
|
+
"id_prefix": item.id_prefix,
|
|
466
|
+
"extra_packages": list(item.extra_packages),
|
|
467
|
+
"preamble": item.preamble_input,
|
|
468
|
+
"scale": item.scale,
|
|
469
|
+
"size_pt": item.size_pt,
|
|
470
|
+
"source": item.source,
|
|
471
|
+
"textext_alignment": item.textext_alignment,
|
|
472
|
+
"textext_compatible": item.textext_compatible,
|
|
473
|
+
"textext_preamble_file": item.textext_preamble_file,
|
|
474
|
+
}
|
|
475
|
+
if cached:
|
|
476
|
+
# A record compiled by one installation must not be served after that
|
|
477
|
+
# installation changes, so the tools identify themselves in the key.
|
|
478
|
+
fields["toolchain"] = [
|
|
479
|
+
_component_identity(component, item.overrides)
|
|
480
|
+
for component in (item.compiler, item.converter)
|
|
481
|
+
]
|
|
482
|
+
return _fingerprint(fields)
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _component_identity(
|
|
486
|
+
component: Compiler | Converter, overrides: Mapping[str, str]
|
|
487
|
+
) -> str:
|
|
488
|
+
"""Identify the installed tool behind a component for cache keys.
|
|
489
|
+
|
|
490
|
+
A component may report its own identity; built-in components fall back to
|
|
491
|
+
probing the executable, which is why a compiled record is not reused after
|
|
492
|
+
the TeX or converter installation changes.
|
|
493
|
+
"""
|
|
494
|
+
declared = getattr(component, "identity", None)
|
|
495
|
+
if callable(declared):
|
|
496
|
+
return str(declared())
|
|
497
|
+
name = component.name
|
|
498
|
+
return tool_identity(overrides.get(name, name))
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _extra_package_names(value: Sequence[str]) -> tuple[str, ...]:
|
|
502
|
+
"""Validate package names without accepting TeX syntax as convenience input."""
|
|
503
|
+
if isinstance(value, (str, bytes)) or not isinstance(value, Sequence):
|
|
504
|
+
raise ConfigurationError("extra_packages must be a sequence of package names")
|
|
505
|
+
extra_packages = tuple(value)
|
|
506
|
+
if not all(
|
|
507
|
+
isinstance(name, str) and _PACKAGE_RE.fullmatch(name) for name in extra_packages
|
|
508
|
+
):
|
|
509
|
+
raise ConfigurationError(
|
|
510
|
+
"extra_packages must contain only letters, digits, dots, underscores, "
|
|
511
|
+
"and hyphens"
|
|
512
|
+
)
|
|
513
|
+
return extra_packages
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _effective_scale(
|
|
517
|
+
scale: float | None, size_pt: float | None, preamble: str
|
|
518
|
+
) -> float:
|
|
519
|
+
if scale is not None and size_pt is not None:
|
|
520
|
+
raise ConfigurationError("scale and size_pt are alternatives")
|
|
521
|
+
if size_pt is None:
|
|
522
|
+
return _positive_number(1.0 if scale is None else scale, "scale")
|
|
523
|
+
size = _positive_number(size_pt, "size_pt")
|
|
524
|
+
return size / tex_base_size(preamble)
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _prefix(explicit: str | None, unique: bool, key: str) -> str:
|
|
528
|
+
if unique:
|
|
529
|
+
return f"vx-{uuid.uuid4().hex[:12]}"
|
|
530
|
+
if explicit is None:
|
|
531
|
+
return f"vx{key[:12]}"
|
|
532
|
+
return explicit
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def _fingerprint(value: Mapping[str, Any]) -> str:
|
|
536
|
+
encoded = json.dumps(
|
|
537
|
+
value, ensure_ascii=False, separators=(",", ":"), sort_keys=True
|
|
538
|
+
)
|
|
539
|
+
return hashlib.blake2s(encoded.encode("utf-8")).hexdigest()
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _positive_number(value: float, option: str) -> float:
|
|
543
|
+
try:
|
|
544
|
+
number = float(value)
|
|
545
|
+
except (TypeError, ValueError) as exc:
|
|
546
|
+
raise ConfigurationError(f"{option} must be a positive finite number") from exc
|
|
547
|
+
if not math.isfinite(number) or number <= 0:
|
|
548
|
+
raise ConfigurationError(f"{option} must be a positive finite number")
|
|
549
|
+
return number
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def _executable_overrides(value: Mapping[str, str] | None) -> dict[str, str]:
|
|
553
|
+
if value is None:
|
|
554
|
+
return {}
|
|
555
|
+
try:
|
|
556
|
+
overrides = dict(value)
|
|
557
|
+
except (TypeError, ValueError) as exc:
|
|
558
|
+
raise ConfigurationError(
|
|
559
|
+
"executable_overrides must map component names to executable strings"
|
|
560
|
+
) from exc
|
|
561
|
+
if not all(
|
|
562
|
+
isinstance(key, str)
|
|
563
|
+
and isinstance(executable, str)
|
|
564
|
+
and executable
|
|
565
|
+
and "\x00" not in executable
|
|
566
|
+
for key, executable in overrides.items()
|
|
567
|
+
):
|
|
568
|
+
raise ConfigurationError(
|
|
569
|
+
"executable_overrides must map names to non-empty NUL-free strings"
|
|
570
|
+
)
|
|
571
|
+
return overrides
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def _component(value: object, kind: str) -> None:
|
|
575
|
+
if not isinstance(getattr(value, "name", None), str) or not callable(
|
|
576
|
+
getattr(value, "compile" if kind == "compiler" else "convert", None)
|
|
577
|
+
):
|
|
578
|
+
raise ConfigurationError(f"{kind} does not implement the vectex protocol")
|