Metadata-Version: 2.5
Name: vectex
Version: 0.5.0
Summary: Render LaTeX as portable, editable SVG group fragments
Project-URL: Documentation, https://github.com/maiani/vectex/blob/main/docs/index.md
Project-URL: Issues, https://github.com/maiani/vectex/issues
Project-URL: Source, https://github.com/maiani/vectex
Author-email: Andrea Maiani <dev@andreamaiani.com>
License-Expression: MIT
License-File: LICENSE
Keywords: latex,svg,vector-graphics
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: MacOS
Classifier: Operating System :: Microsoft :: Windows
Classifier: Operating System :: POSIX :: Linux
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Multimedia :: Graphics :: Graphics Conversion
Classifier: Typing :: Typed
Requires-Python: >=3.12
Requires-Dist: lxml>=5.0
Requires-Dist: typer>=0.12
Provides-Extra: all
Requires-Dist: drawsvg>=2.0; extra == 'all'
Requires-Dist: svg-py>=1.5; extra == 'all'
Provides-Extra: drawsvg
Requires-Dist: drawsvg>=2.0; extra == 'drawsvg'
Provides-Extra: examples
Requires-Dist: cairosvg>=2.7; extra == 'examples'
Provides-Extra: svg-py
Requires-Dist: svg-py>=1.5; extra == 'svg-py'
Description-Content-Type: text/markdown

# VecTeX

