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 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")