[![Test](https://github.com/maiani/vectex/actions/workflows/test.yml/badge.svg?branch=main)](https://github.com/maiani/vectex/actions/workflows/test.yml)
[![PyPI](https://img.shields.io/pypi/v/vectex.svg)](https://pypi.org/project/vectex/)
[![Python](https://img.shields.io/badge/python-%3E%3D3.12-3776AB?logo=python&logoColor=white)](https://www.python.org/)
[![License](https://img.shields.io/badge/license-MIT-3DA639)](https://github.com/maiani/vectex/blob/v0.5.0/LICENSE)

VecTeX compiles TeX source into one self-contained SVG `<g>` fragment, so
equations and labels in a figure are generated from code and stay editable
afterwards. Output is deterministic, with stable ids, so a figure can be
regenerated and diffed in version control; the fragment is also an editable
TexText object once inserted into an Inkscape document.

<p align="center">
  <img src="https://raw.githubusercontent.com/maiani/vectex/v0.5.0/docs/images/readme.svg" alt="The time-dependent Schrodinger equation typeset by TeX as an SVG fragment" width="640">
</p>

<p align="center"><sub>One display equation, compiled by real TeX into an SVG fragment, from <code>docs/readme_figure.py</code>.</sub></p>

VecTeX is a library-level reimplementation of the rendering and normalization
boundary behind TexText. It needs neither Inkscape nor access to the
destination document.

## Install

VecTeX is on PyPI and needs Python 3.12 or newer:

```bash
python -m pip install vectex
```

Rendering also needs, on `PATH`, a TeX engine (`pdflatex`, `xelatex`, or
`lualatex`) with the `standalone` class and the `preview`, `amsmath`, and `varwidth`
packages, and `dvisvgm`; TeX Live and MiKTeX ship all of them. dvisvgm reads PDF
through MuPDF's `mutool` or a Ghostscript older than 10.01, so with a current
Ghostscript install `mutool` too. On Debian and Ubuntu:

```bash
sudo apt install texlive-latex-extra dvisvgm mupdf-tools
sudo apt install texlive-xetex texlive-luatex   # only for xelatex and lualatex
```

The optional object-model adapters install with an extra:

```bash
python -m pip install 'vectex[svg-py]'   # import svg
python -m pip install 'vectex[drawsvg]'  # import drawsvg
python -m pip install 'vectex[all]'
```

## Quick start

```python
import vectex

fragment = vectex.render(r"mass $m$ and energy $E = mc^2$")
label = vectex.render(r"$E = mc^2$", size_pt=8, id_prefix="einstein")
vector = vectex.render(r"$\bm{n}$", extra_packages=("bm",))
purple = vectex.render(r"$\mu$", color="#7a1fa2")  # glyphs and rules alike

svg_text = fragment.to_svg()  # the <g> fragment as a string
lxml_group = fragment.to_lxml()  # a fresh lxml element
document = fragment.to_svg_document()  # a complete, openable SVG document
fragment.write_svg_document("label.svg")  # the same document, on disk

print(fragment.width, fragment.height, fragment.view_box)
print(fragment.source, fragment.engine, fragment.metadata)
```

The outer group of `label` has `id="einstein-root"`, and rendering the same
input again produces the same bytes.

TeX input is always a literal document body, the convention TexText uses:
`$...$` marks inline mathematics, `\[...\]` display mathematics, and everything
else is prose. Complete environments such as `align*` work directly. The
default preamble loads `amsmath`, so `\text{...}` works in math.
A `\\` in prose, such as `r"qubit\\frequency $f_{01}$"`, breaks a label into
centered rows that keep the first row's baseline. Adjacent displays such as
`r"\[E_0=-\Delta/2\]\[E_1=\Delta/2\]"` form one cropped stack of centered
rows; use `render_many()` for separate fragments.

The default preamble is the `standalone` class with `amsmath`, and
`extra_packages=("bm",)` adds `\usepackage` lines to it. A nonempty `preamble`
replaces the whole preamble instead: it must contain `\documentclass`, and must
load `amsmath` itself if the source needs it. The two options are mutually
exclusive. With `standalone`, every fragment is cropped tightly to what TeX
typesets, so a `border=` class option has no effect; any other class, such as
`preamble=r"\documentclass{article}"`, gives full pages and no baseline.

`size_pt=7` scales a fragment so that the class's nominal size (10 pt unless a
class option such as `11pt` says otherwise) comes out at 7 pt; `scale=0.7`
scales it directly, and passing both is an error. Both are geometric: TeX still
typesets at the class size, and TikZ lengths shrink with the text.

Every call uses a fresh temporary directory and runs two stages:

```text
source -> pdflatex/xelatex/lualatex -> PDF -> dvisvgm -> SVG -> lxml -> <g>
```

## Command line

The `vectex` command renders a document body to a fragment on standard output:

```bash
vectex '$E = mc^2$'
vectex '$E = mc^2$' --as-doc > einstein.svg      # a complete SVG document
vectex '$E = mc^2$' --as-doc -o einstein.svg     # or write it with -o/--output
vectex --input equation.tex --as-doc -o equation.svg
printf '%s\n' '$E = mc^2$' | vectex - --as-doc > einstein.svg
```

`-i`/`--input PATH` reads the source from a UTF-8 file. `--extra-package NAME`
(repeatable) loads a package; `--preamble` or `--preamble-file preamble.tex`
supplies a complete preamble, and the file form also records its absolute path
for later TexText editing. `--cache-dir PATH` reuses render records,
`--refresh` recompiles the selected one, and `--no-cache` skips the cache. `--size-pt` or `--scale`, `--color`,
`--id-prefix`, `--engine`, `--timeout`, and `--executable NAME=PATH`
(repeatable) mirror the Python options. The converter, compiler and converter
arguments, `baseline`, `unique_ids`, `textext_compatible`, `textext_alignment`,
and batches have no command-line form. `vectex --help` lists everything;
`vectex --version` reports the installed version. An invalid option exits with
status 2 and usage text; a TeX error, a converter failure, or a missing tool
exits with status 1.

## TikZ pictures

A `tikzpicture` is an ordinary document body:

```python
arrow = vectex.render(
    r"\begin{tikzpicture}\draw[->] (0,0) -- (2,1);\end{tikzpicture}",
    extra_packages=["tikz"],
)
```

`extra_packages` emits only `\usepackage` lines, so `\usetikzlibrary`,
`\pgfplotsset`, and package options need a complete `preamble`.
[`examples/bloch_sphere.py`](https://github.com/maiani/vectex/blob/v0.5.0/examples/bloch_sphere.py) is a worked example, and
[`examples/figure_labels.py`](https://github.com/maiani/vectex/blob/v0.5.0/examples/figure_labels.py) renders a batch of
labels in one compilation. The
[rendering guide](https://github.com/maiani/vectex/blob/v0.5.0/docs/rendering.md#tikz-pictures) records one converter
limitation: `dvisvgm` does not translate PDF shadings, so `\shade` and
`ball color` can yield a correctly sized fragment with nothing painted in it.
VecTeX emits `EmptyRenderWarning` for SVGs without graphical elements outside
definitions, including cached results. This detects wholly empty output, not
partially missing shading. Flat fills, patterns, opacity, and `pgfplots` convert
normally. Use `warnings.simplefilter("error", vectex.EmptyRenderWarning)` to
treat the warning as a build failure.

## Embedding and adapters

`to_lxml()` returns a fresh element on every call, so editing it cannot mutate
the fragment's canonical serialization:

```python
from lxml import etree

document = etree.fromstring('<svg xmlns="http://www.w3.org/2000/svg"/>')
document.append(fragment.to_lxml())
```

The optional adapters wrap the complete normalized XML rather than translating
arbitrary SVG into a smaller object model:

```python
import drawsvg
import svg

svg_py_document = svg.SVG(
    width=fragment.width_px,
    height=fragment.height_px,
    elements=[fragment.to_svg_py()],
)

drawing = drawsvg.Drawing(fragment.width_px, fragment.height_px)
drawing.append(fragment.to_drawsvg())
```

Both documents measure in CSS px, so the wrappers scale the label from TeX
points to px and it keeps its size; `width_px`, `height_px`, and `baseline_px`
measure it there, and `unit="pt"` gives the canonical group unscaled. Either
wrapper can also place the label by a point of its box, its baseline included:
`fragment.to_svg_py(x=40, y=120, valign="baseline")`.

## TexText editing in Inkscape

TexText recognizes editable nodes by attributes in its namespace on the outer
`<g>`. VecTeX writes the current compatibility fields: encoded source, compiler,
converter marker, preamble-file path, scale, alignment, version, and transform
Jacobian. Insert the outer group itself and select that whole group before
opening TexText; TexText rejects a selected nested path or subgroup. Because
both tools treat the stored text as a document body, it recompiles without
translation.

TexText stores its preamble as a file path, while VecTeX accepts preamble
content. If re-editing must use the same custom preamble, pass both:

```python
fragment = vectex.render(
    r"$\operatorname{rank}(A)$",
    preamble="\\documentclass{standalone}\n\\usepackage{amsmath}",
    textext_preamble_file="/absolute/shared/preamble.tex",
)
```

The path must be accessible to TexText on the editing machine; the preamble
content itself is kept in VecTeX metadata. Pass `textext_compatible=False` to
omit all TexText attributes.

## Executable discovery and configuration

Built-in components resolve `pdflatex`, `xelatex`, `lualatex`, and `dvisvgm`
with `shutil.which`. Exact overrides make discovery explicit:

```python
fragment = vectex.render(
    "$x+y$",
    executable_overrides={
        "pdflatex": "/opt/texlive/bin/pdflatex",
        "dvisvgm": "/opt/texlive/bin/dvisvgm",
    },
    timeout=20,
    compiler_args=("--synctex=0",),
    converter_args=("--precision=6",),
)
```

Argument options are sequences, never shell strings, and VecTeX never uses
`shell=True`. `timeout` limits each external process separately, TeX and then
dvisvgm, and enters neither the cache key nor the ids. Nonzero exits and
timeouts raise `CompilationError` or `ConversionError` carrying argv, return
code, stdout, and stderr. A TeX failure's message is the TeX error and its
context rather than the whole log, and in a batch it names the source that
failed, as in `TeX error in source 2 of 3, '$\\foo$': Undefined control sequence.`;
the full log stays in `stdout`. A missing executable raises
`MissingExecutableError`, which is not a `CompilationError`; every VecTeX
error derives from `VectexError`, and the
[rendering guide](https://github.com/maiani/vectex/blob/v0.5.0/docs/rendering.md#errors)
lists them. Applications may implement the `Compiler` and `Converter` protocols
and pass
[component objects](https://github.com/maiani/vectex/blob/v0.5.0/docs/rendering.md#custom-components)
instead of built-in names.

## Batch rendering and disk cache

`render_many([a, b, ...])` shares one compiler and one `dvisvgm` invocation
while preserving each expression's crop and baseline. A source may also be a
`RenderItem` carrying any option that shapes its fragment; options left as
`None` take the batch value
([merge rules](https://github.com/maiani/vectex/blob/v0.5.0/docs/rendering.md#batches-and-persistent-cache)).
Items that can share a compilation are grouped, so labels differing only in
size still cost one invocation, while an item with its own preamble or engine
forms its own group. Fragments come back in input order. `render()` also
accepts a `RenderItem`.

The persistent cache is enabled with `cache_dir=` or the `VECTEX_CACHE_DIR`
environment variable; once the variable is set, every call and the `vectex`
command use the cache, unless a call passes `cache_dir=False`. Entries are keyed by the render options, executable
overrides included, and by each tool's identity: for a built-in component, the
executable's resolved path and the first line of its `--version` output. A TeX
or `dvisvgm` upgrade therefore misses old records, but an update to a LaTeX
package, class, or font, to `mutool`, or to Ghostscript does not; pass
`refresh=True` or call `vectex.clear_cache(directory)` after one. Entries are
checksummed and written atomically, and corrupt ones are treated as misses.
A cache that cannot be written costs only its records: the render goes on and
warns with `CacheWarning`.

`refresh=True` recompiles every item of the call and is ignored without a
cache. `clear_cache()` removes only VecTeX's records and returns how many it
removed. A custom component is cached only if it has an `identity()` method
([custom components](https://github.com/maiani/vectex/blob/v0.5.0/docs/rendering.md#custom-components));
with a cache enabled, by either route, a component without one and
`unique_ids=True` raise `ConfigurationError`. Enabling caching or refreshing a
record does not change SVG IDs or serialization.

## Fragment guarantees

A successful render returns exactly one SVG `<g>` root with:

- copied converter definitions, in order of first use, and visible elements;
- a deterministic, input-derived id prefix, with `href`, `xlink:href`, and
  `url(#...)` references rewritten, including inside inline styles;
- the source viewport represented by an inner matrix transform;
- normalized width, height, view box, scale, and measurable baseline;
- TeX's default black, in fills and strokes, as `currentColor`, so a label
  takes the CSS `color` of where it is placed -- or `color=` at render time --
  rules included, while parts given another colour in TeX keep it (an explicit
  `black` cannot be told from the default and follows too);
- deterministic repeated serialization;
- a VecTeX `<metadata>` child with format version, source, engine, converter,
  geometry, and preamble/options;
- TexText edit attributes unless disabled.

Identical render inputs serialize identically. Without `id_prefix`, the id
prefix is hashed from the inputs that shape the output: the source, preamble or
extra packages, engine and converter names and arguments, `scale` or `size_pt`,
`baseline`, `color`, and the TexText options. The timeout, executable
overrides, cache settings, and installed tools do not enter it.
`id_prefix="einstein"` names the outer group `einstein-root` and rewritten
definitions `einstein-0`, …; the CLI spells it `--id-prefix einstein`.
`render_many(..., id_prefix="labels")` suffixes the prefix by input position.
To embed the same render more than once in one SVG, give each copy its own
`id_prefix`. `unique_ids=True` draws a random prefix instead, so its output
differs on every run, and it cannot be combined with `id_prefix` or a disk
cache.

## Security and trust assumptions

The XML parser disables DTD loading, entity resolution, network access,
recovery, comments, and processing instructions. Normalization rejects scripts,
`foreignObject`, SVG animation, event handlers, `<style>` elements, `class`
attributes, CSS imports, escapes, and comments, `xml:base`, external hrefs and
URLs, duplicate ids, and unresolved local references, so a fragment carries no
active content and no dependency on destination stylesheets beyond the `color`
it inherits.

LaTeX is a powerful program, not a sandbox. VecTeX passes `-no-shell-escape` to
the built-in engines, but a malicious source or compiler option can still read
files or consume resources. Compile only trusted source, and use an OS or
container sandbox for untrusted input. Executable overrides, preamble content,
and extra argv values are trusted configuration.

## Stability and supported platforms

VecTeX is beta. The API is settled enough to build on, and what may still change
is written down here rather than discovered in a release.

**Public API.** The supported surface is the names in `vectex.__all__` and the
`vectex` command-line interface. Module layout, private helpers, and the shape
of intermediate records may change in any release. The package ships a
`py.typed` marker, so consumers type-check against that surface.

**Versioning.** VecTeX follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
Before 1.0 a minor release may still break the public API, but never silently:
every break is listed under `Changed` or `Removed` in
[`CHANGELOG.md`](https://github.com/maiani/vectex/blob/v0.5.0/CHANGELOG.md). Patch releases never break it. 1.0 will freeze
the surface above for the 1.x series.

**Output stability** is a separate and stronger promise: identical render inputs
serialize identically (see [Fragment guarantees](#fragment-guarantees)). With
the same VecTeX version and the same TeX and dvisvgm installation, treat a
change in bytes for unchanged inputs as a bug.

**Supported platforms.** VecTeX is pure Python driving an external TeX
toolchain, so support is bounded by what continuous integration exercises:

| | Python | Unit tests | Real TeX toolchain |
| --- | --- | --- | --- |
| Linux | 3.12 and 3.14 | yes | yes (Python 3.14): pdflatex, xelatex, lualatex, dvisvgm |
| macOS | 3.14 | yes | not exercised |
| Windows | 3.14 | yes | not exercised |

Python 3.13 is not built separately, on the assumption that a pure-Python
package passing at both ends of its range passes between them. On macOS and
Windows the unit suite covers executable lookup and path handling, but no job
installs TeX: rendering is expected to work with TeX on `PATH` and is not
verified by CI. Report a platform failure as a bug; the gap is in the testing,
not the intent.

## Development

Unit tests use checked-in SVG fixtures and mocked subprocesses, and need no TeX
installation:

```bash
uv sync --all-extras
uv run ruff format --check .
uv run ruff check .
uv run ty check
uv run pytest
uv run python -m build
```

The real-tool tests are opt-in locally because they need TeX. They include
`tests/test_examples.py`, which runs every script in `examples/`:

```bash
VECTEX_RUN_INTEGRATION=1 uv run pytest -m integration
uv run python examples/bloch_sphere.py --theta 50 --phi 40
```

Continuous integration runs the real-tool tests against TeX Live on every push,
every release, and nightly. That job first checks that each executable is
present, because the tests skip themselves when a tool is missing and a silent
skip would look like a pass.

## Related projects

VecTeX is developed alongside [FigWorks](https://github.com/maiani/figworks),
which composes multi-panel figures, and two other producers of editable SVG:
[VecView](https://github.com/maiani/vecview) (layered 3D schematics) and
[VecWire](https://github.com/maiani/vecwire) (circuit schematics). All four
share one premise: figures generated from code, with stable ids and
byte-identical output, that stay editable in Inkscape.

VecTeX does not depend on any of them. A composition tool needs only
`VectexFragment.to_svg_document()`, so the integration costs no import in either
direction, and VecTeX works the same against any destination that accepts SVG.

## Documentation

- [Rendering](https://github.com/maiani/vectex/blob/v0.5.0/docs/rendering.md): the pipeline, baselines, batches and cache,
  custom components, TexText contract, trust policy, and errors
- [Fragments](https://github.com/maiani/vectex/blob/v0.5.0/docs/fragments.md): placement, anchors, ids, colour, metadata, and
  caller integration
- [Development](https://github.com/maiani/vectex/blob/v0.5.0/docs/development.md): toolchain and releases

Build the site locally with `uv run zensical build`.

## License

MIT
