mirror of
https://github.com/ManimCommunity/manim.git
synced 2026-06-22 10:01:47 +00:00
Introduce first-class support for rendering text and markup via Typst (optional dependency) (#4681)
* feat: add TypstMobject and TypstMathMobject for first-class Typst support
Implement the initial TypstMobject proposal (see agents/typst.md):
- TypstMobject: renders arbitrary Typst markup to SVG via the 'typst'
Python package (self-contained Rust binary, no system install needed),
then imports through SVGMobject.
- TypstMathMobject: convenience subclass that wraps input in Typst math
delimiters ($ ... $).
Pipeline: user string → wrap in minimal Typst document (auto-sized page,
transparent background) → write .typ file → compile to SVG via
typst.compile() → import via SVGMobject → scale/center/recolor.
Key details:
- Compilation helper in manim/utils/typst_file_writing.py with SHA-256
content-hash caching (same scheme as the LaTeX pipeline).
- font_size property mirrors SingleStringMathTex: compile at fixed 11pt,
scale after import using initial_height / SCALE_FACTOR_PER_FONT_POINT.
- init_colors() recolors black submobjects to self.color (Typst default
fill is black), preserving any explicit Typst colors.
- path_string_config enables curve subdivision for smooth animation.
- 'typst' added as optional dependency group in pyproject.toml.
- 10 tests covering creation, font_size, caching, preamble, and repr.
* feat(typst): add sub-expression selection via {{ }} groups and .select()
- Override modify_xml_tree in Typst to convert data-typst-label
attributes to id attributes before svgelements parsing (avoids
attribute inheritance issue with data-* attributes)
- Add Typst.select(key) method accepting str labels or int indices
to retrieve VGroup sub-expressions from id_to_vgroup_dict
- Implement {{ }} double-brace preprocessor on TypstMath:
- {{ content }} → manimgrp("_grp-N", content) (auto-numbered)
- {{ content : label }} → manimgrp("label", content) (named)
- Skips {{ }} inside string literals and [...] content blocks
- Uses math-mode call convention (no # prefix) to keep args in
math mode
- Auto-inject manimgrp preamble when groups are detected
- Add 12 new tests covering label mapping, nesting, select(),
error handling, and preprocessor edge cases
* Remove unneeded assert line
* Generalize some more critical hard-coded LaTeX assumptions
* Fix syntax error
* Support Typst labels in existing APIs
* Add ManimTextLabel typing alias
* Preserve Typst SVG stroke widths by default
* Calibrate Typst font sizing against TeX
* Add Typst mobject documentation
* Track Typst baseline frames
* Document Typst baseline frame tracking
* Add Typst section to text guide
* Polish Typst docs and label handling
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Improve Typst stroke scaling and docs examples
* Tune Typst SVG stroke scaling
* Restore Typst math example wording
* Fix pre-commit issues for Typst branch
* Fix number line runtime typing import
* Exclude Typst autogenerated .rst files
* Use Manim directive in docstrings and fix wrong key in Typst.select() docstring
* Fix GroupedMath comments
---------
Co-authored-by: Toon Verstraelen <Toon.Verstraelen@UGent.be>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Francisco Manríquez Novoa <49853152+chopan050@users.noreply.github.com>
Co-authored-by: Francisco Manríquez Novoa <francisco.manriquezn@usm.cl>
This commit is contained in:
parent
561de9d72a
commit
d999d422c9
17 changed files with 1825 additions and 58 deletions
|
|
@ -33,7 +33,7 @@ jobs:
|
|||
babel-english ctex doublestroke dvisvgm frcursive fundus-calligra jknapltx \
|
||||
mathastext microtype physics preview ragged2e relsize rsfs setspace standalone \
|
||||
wasy wasysym
|
||||
uv sync
|
||||
uv sync --extra typst
|
||||
|
||||
- name: Build and package documentation
|
||||
run: |
|
||||
|
|
|
|||
382
agents/typst_selector.md
Normal file
382
agents/typst_selector.md
Normal file
|
|
@ -0,0 +1,382 @@
|
|||
# Design: Sub-Expression Selection for `Typst` / `TypstMath`
|
||||
|
||||
## Problem Statement
|
||||
|
||||
Users need to interact with individual parts of a Typst-rendered expression:
|
||||
color a variable, animate the numerator of a fraction, morph one sub-expression
|
||||
into another, etc. The `MathTex` class solves this with:
|
||||
|
||||
1. **`{{ ... }}` double-brace notation** — splits the TeX string into named
|
||||
submobject groups at compile time.
|
||||
2. **`substrings_to_isolate` / `get_part_by_tex`** — identifies submobjects
|
||||
whose TeX source matches a given string.
|
||||
|
||||
Both mechanisms ultimately rely on injecting `\special{dvisvgm:raw <g id='...'>}`
|
||||
markers into the LaTeX source so that the resulting SVG contains `<g>` elements
|
||||
with known `id` attributes, which SVGMobject's parser maps to `VGroup`
|
||||
sub-trees via `id_to_vgroup_dict`.
|
||||
|
||||
We need an analogous mechanism for Typst.
|
||||
|
||||
## Key Discovery: `data-typst-label` in SVG Output
|
||||
|
||||
Typst's SVG renderer (`typst-svg` crate) already emits a `data-typst-label`
|
||||
attribute on `<g>` elements whenever a `GroupItem` (hard frame) carries a
|
||||
label. The relevant code path:
|
||||
|
||||
```rust
|
||||
// typst-svg/src/lib.rs — render_group()
|
||||
if let Some(label) = group.label {
|
||||
svg.init().attr("data-typst-label", label.resolve());
|
||||
}
|
||||
```
|
||||
|
||||
A **hard frame** is created by the `box` element (and `block`, etc.). Crucially,
|
||||
`box` can be used inline inside math mode, and labels can be attached to it.
|
||||
|
||||
### Proof of Concept
|
||||
|
||||
The following Typst helper wraps content in a labeled `box`:
|
||||
|
||||
```typst
|
||||
#let grp(lbl, body) = [#box(body) #label(lbl)]
|
||||
```
|
||||
|
||||
When used in math:
|
||||
|
||||
```typst
|
||||
$ #grp("numerator", $a + b$) / #grp("denom", $c - d$) = #grp("result", $x$) $
|
||||
```
|
||||
|
||||
The compiled SVG contains:
|
||||
|
||||
```xml
|
||||
<g class="typst-group" ... data-typst-label="numerator">
|
||||
<!-- glyphs for a + b -->
|
||||
</g>
|
||||
<g class="typst-group" ... data-typst-label="denom">
|
||||
<!-- glyphs for c - d -->
|
||||
</g>
|
||||
<g class="typst-group" ... data-typst-label="result">
|
||||
<!-- glyph for x -->
|
||||
</g>
|
||||
```
|
||||
|
||||
**Nesting works.** A `grp` wrapping a fraction that itself contains `grp`-ed
|
||||
sub-parts produces nested `data-typst-label` groups:
|
||||
|
||||
```typst
|
||||
$ #grp("whole-frac", $frac(#grp("numerator", $a + b$), #grp("denom", $c - d$))$) $
|
||||
```
|
||||
|
||||
SVG output:
|
||||
|
||||
```xml
|
||||
<g ... data-typst-label="whole-frac">
|
||||
<g ... data-typst-label="numerator"> ... </g>
|
||||
<g ... data-typst-label="denom"> ... </g>
|
||||
<path class="typst-shape" ... /> <!-- fraction bar -->
|
||||
</g>
|
||||
```
|
||||
|
||||
### SVG Parser Compatibility
|
||||
|
||||
Manim uses `svgelements` to parse SVGs. The library preserves
|
||||
`data-typst-label` in the `values` dictionary of `Group` objects, and it
|
||||
propagates to child elements. Manim's `SVGMobject.get_mobjects_from()` already
|
||||
iterates over groups and builds `id_to_vgroup_dict` keyed by the `id` attribute.
|
||||
Extending this to also key by `data-typst-label` is straightforward.
|
||||
|
||||
## Proposed Interface
|
||||
|
||||
### 1. Explicit Groups via `{{ ... }}` Notation (Compile-Time)
|
||||
|
||||
Mirror the `MathTex` double-brace convention. Users write:
|
||||
|
||||
```python
|
||||
eq = TypstMath("{{ a + b }} / {{ c - d }} = {{ x }}")
|
||||
```
|
||||
|
||||
The pre-processor splits on `{{ ... }}` (reusing the same whitespace-guard
|
||||
rules as `MathTex._split_double_braces`) and wraps each group in a labeled
|
||||
`box` call:
|
||||
|
||||
```typst
|
||||
$ #box[$a + b$] <_grp-0> / #box[$c - d$] <_grp-1> = #box[$x$] <_grp-2> $
|
||||
```
|
||||
|
||||
Each group gets an auto-generated label (`_grp-0`, `_grp-1`, ...).
|
||||
The `data-typst-label` attributes then appear in the SVG, and
|
||||
`SVGMobject.get_mobjects_from()` can map them to `VGroup` entries in
|
||||
`label_to_vgroup_dict` (or reuse `id_to_vgroup_dict`).
|
||||
|
||||
These groups become sub-mobjects of the `TypstMath` instance, accessible by
|
||||
index:
|
||||
|
||||
```python
|
||||
eq[0] # VGroup for "a + b"
|
||||
eq[1] # VGroup for "c - d"
|
||||
eq[2] # VGroup for "x"
|
||||
```
|
||||
|
||||
(Non-group content between groups — like `/` and `=` — also becomes
|
||||
its own submobject, mirroring `MathTex` behavior.)
|
||||
|
||||
**For `Typst` (text mode):** the same `{{ ... }}` notation applies, but the
|
||||
wrapper is `#box[...]` without math delimiters.
|
||||
|
||||
### 2. Named Groups via Labels
|
||||
|
||||
Users can also assign explicit label names for retrieval by name:
|
||||
|
||||
```python
|
||||
eq = Typst(
|
||||
r"$ #box[$a + b$] <numerator> / #box[$c - d$] <denom> $"
|
||||
)
|
||||
eq.select("numerator").set_color(RED)
|
||||
eq.select("denom").set_color(BLUE)
|
||||
```
|
||||
|
||||
Alternatively, an even more ergonomic approach that hides the `box` boilerplate
|
||||
and uses the `{{ ... : label }}` notation:
|
||||
|
||||
```python
|
||||
eq = TypstMath("{{ a + b : numerator }} / {{ c - d : denom }}")
|
||||
eq.select("numerator").set_color(RED)
|
||||
```
|
||||
|
||||
Here the pre-processor recognizes `{{ content : label }}` and emits
|
||||
`#box[$content$] <label>` in the Typst source.
|
||||
|
||||
### 3. The `.select()` Method
|
||||
|
||||
```python
|
||||
def select(self, key: str | int) -> VGroup:
|
||||
"""Select a labeled sub-expression.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
key
|
||||
Either a label name (string) matching a ``data-typst-label``
|
||||
in the SVG, or an integer index into the auto-numbered
|
||||
``{{ ... }}`` groups.
|
||||
|
||||
Returns
|
||||
-------
|
||||
VGroup
|
||||
The sub-mobjects corresponding to the selected group.
|
||||
|
||||
Raises
|
||||
------
|
||||
KeyError
|
||||
If no group with the given label/index exists.
|
||||
"""
|
||||
```
|
||||
|
||||
This returns a `VGroup` containing exactly the submobjects (paths) that
|
||||
were rendered inside the corresponding `<g data-typst-label="...">` in the SVG.
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Step 1: Extend `SVGMobject.get_mobjects_from()` to Track Labels
|
||||
|
||||
In `manim/mobject/svg/svg_mobject.py`, the group-walking loop already checks
|
||||
for `id` attributes. Add a parallel check for `data-typst-label`:
|
||||
|
||||
```python
|
||||
try:
|
||||
group_name = str(element.values["id"])
|
||||
except Exception:
|
||||
# Fall back to data-typst-label if available
|
||||
label = element.values.get("data-typst-label")
|
||||
if label:
|
||||
group_name = f"typst-label:{label}"
|
||||
else:
|
||||
group_name = f"numbered_group_{group_id_number}"
|
||||
group_id_number += 1
|
||||
```
|
||||
|
||||
This automatically populates `id_to_vgroup_dict` with label-keyed entries.
|
||||
|
||||
### Step 2: Pre-Processing `{{ ... }}` in Typst Source
|
||||
|
||||
Add a `_split_and_label_groups()` method that:
|
||||
|
||||
1. Scans the input for `{{ ... }}` or `{{ ... : label }}` patterns
|
||||
(using the same whitespace-guard rules as `MathTex._split_double_braces`).
|
||||
2. Replaces each group with `#box[$content$] <label>` (math mode) or
|
||||
`#box[content] <label>` (text mode).
|
||||
3. Records the mapping from label → original source string for later lookup.
|
||||
|
||||
### Step 3: `Typst.select()` / Index Access
|
||||
|
||||
- Store the ordered list of group labels and their source strings.
|
||||
- `select(label_or_index)` looks up the corresponding `VGroup` from
|
||||
`id_to_vgroup_dict` (using the `typst-label:...` key).
|
||||
- `__getitem__(int)` returns the *n*-th group's `VGroup`.
|
||||
|
||||
### Step 4: Compatibility with `TransformMatchingTex` (future)
|
||||
|
||||
`TransformMatchingTex` (and its successor `TransformMatchingShapes`) works by
|
||||
matching submobjects between two `MathTex` instances by their TeX string keys.
|
||||
The same approach extends to `Typst` if each `{{ ... }}` group carries its
|
||||
original source string as metadata. A `TransformMatchingTypst` animation could
|
||||
match groups by label name or by source string equality.
|
||||
|
||||
## Open Design Questions
|
||||
|
||||
### Q1: Context-Aware Wrapping — Math vs. Text Mode
|
||||
|
||||
The `box` + `label` mechanism works identically in math and text mode, but the
|
||||
**wrapping** of group content must match the surrounding context:
|
||||
|
||||
- **In text mode:** `{{ Hello : greeting }}` → `#box[Hello] <greeting>`
|
||||
- **In math mode:** `{{ y^2 : second }}` → `#box[$y^2$] <second>`
|
||||
|
||||
Getting this wrong is not a silent error — it produces visually broken output.
|
||||
Wrapping math content with `#box[y^2]` (no `$...$`) renders `y^2` as literal
|
||||
text in the body font instead of as a math superscript.
|
||||
|
||||
This is a real problem for `Typst()`, where a single source string can mix text
|
||||
and math freely:
|
||||
|
||||
```python
|
||||
Typst("hello world, here is a formula: $x^2 + {{ y^2 : second }} = z^2$")
|
||||
```
|
||||
|
||||
Here `{{ y^2 : second }}` is inside a `$ ... $` block, so it needs the
|
||||
math-mode wrapper, but the pre-processor has no way to know this unless it
|
||||
tracks `$` delimiters.
|
||||
|
||||
### The `#` prefix problem and math calls
|
||||
|
||||
A natural idea is to translate `{{ content }}` into a Typst function call like
|
||||
`grp("lbl", content)`. However, this has a subtle but critical context
|
||||
sensitivity: Typst has two different call conventions depending on context:
|
||||
|
||||
- **Math call** (no `#` prefix): `$ grp("lbl", a^2 + b) $` — arguments are
|
||||
parsed **in math mode**. The content `a^2 + b` is math. ✓
|
||||
- **Code call** (`#` prefix): `$ #grp("lbl", a^2 + b) $` — arguments are
|
||||
parsed **in code mode**. `a^2` is a syntax error in code! ✗
|
||||
|
||||
So in math mode, the function MUST be called without `#` for args to stay in
|
||||
math mode. In text/markup mode, the function MUST be called WITH `#` (that's
|
||||
how you invoke code from markup), and content arguments need `[...]` wrapping:
|
||||
|
||||
```typst
|
||||
// Text context: #grp("lbl", [Hello world])
|
||||
// Math context: grp("lbl", a^2 + b)
|
||||
```
|
||||
|
||||
The function definition is the same either way:
|
||||
```typst
|
||||
#let grp(lbl, body) = [#box(body) #label(lbl)]
|
||||
```
|
||||
|
||||
This means the function call approach has **exactly the same context problem**
|
||||
as the raw `#box` approach: the pre-processor must know whether it's in math or
|
||||
text to emit the right calling convention.
|
||||
|
||||
### Further complication: string literals and content blocks
|
||||
|
||||
Even inside `TypstMath` (where everything is math), the scanner must avoid
|
||||
`{{ }}` matches inside string literals or content blocks:
|
||||
|
||||
```python
|
||||
TypstMath('x^2 + y^2 =_("Hello {{ world }}") z^2')
|
||||
```
|
||||
|
||||
Here `{{ world }}` is inside a `"..."` string literal — it should NOT be
|
||||
processed. Similarly, content blocks `[...]` inside math switch back to text
|
||||
mode.
|
||||
|
||||
### Options
|
||||
|
||||
**A. `TypstMath`: math calls with simple string-aware scanning.**
|
||||
For `TypstMath`, the entire body is math, so `{{ content }}` always becomes
|
||||
`grp("_grp-N", content)` (no `#`, no `$...$`). The scanner just needs to
|
||||
skip `"..."` string literals and `[...]` content blocks — no `$` tracking
|
||||
needed. This is clean and robust.
|
||||
|
||||
**B. `Typst`: context-aware scanning (full parser).**
|
||||
For the general `Typst` class, the scanner must additionally track `$...$`
|
||||
math blocks (toggling a mode flag on unescaped `$`) to choose between
|
||||
`grp(...)` (in math) and `#grp("lbl", [...])` (in text). It must also handle
|
||||
string literals and content blocks inside math that switch context back. This
|
||||
is doable but non-trivial — essentially a mini Typst lexer.
|
||||
|
||||
**C. `Typst`: no `{{ }}`, manual groups only.**
|
||||
For the general `Typst` class, don't support `{{ }}` at all. Users write
|
||||
`grp(...)` / `#grp(...)` themselves (with the helper injected into the
|
||||
preamble). `{{ }}` is only available on `TypstMath`. This is simpler and
|
||||
avoids the parsing complexity, at the cost of ergonomics for mixed-mode
|
||||
documents.
|
||||
|
||||
**Recommendation:** Start with A (TypstMath only) and C (manual for Typst).
|
||||
Upgrade to B later if demand warrants it — the function call infrastructure
|
||||
is already in place, it's only the scanner that needs upgrading.
|
||||
|
||||
### Q2: What about "unlabeled" content between groups?
|
||||
|
||||
Like `MathTex`, the pieces of content *between* `{{ ... }}` groups should also
|
||||
become their own submobjects (auto-labeled with sequential indices). For
|
||||
example:
|
||||
|
||||
```python
|
||||
TypstMath("{{ a }} + {{ b }} = {{ c }}")
|
||||
# group-0: "a"
|
||||
# group-1: "+" (auto-group for inter-group content)
|
||||
# group-2: "b"
|
||||
# group-3: "=" (auto-group for inter-group content)
|
||||
# group-4: "c"
|
||||
```
|
||||
|
||||
Each segment (group or inter-group) gets wrapped in its own labeled `box`.
|
||||
|
||||
### Q3: What happens with `box` and baseline alignment?
|
||||
|
||||
`box` is an inline element in Typst, and when used inside math mode it
|
||||
participates in math layout. Testing confirms that fractions, superscripts, and
|
||||
other constructs render correctly when their children are `box`-wrapped.
|
||||
However, `box` creates a "hard frame" boundary which may subtly affect spacing
|
||||
in edge cases (e.g., math operator spacing around a boxed expression). This
|
||||
needs further testing; if issues arise, we could explore `block(breakable: false)`
|
||||
or invisible `rect` wrappers as alternatives.
|
||||
|
||||
### Q4: Can we avoid the `#grp(...)` / `#box[...] <label>` verbosity?
|
||||
|
||||
Yes — the `{{ ... }}` double-brace notation is purely syntactic sugar that gets
|
||||
pre-processed by Manim before the source reaches the Typst compiler. Users never
|
||||
need to write raw `#box` or `#label()` calls unless they want finer control.
|
||||
|
||||
### Q5: String-based selection without explicit groups?
|
||||
|
||||
A future enhancement could support:
|
||||
|
||||
```python
|
||||
eq = TypstMath(r"a + b = c")
|
||||
eq.select("a") # finds submobjects corresponding to the glyph "a"
|
||||
```
|
||||
|
||||
This is hard to do reliably because:
|
||||
- Typst SVGs embed glyphs as `<use xlink:href="#gXXX">` references; there's no
|
||||
text content in the SVG itself.
|
||||
- A single variable in Typst may span multiple glyphs (e.g., `"alpha"` → one
|
||||
glyph) or identical glyphs may appear multiple times.
|
||||
|
||||
A possible approach: at pre-processing time, wrap every "token" in the Typst
|
||||
math source in its own labeled `box`. This would require a Typst math tokenizer
|
||||
and is better suited for a v2 implementation.
|
||||
|
||||
## Summary: What Typst Gives Us
|
||||
|
||||
| Mechanism | How it works | SVG output |
|
||||
|---|---|---|
|
||||
| `#box(body) <label>` | Creates a hard-frame `GroupItem` with a `Label` | `<g data-typst-label="label">...</g>` |
|
||||
| `#metadata(val) <label>` | Invisible; queryable via `typst query` CLI | No visual output (useful for CLI queries, not SVG) |
|
||||
| Show rules on labels | `#show <label>: ...` | Transforms visual output but no automatic SVG grouping |
|
||||
| `context query(<label>)` | Document introspection (positions, counters) | In-document only; not available from Python |
|
||||
|
||||
The `box` + `label` mechanism is the **only** one that produces identifiable
|
||||
groups in the SVG output, making it the correct tool for sub-expression
|
||||
selection in Manim.
|
||||
|
|
@ -5,3 +5,4 @@ sphinx-copybutton
|
|||
sphinxext-opengraph
|
||||
sphinx-design
|
||||
sphinx-reredirects
|
||||
typst>=0.14
|
||||
|
|
|
|||
|
|
@ -1,2 +1,3 @@
|
|||
jupyterlab
|
||||
sphinxcontrib-programoutput
|
||||
typst>=0.14
|
||||
|
|
|
|||
|
|
@ -2,17 +2,24 @@
|
|||
Rendering Text and Formulas
|
||||
###########################
|
||||
|
||||
There are two different ways by which you can render **Text** in videos:
|
||||
There are three different ways by which you can render **Text** in videos:
|
||||
|
||||
1. Using Pango (:mod:`~.text_mobject`)
|
||||
2. Using LaTeX (:mod:`~.tex_mobject`)
|
||||
3. Using Typst (:mod:`~.typst_mobject`)
|
||||
|
||||
If you want to render simple text, you should use either :class:`~.Text` or
|
||||
:class:`~.MarkupText`, or one of its derivatives like :class:`~.Paragraph`.
|
||||
Manim's Pango-based text classes include :class:`~.Text`,
|
||||
:class:`~.MarkupText`, and derivatives such as :class:`~.Paragraph`.
|
||||
See :ref:`using-text-objects` for more information.
|
||||
|
||||
LaTeX should be used when you need mathematical typesetting. See
|
||||
:ref:`rendering-with-latex` for more information.
|
||||
LaTeX rendering is available via :class:`~.Tex` and
|
||||
:class:`~.MathTex`. See :ref:`rendering-with-latex` for more
|
||||
information.
|
||||
|
||||
Typst support is available via :class:`~.Typst` and
|
||||
:class:`~.TypstMath`. It offers both general markup and mathematical
|
||||
typesetting through the Typst compiler without requiring a TeX
|
||||
distribution. See :ref:`typst-mobjects` for more information.
|
||||
|
||||
.. _using-text-objects:
|
||||
|
||||
|
|
@ -291,6 +298,54 @@ and further references about PangoMarkup.
|
|||
)
|
||||
self.add(text)
|
||||
|
||||
.. _rendering-with-typst:
|
||||
|
||||
Text With Typst
|
||||
***************
|
||||
|
||||
Manim also supports rendering text and formulas with Typst via
|
||||
:class:`~.Typst` and :class:`~.TypstMath`.
|
||||
|
||||
.. important::
|
||||
|
||||
Typst support requires the optional ``typst`` dependency. Install it with
|
||||
``pip install manim[typst]``.
|
||||
|
||||
Typst mobjects compile Typst markup directly to SVG and import the result as
|
||||
vector graphics. This works both for general markup and for mathematical
|
||||
expressions.
|
||||
|
||||
.. manim:: HelloTypst
|
||||
:save_last_frame:
|
||||
:ref_classes: Typst
|
||||
|
||||
class HelloTypst(Scene):
|
||||
def construct(self):
|
||||
text = Typst(r"*Hello* from _Typst!_", font_size=96)
|
||||
self.add(text)
|
||||
|
||||
For mathematical expressions, use :class:`~.TypstMath`:
|
||||
|
||||
.. manim:: HelloTypstMath
|
||||
:save_last_frame:
|
||||
:ref_classes: TypstMath
|
||||
|
||||
class HelloTypstMath(Scene):
|
||||
def construct(self):
|
||||
equation = TypstMath(r"sum_(k=1)^n k = (n(n + 1)) / 2", font_size=72)
|
||||
self.add(equation)
|
||||
|
||||
Typst also supports selecting subexpressions via labels in the Typst source,
|
||||
or via Manim's ``{{ ... }}`` shorthand in :class:`~.TypstMath`:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
eq = TypstMath("{{ a + b : lhs }} = {{ c }}")
|
||||
eq.select("lhs").set_color(BLUE)
|
||||
eq.select(0).set_color(YELLOW)
|
||||
|
||||
See :ref:`typst-mobjects` for more details and additional examples.
|
||||
|
||||
.. _rendering-with-latex:
|
||||
|
||||
Text With LaTeX
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ from .mobject.text.code_mobject import *
|
|||
from .mobject.text.numbers import *
|
||||
from .mobject.text.tex_mobject import *
|
||||
from .mobject.text.text_mobject import *
|
||||
from .mobject.text.typst_mobject import *
|
||||
from .mobject.three_d.polyhedra import *
|
||||
from .mobject.three_d.three_d_utils import *
|
||||
from .mobject.three_d.three_dimensions import *
|
||||
|
|
|
|||
|
|
@ -15,14 +15,15 @@ from manim.mobject.geometry.shape_matchers import (
|
|||
BackgroundRectangle,
|
||||
SurroundingRectangle,
|
||||
)
|
||||
from manim.mobject.text.tex_mobject import MathTex, Tex
|
||||
from manim.mobject.text.tex_mobject import MathTex
|
||||
from manim.mobject.text.text_mobject import Text
|
||||
from manim.mobject.text.typst_mobject import Typst
|
||||
from manim.mobject.types.vectorized_mobject import VGroup
|
||||
from manim.utils.color import WHITE
|
||||
from manim.utils.polylabel import polylabel
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from manim.typing import Point3DLike_Array
|
||||
from manim.typing import ManimTextLabel, Point3DLike_Array
|
||||
|
||||
|
||||
class Label(VGroup):
|
||||
|
|
@ -61,7 +62,7 @@ class Label(VGroup):
|
|||
|
||||
def __init__(
|
||||
self,
|
||||
label: str | Tex | MathTex | Text,
|
||||
label: str | ManimTextLabel,
|
||||
label_config: dict[str, Any] | None = None,
|
||||
box_config: dict[str, Any] | None = None,
|
||||
frame_config: dict[str, Any] | None = None,
|
||||
|
|
@ -94,13 +95,15 @@ class Label(VGroup):
|
|||
frame_config = default_frame_config | (frame_config or {})
|
||||
|
||||
# Determine the type of label and instantiate the appropriate object
|
||||
self.rendered_label: MathTex | Tex | Text
|
||||
self.rendered_label: ManimTextLabel
|
||||
if isinstance(label, str):
|
||||
self.rendered_label = MathTex(label, **label_config)
|
||||
elif isinstance(label, (MathTex, Tex, Text)):
|
||||
elif isinstance(label, (MathTex, Text, Typst)):
|
||||
self.rendered_label = label
|
||||
else:
|
||||
raise TypeError("Unsupported label type. Must be MathTex, Tex, or Text.")
|
||||
raise TypeError(
|
||||
"Unsupported label type. Must be MathTex, Tex, Text, Typst, or TypstMath."
|
||||
)
|
||||
|
||||
# Add a background box
|
||||
self.background_rect = BackgroundRectangle(self.rendered_label, **box_config)
|
||||
|
|
@ -155,7 +158,7 @@ class LabeledLine(Line):
|
|||
|
||||
def __init__(
|
||||
self,
|
||||
label: str | Tex | MathTex | Text,
|
||||
label: str | ManimTextLabel,
|
||||
label_position: float = 0.5,
|
||||
label_config: dict[str, Any] | None = None,
|
||||
box_config: dict[str, Any] | None = None,
|
||||
|
|
@ -343,7 +346,7 @@ class LabeledPolygram(Polygram):
|
|||
def __init__(
|
||||
self,
|
||||
*vertex_groups: Point3DLike_Array,
|
||||
label: str | Tex | MathTex | Text,
|
||||
label: str | ManimTextLabel,
|
||||
precision: float = 0.01,
|
||||
label_config: dict[str, Any] | None = None,
|
||||
box_config: dict[str, Any] | None = None,
|
||||
|
|
|
|||
|
|
@ -8,14 +8,14 @@ from manim.mobject.opengl.opengl_vectorized_mobject import OpenGLVMobject
|
|||
__all__ = ["NumberLine", "UnitInterval"]
|
||||
|
||||
|
||||
from collections.abc import Callable, Iterable, Sequence
|
||||
from typing import TYPE_CHECKING
|
||||
from collections.abc import Iterable, Sequence
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any, Self
|
||||
from typing import Self
|
||||
|
||||
from manim.mobject.geometry.tips import ArrowTip
|
||||
from manim.typing import Point3D, Point3DLike, Vector3D
|
||||
from manim.typing import ManimTextLabel, Point3D, Point3DLike, Vector3D
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
|
@ -23,9 +23,9 @@ from manim import config
|
|||
from manim.constants import *
|
||||
from manim.mobject.geometry.line import Line
|
||||
from manim.mobject.graphing.scale import LinearBase, _ScaleBase
|
||||
from manim.mobject.text.numbers import DecimalNumber, Integer
|
||||
from manim.mobject.text.tex_mobject import MathTex, Tex
|
||||
from manim.mobject.text.text_mobject import Text
|
||||
from manim.mobject.text.numbers import DecimalNumber
|
||||
from manim.mobject.text.tex_mobject import MathTex, SingleStringMathTex, Tex
|
||||
from manim.mobject.text.typst_mobject import Typst, TypstMath
|
||||
from manim.mobject.types.vectorized_mobject import VGroup, VMobject
|
||||
from manim.utils.bezier import interpolate
|
||||
from manim.utils.config_ops import merge_dicts_recursively
|
||||
|
|
@ -161,7 +161,7 @@ class NumberLine(Line):
|
|||
include_numbers: bool = False,
|
||||
font_size: float = 36,
|
||||
label_direction: Point3DLike = DOWN,
|
||||
label_constructor: type[MathTex] = MathTex,
|
||||
label_constructor: type[ManimTextLabel] = MathTex,
|
||||
scaling: _ScaleBase = LinearBase(),
|
||||
line_to_number_buff: float = MED_SMALL_BUFF,
|
||||
decimal_number_config: dict | None = None,
|
||||
|
|
@ -450,7 +450,7 @@ class NumberLine(Line):
|
|||
direction: Vector3D | None = None,
|
||||
buff: float | None = None,
|
||||
font_size: float | None = None,
|
||||
label_constructor: type[MathTex] | None = None,
|
||||
label_constructor: type[SingleStringMathTex] | None = None,
|
||||
**number_config: dict[str, Any],
|
||||
) -> VMobject:
|
||||
"""Generates a positioned :class:`~.DecimalNumber` mobject
|
||||
|
|
@ -487,7 +487,7 @@ class NumberLine(Line):
|
|||
if font_size is None:
|
||||
font_size = self.font_size
|
||||
if label_constructor is None:
|
||||
label_constructor = self.label_constructor
|
||||
label_constructor = cast(type[SingleStringMathTex], self.label_constructor)
|
||||
|
||||
num_mob = DecimalNumber(
|
||||
x,
|
||||
|
|
@ -515,7 +515,7 @@ class NumberLine(Line):
|
|||
x_values: Iterable[float] | None = None,
|
||||
excluding: Iterable[float] | None = None,
|
||||
font_size: float | None = None,
|
||||
label_constructor: type[MathTex] | None = None,
|
||||
label_constructor: type[SingleStringMathTex] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Self:
|
||||
"""Adds :class:`~.DecimalNumber` mobjects representing their position
|
||||
|
|
@ -547,7 +547,7 @@ class NumberLine(Line):
|
|||
font_size = self.font_size
|
||||
|
||||
if label_constructor is None:
|
||||
label_constructor = self.label_constructor
|
||||
label_constructor = cast(type[SingleStringMathTex], self.label_constructor)
|
||||
|
||||
numbers = VGroup()
|
||||
for x in x_values:
|
||||
|
|
@ -571,7 +571,7 @@ class NumberLine(Line):
|
|||
direction: Point3DLike | None = None,
|
||||
buff: float | None = None,
|
||||
font_size: float | None = None,
|
||||
label_constructor: type[MathTex] | None = None,
|
||||
label_constructor: type[ManimTextLabel] | None = None,
|
||||
) -> Self:
|
||||
"""Adds specifically positioned labels to the :class:`~.NumberLine` using a ``dict``.
|
||||
The labels can be accessed after creation via ``self.labels``.
|
||||
|
|
@ -609,14 +609,18 @@ class NumberLine(Line):
|
|||
# TODO: remove this check and ability to call
|
||||
# this method via CoordinateSystem.add_coordinates()
|
||||
# must be explicitly called
|
||||
if isinstance(label, str) and label_constructor is MathTex:
|
||||
label = Tex(label)
|
||||
if isinstance(label, str):
|
||||
if label_constructor is MathTex:
|
||||
label = Tex(label)
|
||||
elif label_constructor is TypstMath:
|
||||
label = Typst(label)
|
||||
else:
|
||||
label = self._create_label_tex(label, label_constructor)
|
||||
else:
|
||||
label = self._create_label_tex(label, label_constructor)
|
||||
|
||||
if hasattr(label, "font_size"):
|
||||
assert isinstance(label, (MathTex, Tex, Text, Integer)), label
|
||||
label.font_size = font_size
|
||||
cast(Any, label).font_size = font_size
|
||||
else:
|
||||
raise AttributeError(f"{label} is not compatible with add_labels.")
|
||||
label.next_to(self.number_to_point(x), direction=direction, buff=buff)
|
||||
|
|
@ -629,7 +633,7 @@ class NumberLine(Line):
|
|||
def _create_label_tex(
|
||||
self,
|
||||
label_tex: str | float | VMobject,
|
||||
label_constructor: Callable | None = None,
|
||||
label_constructor: type[ManimTextLabel] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> VMobject:
|
||||
"""Checks if the label is a :class:`~.VMobject`, otherwise, creates a
|
||||
|
|
|
|||
|
|
@ -371,7 +371,7 @@ class BarChart(Axes):
|
|||
# to accommodate negative bars, the label may need to be
|
||||
# below or above the x_axis depending on the value of the bar
|
||||
direction = UP if self.values[i] < 0 else DOWN
|
||||
bar_name_label: MathTex = self.x_axis.label_constructor(bar_name)
|
||||
bar_name_label = self.x_axis.label_constructor(bar_name)
|
||||
|
||||
bar_name_label.font_size = self.x_axis.font_size
|
||||
bar_name_label.next_to(
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""Mobjects used to display Text using Pango or LaTeX.
|
||||
"""Mobjects used to display Text using Pango, LaTeX, or Typst.
|
||||
|
||||
Modules
|
||||
=======
|
||||
|
|
@ -10,4 +10,5 @@ Modules
|
|||
~numbers
|
||||
~tex_mobject
|
||||
~text_mobject
|
||||
~typst_mobject
|
||||
"""
|
||||
|
|
|
|||
818
manim/mobject/text/typst_mobject.py
Normal file
818
manim/mobject/text/typst_mobject.py
Normal file
|
|
@ -0,0 +1,818 @@
|
|||
"""Mobjects representing text rendered using Typst.
|
||||
|
||||
.. _typst-mobjects:
|
||||
|
||||
.. important::
|
||||
|
||||
The ``typst`` Python package must be installed to use these classes.
|
||||
Install it via ``pip install typst>=0.14`` or add the ``typst`` optional
|
||||
dependency group (``pip install manim[typst]``).
|
||||
|
||||
Typst mobjects compile Typst markup directly to SVG using the ``typst``
|
||||
Python package and then import the result through :class:`~.SVGMobject`.
|
||||
Use :class:`~.Typst` for general Typst markup and :class:`~.TypstMath`
|
||||
for display-style math.
|
||||
|
||||
Examples
|
||||
--------
|
||||
|
||||
Basic text and math
|
||||
^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
.. manim:: TypstTextReferenceExample
|
||||
:save_last_frame:
|
||||
:ref_classes: Typst
|
||||
|
||||
class TypstTextReferenceExample(Scene):
|
||||
def construct(self):
|
||||
text = Typst(
|
||||
r"*Hello* from _Typst!_",
|
||||
color=YELLOW,
|
||||
font_size=72,
|
||||
)
|
||||
self.add(text)
|
||||
|
||||
.. manim:: TypstMathReferenceExample
|
||||
:save_last_frame:
|
||||
:ref_classes: TypstMath
|
||||
|
||||
class TypstMathReferenceExample(Scene):
|
||||
def construct(self):
|
||||
equation = TypstMath(
|
||||
r"sum_(k=1)^n k = frac(n(n + 1), 2)",
|
||||
font_size=72,
|
||||
)
|
||||
self.add(equation)
|
||||
|
||||
Selecting subexpressions
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
Typst mobjects expose label-based selection via :meth:`~.Typst.select`.
|
||||
There are two common ways to create selectable groups:
|
||||
|
||||
- use ordinary Typst labels in :class:`~.Typst`
|
||||
- use Manim's ``{{ ... }}`` shorthand in :class:`~.TypstMath`
|
||||
|
||||
.. note::
|
||||
|
||||
The ``{{ ... }}`` shorthand is currently only supported by
|
||||
:class:`~.TypstMath`. For :class:`~.Typst`, create labels directly in the
|
||||
Typst source, for example with ``#box[body] <label>``.
|
||||
|
||||
.. manim:: TypstLabelSelectionExample
|
||||
:save_last_frame:
|
||||
:ref_classes: Typst
|
||||
:ref_methods: Typst.select
|
||||
|
||||
class TypstLabelSelectionExample(Scene):
|
||||
def construct(self):
|
||||
text = Typst(
|
||||
r'''
|
||||
#box[
|
||||
*Typst* labels also work in regular markup.
|
||||
] <headline>
|
||||
|
||||
#let pick(body) = [#box(body) <picked>]
|
||||
We can highlight #pick[multiple] #pick[fragments] at once.
|
||||
''',
|
||||
font_size=42,
|
||||
)
|
||||
text.select("headline").set_color(BLUE)
|
||||
text.select("picked").set_color(YELLOW)
|
||||
self.add(text)
|
||||
|
||||
.. manim:: TypstMathSelectionExample
|
||||
:save_last_frame:
|
||||
:ref_classes: TypstMath
|
||||
:ref_methods: Typst.select
|
||||
|
||||
class TypstMathSelectionExample(Scene):
|
||||
def construct(self):
|
||||
equation = TypstMath(
|
||||
"{{ a^2 + b^2 : lhs }} = {{ c^2 }}",
|
||||
font_size=72,
|
||||
)
|
||||
equation.select("lhs").set_color(BLUE)
|
||||
equation.select(0).set_color(YELLOW)
|
||||
self.add(equation)
|
||||
|
||||
Inspecting baseline frames
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
For debugging or alignment tasks, Typst mobjects can optionally track a
|
||||
per-element baseline frame. Enable this with ``track_baselines=True`` and
|
||||
query either :attr:`~.Typst.baseline_frames` for all tracked leaf elements or
|
||||
:meth:`~.Typst.get_baseline_frame` for a specific selected submobject.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
text = Typst("Ggf", track_baselines=True)
|
||||
orig, right, up = text.baseline_frames[0]
|
||||
|
||||
eq = TypstMath("{{ a^2 + b^2 : lhs }} = c^2", track_baselines=True)
|
||||
for part in eq.select("lhs"):
|
||||
orig, right, up = eq.get_baseline_frame(part)
|
||||
print(orig, right, up)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
__all__ = [
|
||||
"Typst",
|
||||
"TypstMath",
|
||||
]
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
import numpy as np
|
||||
import svgelements as se
|
||||
|
||||
from manim import config
|
||||
from manim.constants import DEFAULT_FONT_SIZE, SCALE_FACTOR_PER_FONT_POINT, RendererType
|
||||
from manim.mobject.svg.svg_mobject import SVGMobject
|
||||
from manim.mobject.types.vectorized_mobject import VGroup, VMobject
|
||||
from manim.utils.color import BLACK, ParsableManimColor
|
||||
from manim.utils.typst_file_writing import typst_to_svg_file
|
||||
|
||||
_MANIMGRP_PREAMBLE = "#let manimgrp(lbl, body) = [#box(body) #label(lbl)]"
|
||||
|
||||
# Pattern for the label part of {{ content : label }}.
|
||||
# The label must be a valid Typst label identifier.
|
||||
_LABEL_RE = re.compile(r"^(.*)\s*:\s*([a-zA-Z_][a-zA-Z0-9_-]*)\s*$", re.DOTALL)
|
||||
_INTERNAL_TYPST_ID_RE = re.compile(r"g[0-9A-Fa-f]+")
|
||||
_DUPLICATE_LABEL_SUFFIX = "__manim_typst_dup_"
|
||||
# Empirical correction so Typst-authored SVG strokes (fraction bars,
|
||||
# underlines, etc.) visually match the weight of TeX-derived geometry more
|
||||
# closely after import into Manim's pixel-based stroke model.
|
||||
_TYPST_SVG_STROKE_WIDTH_SCALE = 0.5
|
||||
|
||||
|
||||
class Typst(SVGMobject):
|
||||
"""A mobject rendered from a Typst markup string.
|
||||
|
||||
The Typst source is compiled to SVG via the ``typst`` Python package
|
||||
(a self-contained Rust binary extension — no system-level install
|
||||
required) and then imported through :class:`~.SVGMobject`.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
typst_code
|
||||
Raw Typst markup to be compiled. This string is placed verbatim
|
||||
into the body of a minimal Typst document.
|
||||
font_size
|
||||
Font size in Manim font-size units (default: ``DEFAULT_FONT_SIZE``,
|
||||
i.e. 48). The actual scaling is applied *after* SVG import, matching
|
||||
the approach used by :class:`~.SingleStringMathTex`.
|
||||
typst_preamble
|
||||
Extra Typst code inserted before the body. Useful for ``#import``,
|
||||
``#set``, or ``#show`` rules. Default: ``""``.
|
||||
color
|
||||
The color of the mobject. By default the standard VMobject color
|
||||
(white in dark mode). Overrides the Typst text fill color.
|
||||
stroke_width
|
||||
SVG stroke width override. If ``None`` (default), the stroke widths
|
||||
from Typst's SVG output are preserved.
|
||||
font_paths
|
||||
Optional list of additional font directories passed to the Typst
|
||||
compiler (e.g. for custom fonts not installed system-wide).
|
||||
track_baselines
|
||||
Whether to keep enough per-element reference data to recover the
|
||||
current Typst baseline frame for each imported submobject.
|
||||
When enabled, :attr:`baseline_frames` and
|
||||
:meth:`get_baseline_frame` can be used to retrieve the current
|
||||
``(orig, right, up)`` positions for the imported SVG elements.
|
||||
should_center
|
||||
Whether to center the mobject after import (default ``True``).
|
||||
height
|
||||
Target height of the mobject. If ``None`` (default), the height is
|
||||
determined by ``font_size``.
|
||||
**kwargs
|
||||
Forwarded to :class:`~.SVGMobject`.
|
||||
|
||||
Examples
|
||||
--------
|
||||
.. manim:: TypstExample
|
||||
:ref_classes: Typst
|
||||
|
||||
class TypstExample(Scene):
|
||||
def construct(self):
|
||||
formula = Typst(r"$ integral_a^b f(x) dif x $")
|
||||
self.play(Write(formula))
|
||||
|
||||
.. manim:: TypstTextExample
|
||||
:save_last_frame:
|
||||
:ref_classes: Typst
|
||||
|
||||
class TypstTextExample(Scene):
|
||||
def construct(self):
|
||||
text = Typst(
|
||||
r"*Hello* from _Typst!_",
|
||||
font_size=72,
|
||||
)
|
||||
self.add(text)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
typst_code: str,
|
||||
*,
|
||||
font_size: float = DEFAULT_FONT_SIZE,
|
||||
typst_preamble: str = "",
|
||||
color: ParsableManimColor | None = None,
|
||||
stroke_width: float | None = None,
|
||||
font_paths: list[str | Path] | None = None,
|
||||
track_baselines: bool = False,
|
||||
should_center: bool = True,
|
||||
height: float | None = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
if color is None:
|
||||
color = VMobject().color
|
||||
|
||||
self._font_size = font_size
|
||||
self.typst_code = typst_code
|
||||
self.typst_preamble = typst_preamble
|
||||
self.track_baselines = track_baselines
|
||||
self._preserve_svg_stroke_widths = stroke_width is None
|
||||
self._baseline_tracked_submobjects: list[VMobject] = []
|
||||
self._stroke_width_tracked_submobjects: list[VMobject] = []
|
||||
self._label_aliases: dict[str, list[str]] = {}
|
||||
|
||||
file_name = typst_to_svg_file(
|
||||
typst_code,
|
||||
preamble=typst_preamble,
|
||||
font_paths=font_paths,
|
||||
)
|
||||
super().__init__(
|
||||
file_name=file_name,
|
||||
should_center=should_center,
|
||||
stroke_width=stroke_width,
|
||||
height=height,
|
||||
color=color,
|
||||
path_string_config={
|
||||
"should_subdivide_sharp_curves": True,
|
||||
"should_remove_null_curves": True,
|
||||
},
|
||||
**kwargs,
|
||||
)
|
||||
self._rebuild_label_aliases()
|
||||
self._refresh_svg_stroke_widths()
|
||||
self.init_colors()
|
||||
|
||||
# Used for scaling via font_size property (mirrors SingleStringMathTex).
|
||||
self.initial_height = self.height
|
||||
|
||||
if height is None:
|
||||
self.font_size = self._font_size
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"{type(self).__name__}({self.typst_code!r})"
|
||||
|
||||
@property
|
||||
def hash_seed(self) -> tuple:
|
||||
"""Include baseline tracking in the SVG cache key."""
|
||||
return (*super().hash_seed, self.track_baselines)
|
||||
|
||||
# -- font_size property (same approach as SingleStringMathTex) -----------
|
||||
|
||||
@property
|
||||
def font_size(self) -> float:
|
||||
"""The font size of the Typst mobject."""
|
||||
return self.height / self.initial_height / SCALE_FACTOR_PER_FONT_POINT
|
||||
|
||||
@font_size.setter
|
||||
def font_size(self, val: float) -> None:
|
||||
if val <= 0:
|
||||
raise ValueError("font_size must be greater than 0.")
|
||||
if self.height > 0:
|
||||
self.scale(val / self.font_size)
|
||||
|
||||
def scale(
|
||||
self,
|
||||
scale_factor: float,
|
||||
scale_stroke: bool = False,
|
||||
*,
|
||||
about_point: np.ndarray | None = None,
|
||||
about_edge: np.ndarray | None = None,
|
||||
) -> Typst:
|
||||
result = super().scale(
|
||||
scale_factor,
|
||||
scale_stroke=scale_stroke,
|
||||
about_point=about_point,
|
||||
about_edge=about_edge,
|
||||
)
|
||||
self._refresh_svg_stroke_widths()
|
||||
return result
|
||||
|
||||
def _refresh_svg_stroke_widths(self) -> None:
|
||||
"""Refresh pixel stroke widths for Typst-authored SVG strokes.
|
||||
|
||||
SVG stroke widths are specified in the SVG's local coordinate system,
|
||||
while Manim stroke widths are pixel-based. For Typst-authored strokes
|
||||
such as fraction bars or underlines, rescale them according to the
|
||||
current geometric scale of the imported element so their visual weight
|
||||
stays proportional to the rest of the expression.
|
||||
"""
|
||||
if not self._preserve_svg_stroke_widths:
|
||||
return
|
||||
|
||||
pixels_per_unit = config.pixel_width / config.frame_width
|
||||
for submobject in self._stroke_width_tracked_submobjects:
|
||||
submobject_any = cast(Any, submobject)
|
||||
reference_size = cast(float, submobject_any._typst_reference_size)
|
||||
source_stroke_width = cast(
|
||||
float,
|
||||
submobject_any._typst_source_stroke_width,
|
||||
)
|
||||
current_size = max(submobject.width, submobject.height)
|
||||
if reference_size <= 0:
|
||||
continue
|
||||
current_stroke_width = source_stroke_width * current_size / reference_size
|
||||
submobject.set_stroke(
|
||||
width=current_stroke_width
|
||||
* pixels_per_unit
|
||||
* _TYPST_SVG_STROKE_WIDTH_SCALE,
|
||||
family=False,
|
||||
)
|
||||
|
||||
# -- baseline frame tracking ---------------------------------------------
|
||||
|
||||
def get_mob_from_shape_element(self, shape: se.SVGElement) -> VMobject | None:
|
||||
"""Attach Typst-specific metadata to imported shape mobjects."""
|
||||
mob = super().get_mob_from_shape_element(shape)
|
||||
if mob is None or not mob.has_points():
|
||||
return mob
|
||||
|
||||
if self._preserve_svg_stroke_widths and shape.stroke_width not in (None, 0):
|
||||
reference_size = max(mob.width, mob.height)
|
||||
if reference_size > 0:
|
||||
mob_any = cast(Any, mob)
|
||||
mob_any._typst_reference_size = reference_size
|
||||
mob_any._typst_source_stroke_width = shape.stroke_width
|
||||
self._stroke_width_tracked_submobjects.append(mob)
|
||||
|
||||
if not self.track_baselines:
|
||||
return mob
|
||||
|
||||
baseline_marks = self._get_reference_baseline_frame(shape)
|
||||
if baseline_marks is None:
|
||||
return mob
|
||||
|
||||
reference_points = mob.points.copy()
|
||||
reference_xy = np.column_stack(
|
||||
[
|
||||
reference_points[:, 0],
|
||||
reference_points[:, 1],
|
||||
np.ones(len(reference_points)),
|
||||
],
|
||||
)
|
||||
if np.linalg.matrix_rank(reference_xy) < 3:
|
||||
return mob
|
||||
|
||||
mob_any = cast(Any, mob)
|
||||
mob_any._typst_reference_points = reference_points
|
||||
mob_any._typst_reference_baseline_frame = baseline_marks
|
||||
self._baseline_tracked_submobjects.append(mob)
|
||||
return mob
|
||||
|
||||
@staticmethod
|
||||
def _get_reference_baseline_frame(
|
||||
shape: se.SVGElement,
|
||||
) -> np.ndarray | None:
|
||||
"""Return the reference ``(orig, right, up)`` frame for a Typst SVG element.
|
||||
|
||||
The frame is expressed in the same pre-centering coordinate system as the
|
||||
imported submobject points after the element's own SVG transform has been
|
||||
applied.
|
||||
"""
|
||||
if not isinstance(shape, se.Transformable):
|
||||
return None
|
||||
|
||||
matrix = shape.transform if shape.apply else se.Matrix()
|
||||
return np.array(
|
||||
[
|
||||
[matrix.e, matrix.f, 0.0],
|
||||
[matrix.a + matrix.e, matrix.b + matrix.f, 0.0],
|
||||
[matrix.c + matrix.e, matrix.d + matrix.f, 0.0],
|
||||
],
|
||||
)
|
||||
|
||||
def get_baseline_frame(
|
||||
self, submobject: VMobject
|
||||
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
||||
"""Return the current Typst baseline frame for a tracked submobject.
|
||||
|
||||
The returned tuple contains the current positions of ``(orig, right, up)``.
|
||||
These are recovered from the stored reference frame and the submobject's
|
||||
current affine position in the scene.
|
||||
"""
|
||||
try:
|
||||
submobject_any = cast(Any, submobject)
|
||||
reference_points = cast(
|
||||
np.ndarray,
|
||||
submobject_any._typst_reference_points,
|
||||
)
|
||||
reference_frame = cast(
|
||||
np.ndarray,
|
||||
submobject_any._typst_reference_baseline_frame,
|
||||
)
|
||||
except AttributeError as err:
|
||||
raise ValueError(
|
||||
"No tracked Typst baseline frame is available for this submobject. "
|
||||
"Construct the Typst mobject with track_baselines=True.",
|
||||
) from err
|
||||
|
||||
reference_xy = np.column_stack(
|
||||
[
|
||||
reference_points[:, 0],
|
||||
reference_points[:, 1],
|
||||
np.ones(len(reference_points)),
|
||||
],
|
||||
)
|
||||
if np.linalg.matrix_rank(reference_xy) < 3:
|
||||
raise ValueError(
|
||||
"The stored Typst reference geometry is degenerate, so its baseline "
|
||||
"frame cannot be recovered.",
|
||||
)
|
||||
|
||||
transform, _, _, _ = np.linalg.lstsq(
|
||||
reference_xy, submobject.points, rcond=None
|
||||
)
|
||||
frame_xy = np.column_stack(
|
||||
[
|
||||
reference_frame[:, 0],
|
||||
reference_frame[:, 1],
|
||||
np.ones(len(reference_frame)),
|
||||
],
|
||||
)
|
||||
current_frame = frame_xy @ transform
|
||||
return tuple(
|
||||
cast(tuple[np.ndarray, np.ndarray, np.ndarray], tuple(current_frame))
|
||||
)
|
||||
|
||||
@property
|
||||
def baseline_frames(self) -> list[tuple[np.ndarray, np.ndarray, np.ndarray]]:
|
||||
"""Current Typst baseline frames for all tracked leaf submobjects."""
|
||||
if not self.track_baselines:
|
||||
return []
|
||||
return [
|
||||
self.get_baseline_frame(submobject)
|
||||
for submobject in self._baseline_tracked_submobjects
|
||||
]
|
||||
|
||||
def _rebuild_label_aliases(self) -> None:
|
||||
"""Rebuild user-facing label aliases from imported SVG ids."""
|
||||
aliases: dict[str, list[str]] = {}
|
||||
for key in self.id_to_vgroup_dict:
|
||||
if key == "root" or key.startswith("numbered_group_"):
|
||||
continue
|
||||
if _INTERNAL_TYPST_ID_RE.fullmatch(key) is not None:
|
||||
continue
|
||||
|
||||
base_label = key
|
||||
if _DUPLICATE_LABEL_SUFFIX in key:
|
||||
base_label, _, _ = key.partition(_DUPLICATE_LABEL_SUFFIX)
|
||||
aliases.setdefault(base_label, []).append(key)
|
||||
self._label_aliases = aliases
|
||||
|
||||
def _select_label(self, label: str) -> VGroup:
|
||||
if label not in self._label_aliases:
|
||||
raise KeyError(
|
||||
f"No group with label {label!r} found. "
|
||||
f"Available labels: {self._user_label_keys()}"
|
||||
)
|
||||
|
||||
result = VGroup()
|
||||
seen_ids: set[int] = set()
|
||||
for group_id in self._label_aliases[label]:
|
||||
for submobject in self.id_to_vgroup_dict[group_id]:
|
||||
submobject_id = id(submobject)
|
||||
if submobject_id in seen_ids:
|
||||
continue
|
||||
seen_ids.add(submobject_id)
|
||||
result.add(submobject)
|
||||
return result
|
||||
|
||||
# -- SVG post-processing -------------------------------------------------
|
||||
|
||||
def modify_xml_tree(self, element_tree: ET.ElementTree) -> ET.ElementTree:
|
||||
"""Convert ``data-typst-label`` attributes to ``id`` before parsing.
|
||||
|
||||
Typst's SVG renderer emits ``data-typst-label`` on ``<g>`` elements
|
||||
that carry a label (created via ``#box(body) <label>``). The
|
||||
``svgelements`` library propagates custom ``data-*`` attributes from
|
||||
parent groups to all children, making them unusable as unique group
|
||||
keys. ``id`` attributes, on the other hand, are *not* inherited.
|
||||
|
||||
This method walks the XML tree and promotes every
|
||||
``data-typst-label`` to ``id`` (on ``<g>`` elements only), so that
|
||||
:meth:`~.SVGMobject.get_mobjects_from` can pick them up via its
|
||||
existing ``id``-based grouping logic.
|
||||
"""
|
||||
# Let the base class inject default style wrappers first.
|
||||
element_tree = super().modify_xml_tree(element_tree)
|
||||
|
||||
# Walk all elements regardless of namespace — ElementTree
|
||||
# qualifies tags with the namespace URI, so a bare ``"g"``
|
||||
# won't match ``{http://www.w3.org/2000/svg}g``.
|
||||
label_counts: dict[str, int] = {}
|
||||
for element in element_tree.iter():
|
||||
label = element.get("data-typst-label")
|
||||
if label is not None:
|
||||
count = label_counts.get(label, 0)
|
||||
label_counts[label] = count + 1
|
||||
svg_id = label
|
||||
if count > 0:
|
||||
svg_id = f"{label}{_DUPLICATE_LABEL_SUFFIX}{count}"
|
||||
element.set("id", svg_id)
|
||||
del element.attrib["data-typst-label"]
|
||||
|
||||
return element_tree
|
||||
|
||||
# -- sub-expression selection --------------------------------------------
|
||||
|
||||
def select(self, key: str | int) -> VGroup:
|
||||
"""Select a labeled sub-expression.
|
||||
|
||||
Labels are created in the Typst source either manually via the
|
||||
``manimgrp`` helper or automatically through the ``{{ }}``
|
||||
double-brace notation in :class:`TypstMath`.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
key
|
||||
A label name (``str``) matching a ``data-typst-label`` in the
|
||||
SVG, or an integer index into the auto-numbered ``{{ }}``
|
||||
groups (``_grp-0``, ``_grp-1``, …).
|
||||
|
||||
Returns
|
||||
-------
|
||||
VGroup
|
||||
The submobjects corresponding to the selected group.
|
||||
|
||||
Raises
|
||||
------
|
||||
KeyError
|
||||
If no group with the given label exists.
|
||||
IndexError
|
||||
If an integer index is out of range.
|
||||
|
||||
Examples
|
||||
--------
|
||||
.. manim:: TypstSelectExample
|
||||
:save_last_frame:
|
||||
:ref_classes: TypstMath
|
||||
:ref_methods: Typst.select
|
||||
|
||||
class TypstSelectExample(Scene):
|
||||
def construct(self):
|
||||
eq = TypstMath(
|
||||
"{{ a + b : num }} / {{ c : den }} = {{ lambda }} {{ x }}"
|
||||
)
|
||||
eq.select("num").set_color(RED) # "a + b"
|
||||
eq.select("den").set_color(BLUE) # "c"
|
||||
eq.select(0).set_color(YELLOW) # "lambda" (auto-numbered: "grp-0")
|
||||
eq.select(1).set_color(GREEN) # "x" (auto-numbered: "grp-1")
|
||||
|
||||
self.add(eq)
|
||||
"""
|
||||
if isinstance(key, int):
|
||||
label = f"_grp-{key}"
|
||||
if label not in self._label_aliases:
|
||||
raise IndexError(
|
||||
f"Group index {key} out of range. "
|
||||
f"Available labels: {self._user_label_keys()}"
|
||||
)
|
||||
return self._select_label(label)
|
||||
|
||||
return self._select_label(key)
|
||||
|
||||
def _user_label_keys(self) -> list[str]:
|
||||
"""Return the label keys that were created from ``data-typst-label``
|
||||
attributes (filtering out internal Typst group IDs and auto-numbered
|
||||
groups).
|
||||
"""
|
||||
return list(self._label_aliases)
|
||||
|
||||
# -- color handling ------------------------------------------------------
|
||||
|
||||
def init_colors(self, propagate_colors: bool = True) -> Typst:
|
||||
"""Recolor black submobjects to ``self.color``.
|
||||
|
||||
Typst renders text in black (``fill="#000000"``) by default.
|
||||
This mirrors the approach of :meth:`SingleStringMathTex.init_colors`:
|
||||
any submobject whose color is black is recolored to ``self.color``,
|
||||
while explicitly colored submobjects (non-black) are preserved.
|
||||
"""
|
||||
for submobject in self.submobjects:
|
||||
if submobject.color != BLACK:
|
||||
continue
|
||||
submobject.color = self.color
|
||||
if config.renderer == RendererType.OPENGL:
|
||||
submobject.init_colors()
|
||||
elif config.renderer == RendererType.CAIRO:
|
||||
submobject.init_colors(propagate_colors=propagate_colors)
|
||||
return self
|
||||
|
||||
|
||||
class TypstMath(Typst):
|
||||
r"""Convenience wrapper: wraps the input in Typst math delimiters.
|
||||
|
||||
The expression is rendered as a display-level equation
|
||||
(``$ ... $`` with surrounding spaces).
|
||||
|
||||
Supports the ``{{ ... }}`` double-brace notation for grouping
|
||||
sub-expressions. Each ``{{ content }}`` is wrapped in a labeled
|
||||
``manimgrp`` call so that the resulting SVG contains identifiable
|
||||
groups accessible via :meth:`~.Typst.select`.
|
||||
|
||||
Groups can optionally be given explicit labels:
|
||||
``{{ content : label }}``. Without a label, groups are
|
||||
auto-numbered (``_grp-0``, ``_grp-1``, …).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
math_expression
|
||||
Typst math-mode content **without** the ``$ ... $`` delimiters.
|
||||
May contain ``{{ ... }}`` groups.
|
||||
**kwargs
|
||||
Forwarded to :class:`Typst`.
|
||||
|
||||
Examples
|
||||
--------
|
||||
.. manim:: DisplayMath
|
||||
:save_last_frame:
|
||||
:ref_classes: TypstMath
|
||||
|
||||
class DisplayMath(Scene):
|
||||
def construct(self):
|
||||
eq = TypstMath(r"sum_(k=0)^n k = (n(n+1)) / 2")
|
||||
self.add(eq)
|
||||
|
||||
.. manim:: GroupedMath
|
||||
:save_last_frame:
|
||||
:ref_classes: TypstMath
|
||||
:ref_methods: Typst.select
|
||||
|
||||
class GroupedMath(Scene):
|
||||
def construct(self):
|
||||
eq = TypstMath("{{ a^2 + b^2 : lhs }} = {{ c^2 }}")
|
||||
eq.select("lhs").set_color(RED) # "a^2 + b^2"
|
||||
eq.select(0).set_color(BLUE) # "c^2" (auto-numbered: "grp-0")
|
||||
self.add(eq)
|
||||
"""
|
||||
|
||||
def __init__(self, math_expression: str, **kwargs: Any):
|
||||
processed, labels = self._preprocess_groups(math_expression)
|
||||
self._group_labels = labels
|
||||
|
||||
# Inject the manimgrp helper when groups are present.
|
||||
if labels:
|
||||
preamble = kwargs.get("typst_preamble", "")
|
||||
if _MANIMGRP_PREAMBLE not in preamble:
|
||||
preamble = (
|
||||
f"{_MANIMGRP_PREAMBLE}\n{preamble}"
|
||||
if preamble
|
||||
else _MANIMGRP_PREAMBLE
|
||||
)
|
||||
kwargs["typst_preamble"] = preamble
|
||||
|
||||
super().__init__(f"$ {processed} $", **kwargs)
|
||||
|
||||
# -- double-brace preprocessor -------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _preprocess_groups(math_expr: str) -> tuple[str, list[str]]:
|
||||
"""Replace ``{{ ... }}`` groups with ``manimgrp(...)`` calls.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
math_expr
|
||||
The raw math expression (without ``$ ... $`` delimiters).
|
||||
|
||||
Returns
|
||||
-------
|
||||
tuple[str, list[str]]
|
||||
The processed expression and an ordered list of group labels.
|
||||
"""
|
||||
result: list[str] = []
|
||||
labels: list[str] = []
|
||||
auto_index = 0
|
||||
i = 0
|
||||
n = len(math_expr)
|
||||
outer_in_string = False
|
||||
outer_bracket_depth = 0
|
||||
|
||||
while i < n:
|
||||
ch = math_expr[i]
|
||||
|
||||
# Track string literals at the outer level.
|
||||
if outer_in_string:
|
||||
result.append(ch)
|
||||
if ch == "\\" and i + 1 < n:
|
||||
result.append(math_expr[i + 1])
|
||||
i += 2
|
||||
continue
|
||||
if ch == '"':
|
||||
outer_in_string = False
|
||||
i += 1
|
||||
continue
|
||||
if ch == '"':
|
||||
outer_in_string = True
|
||||
result.append(ch)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Track [...] content blocks at the outer level.
|
||||
if ch == "[":
|
||||
outer_bracket_depth += 1
|
||||
result.append(ch)
|
||||
i += 1
|
||||
continue
|
||||
if ch == "]" and outer_bracket_depth > 0:
|
||||
outer_bracket_depth -= 1
|
||||
result.append(ch)
|
||||
i += 1
|
||||
continue
|
||||
if outer_bracket_depth > 0:
|
||||
result.append(ch)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Look for opening {{ (not a single {)
|
||||
if i + 1 < n and ch == "{" and math_expr[i + 1] == "{":
|
||||
i += 2 # skip {{
|
||||
content_start = i
|
||||
depth = 1
|
||||
in_string = False
|
||||
bracket_depth = 0
|
||||
|
||||
while i < n and depth > 0:
|
||||
ch = math_expr[i]
|
||||
|
||||
if in_string:
|
||||
if ch == "\\" and i + 1 < n:
|
||||
i += 2
|
||||
continue
|
||||
if ch == '"':
|
||||
in_string = False
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if ch == '"':
|
||||
in_string = True
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if ch == "[":
|
||||
bracket_depth += 1
|
||||
i += 1
|
||||
continue
|
||||
if ch == "]" and bracket_depth > 0:
|
||||
bracket_depth -= 1
|
||||
i += 1
|
||||
continue
|
||||
if bracket_depth > 0:
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if ch == "{" and i + 1 < n and math_expr[i + 1] == "{":
|
||||
depth += 1
|
||||
i += 2
|
||||
continue
|
||||
if ch == "}" and i + 1 < n and math_expr[i + 1] == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
content = math_expr[content_start:i]
|
||||
i += 2 # skip }}
|
||||
break
|
||||
i += 2
|
||||
continue
|
||||
|
||||
i += 1
|
||||
else:
|
||||
# Unclosed {{ — emit literally and stop.
|
||||
result.append("{{")
|
||||
result.append(math_expr[content_start:])
|
||||
return "".join(result), labels
|
||||
|
||||
# Check for optional `: label` suffix.
|
||||
m = _LABEL_RE.match(content)
|
||||
if m is not None:
|
||||
body = m.group(1).strip()
|
||||
label = m.group(2)
|
||||
else:
|
||||
body = content.strip()
|
||||
label = f"_grp-{auto_index}"
|
||||
auto_index += 1
|
||||
|
||||
labels.append(label)
|
||||
result.append(f'manimgrp("{label}", {body})')
|
||||
else:
|
||||
result.append(math_expr[i])
|
||||
i += 1
|
||||
|
||||
return "".join(result), labels
|
||||
|
|
@ -48,6 +48,7 @@ if TYPE_CHECKING:
|
|||
from typing import Self
|
||||
|
||||
from manim.typing import (
|
||||
ManimTextLabel,
|
||||
MappingFunction,
|
||||
Point3D,
|
||||
Point3DLike,
|
||||
|
|
@ -301,13 +302,13 @@ class VectorScene(Scene):
|
|||
def get_vector_label(
|
||||
self,
|
||||
vector: Vector,
|
||||
label: MathTex | str,
|
||||
label: ManimTextLabel | str,
|
||||
at_tip: bool = False,
|
||||
direction: str = "left",
|
||||
rotate: bool = False,
|
||||
color: ParsableManimColor | None = None,
|
||||
label_scale_factor: float = LARGE_BUFF - 0.2,
|
||||
) -> MathTex:
|
||||
) -> ManimTextLabel:
|
||||
"""
|
||||
Returns naming labels for the passed vector.
|
||||
|
||||
|
|
@ -330,19 +331,18 @@ class VectorScene(Scene):
|
|||
|
||||
Returns
|
||||
-------
|
||||
MathTex
|
||||
The MathTex of the label.
|
||||
:class:`~.ManimTextLabel`
|
||||
The rendered label mobject.
|
||||
"""
|
||||
if not isinstance(label, MathTex):
|
||||
if isinstance(label, str):
|
||||
if len(label) == 1:
|
||||
label = "\\vec{\\textbf{%s}}" % label # noqa: UP031
|
||||
label = rf"\vec{{\textbf{{{label}}}}}"
|
||||
label = MathTex(label)
|
||||
if color is None:
|
||||
prepared_color: ParsableManimColor = vector.get_color()
|
||||
else:
|
||||
prepared_color = color
|
||||
label.set_color(prepared_color)
|
||||
assert isinstance(label, MathTex)
|
||||
label.scale(label_scale_factor)
|
||||
label.add_background_rectangle()
|
||||
|
||||
|
|
@ -365,8 +365,12 @@ class VectorScene(Scene):
|
|||
return label
|
||||
|
||||
def label_vector(
|
||||
self, vector: Vector, label: MathTex | str, animate: bool = True, **kwargs: Any
|
||||
) -> MathTex:
|
||||
self,
|
||||
vector: Vector,
|
||||
label: ManimTextLabel | str,
|
||||
animate: bool = True,
|
||||
**kwargs: Any,
|
||||
) -> ManimTextLabel:
|
||||
"""
|
||||
Shortcut method for creating, and animating the addition of
|
||||
a label for the vector.
|
||||
|
|
@ -377,7 +381,7 @@ class VectorScene(Scene):
|
|||
The vector for which the label must be added.
|
||||
|
||||
label
|
||||
The MathTex/string of the label.
|
||||
The rendered label mobject or the string used to create one.
|
||||
|
||||
animate
|
||||
Whether or not to animate the labelling w/ Write
|
||||
|
|
@ -387,8 +391,8 @@ class VectorScene(Scene):
|
|||
|
||||
Returns
|
||||
-------
|
||||
:class:`~.MathTex`
|
||||
The MathTex of the label.
|
||||
:class:`~.ManimTextLabel`
|
||||
The rendered label mobject.
|
||||
"""
|
||||
mathtex_label = self.get_vector_label(vector, label, **kwargs)
|
||||
if animate:
|
||||
|
|
@ -703,7 +707,7 @@ class LinearTransformationScene(VectorScene):
|
|||
self.foreground_mobjects: list[Mobject] = []
|
||||
self.transformable_mobjects: list[Mobject] = []
|
||||
self.moving_vectors: list[Mobject] = []
|
||||
self.transformable_labels: list[MathTex] = []
|
||||
self.transformable_labels: list[Any] = []
|
||||
self.moving_mobjects: list[Mobject] = []
|
||||
|
||||
self.background_plane = NumberPlane(**self.background_plane_kwargs)
|
||||
|
|
@ -971,16 +975,17 @@ class LinearTransformationScene(VectorScene):
|
|||
"""
|
||||
# TODO: Clear up types in this function. This is currently a mess.
|
||||
label_mob = self.label_vector(vector, label, **kwargs)
|
||||
label_mob_any = cast(Any, label_mob)
|
||||
if new_label:
|
||||
label_mob.target_text = new_label # type: ignore[attr-defined]
|
||||
label_mob_any.target_text = new_label
|
||||
else:
|
||||
label_mob.target_text = ( # type: ignore[attr-defined]
|
||||
label_mob_any.target_text = (
|
||||
f"{transformation_name}({label_mob.get_tex_string()})"
|
||||
)
|
||||
label_mob.vector = vector # type: ignore[attr-defined]
|
||||
label_mob.kwargs = kwargs # type: ignore[attr-defined]
|
||||
if "animate" in label_mob.kwargs:
|
||||
label_mob.kwargs.pop("animate")
|
||||
label_mob_any.vector = vector
|
||||
label_mob_any.kwargs = kwargs
|
||||
if "animate" in label_mob_any.kwargs:
|
||||
label_mob_any.kwargs.pop("animate")
|
||||
self.transformable_labels.append(label_mob)
|
||||
return cast(MathTex, label_mob)
|
||||
|
||||
|
|
@ -1149,11 +1154,12 @@ class LinearTransformationScene(VectorScene):
|
|||
for label in self.transformable_labels:
|
||||
# TODO: This location and lines 933 and 335 are the only locations in
|
||||
# the code where the target_text property is referenced.
|
||||
target_text: MathTex | str = label.target_text # type: ignore[assignment]
|
||||
label_any = cast(Any, label)
|
||||
target_text: MathTex | str = label_any.target_text
|
||||
label.target = self.get_vector_label(
|
||||
label.vector.target, # type: ignore[attr-defined]
|
||||
label_any.vector.target,
|
||||
target_text,
|
||||
**label.kwargs, # type: ignore[arg-type]
|
||||
**label_any.kwargs,
|
||||
)
|
||||
return self.get_piece_movement(self.transformable_labels)
|
||||
|
||||
|
|
|
|||
|
|
@ -22,11 +22,16 @@ from __future__ import annotations
|
|||
|
||||
from collections.abc import Callable, Sequence
|
||||
from os import PathLike
|
||||
from typing import TypeAlias
|
||||
from typing import TYPE_CHECKING, TypeAlias
|
||||
|
||||
import numpy as np
|
||||
import numpy.typing as npt
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from manim.mobject.text.tex_mobject import MathTex
|
||||
from manim.mobject.text.text_mobject import Text
|
||||
from manim.mobject.text.typst_mobject import Typst
|
||||
|
||||
__all__ = [
|
||||
"ManimFloat",
|
||||
"ManimInt",
|
||||
|
|
@ -108,6 +113,7 @@ __all__ = [
|
|||
"PathFuncType",
|
||||
"MappingFunction",
|
||||
"MultiMappingFunction",
|
||||
"ManimTextLabel",
|
||||
"PixelArray",
|
||||
"GrayscalePixelArray",
|
||||
"RGBPixelArray",
|
||||
|
|
@ -931,6 +937,19 @@ MultiMappingFunction: TypeAlias = Callable[[Point3D_Array], Point3D_Array]
|
|||
:class:`.Point3D_Array`.
|
||||
"""
|
||||
|
||||
"""
|
||||
[CATEGORY]
|
||||
Text mobject types
|
||||
"""
|
||||
|
||||
ManimTextLabel: TypeAlias = "Text | MathTex | Typst"
|
||||
"""Text-like label mobjects commonly used across Manim.
|
||||
|
||||
This includes :class:`~.Text`, :class:`~.MathTex`, and :class:`~.Typst`.
|
||||
Subtype-specific variants like :class:`~.Tex` and :class:`~.TypstMath` are
|
||||
covered implicitly through inheritance.
|
||||
"""
|
||||
|
||||
"""
|
||||
[CATEGORY]
|
||||
Image types
|
||||
|
|
|
|||
106
manim/utils/typst_file_writing.py
Normal file
106
manim/utils/typst_file_writing.py
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
"""Interface for writing, compiling, and converting ``.typ`` files via the ``typst`` Python package.
|
||||
|
||||
.. SEEALSO::
|
||||
|
||||
:mod:`.mobject.text.typst_mobject`
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
|
||||
from manim import config, logger
|
||||
|
||||
__all__ = ["typst_to_svg_file"]
|
||||
|
||||
# Use 10pt instead of Typst's 11pt default so that the post-import scaling
|
||||
# based on Manim's font_size property matches TeX / MathTex more closely.
|
||||
TYPST_COMPILATION_FONT_SIZE = 10 # pt
|
||||
|
||||
TYPST_TEMPLATE = """\
|
||||
#set page(width: auto, height: auto, margin: 0pt, fill: none)
|
||||
#set text(size: {text_size}pt)
|
||||
{preamble}
|
||||
{body}
|
||||
"""
|
||||
|
||||
|
||||
def _typst_hash(content: str) -> str:
|
||||
"""Return a truncated SHA-256 hex digest of *content*."""
|
||||
return hashlib.sha256(content.encode()).hexdigest()[:16]
|
||||
|
||||
|
||||
def typst_to_svg_file(
|
||||
typst_code: str,
|
||||
preamble: str = "",
|
||||
text_size: float = TYPST_COMPILATION_FONT_SIZE,
|
||||
font_paths: list[str | Path] | None = None,
|
||||
) -> Path:
|
||||
"""Compile a Typst string to SVG via the ``typst`` Python package.
|
||||
|
||||
The compiled SVG and the intermediate ``.typ`` source are cached
|
||||
under :func:`config.get_dir("tex_dir") <manim.ManimConfig.get_dir>`
|
||||
using a content-hash filename scheme (identical to the LaTeX pipeline).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
typst_code
|
||||
The body of the Typst document (user-supplied markup).
|
||||
preamble
|
||||
Extra Typst code inserted between the ``#set`` rules and the body.
|
||||
Useful for ``#import``, ``#set``, or ``#show`` rules.
|
||||
text_size
|
||||
Font size in Typst points used during compilation.
|
||||
font_paths
|
||||
Optional list of additional font directories passed to the Typst
|
||||
compiler.
|
||||
|
||||
Returns
|
||||
-------
|
||||
:class:`Path`
|
||||
Path to the generated SVG file.
|
||||
|
||||
Raises
|
||||
------
|
||||
ImportError
|
||||
If the ``typst`` Python package is not installed.
|
||||
"""
|
||||
try:
|
||||
import typst as typst_compiler
|
||||
except ImportError as err:
|
||||
raise ImportError(
|
||||
"TypstMobject requires the 'typst' Python package. "
|
||||
"Install it with: pip install typst>=0.14"
|
||||
) from err
|
||||
|
||||
full_source = TYPST_TEMPLATE.format(
|
||||
text_size=text_size,
|
||||
preamble=preamble,
|
||||
body=typst_code,
|
||||
)
|
||||
content_hash = _typst_hash(full_source)
|
||||
typst_dir = config.get_dir("tex_dir")
|
||||
typst_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
typ_file = typst_dir / f"{content_hash}.typ"
|
||||
svg_file = typst_dir / f"{content_hash}.svg"
|
||||
|
||||
if svg_file.exists():
|
||||
return svg_file
|
||||
|
||||
typ_file.write_text(full_source, encoding="utf-8")
|
||||
|
||||
logger.info(
|
||||
"Compiling Typst source %(path)s ...",
|
||||
{"path": f"{typ_file}"},
|
||||
)
|
||||
|
||||
svg_bytes = typst_compiler.compile(
|
||||
str(typ_file),
|
||||
format="svg",
|
||||
font_paths=font_paths or [],
|
||||
)
|
||||
svg_file.write_bytes(svg_bytes)
|
||||
return svg_file
|
||||
|
|
@ -74,6 +74,9 @@ jupyterlab = [
|
|||
"jupyterlab>=4.3.4",
|
||||
"notebook>=7.3.2",
|
||||
]
|
||||
typst = [
|
||||
"typst>=0.14",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
|
|
|
|||
339
tests/module/mobject/text/test_typst_mobject.py
Normal file
339
tests/module/mobject/text/test_typst_mobject.py
Normal file
|
|
@ -0,0 +1,339 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from manim import (
|
||||
RIGHT,
|
||||
Label,
|
||||
MathTex,
|
||||
NumberLine,
|
||||
Tex,
|
||||
Typst,
|
||||
TypstMath,
|
||||
Vector,
|
||||
VectorScene,
|
||||
)
|
||||
|
||||
|
||||
def test_Typst(config):
|
||||
"""Basic Typst creation produces an SVG file."""
|
||||
m = Typst(r"$ x^2 $")
|
||||
assert m.height > 0
|
||||
assert m.width > 0
|
||||
assert len(m.submobjects) > 0
|
||||
|
||||
|
||||
def test_TypstMath(config):
|
||||
"""TypstMath wraps the expression in math delimiters."""
|
||||
m = TypstMath(r"alpha + beta")
|
||||
assert m.typst_code == "$ alpha + beta $"
|
||||
assert m.height > 0
|
||||
|
||||
|
||||
def test_typst_default_font_size(config):
|
||||
"""Default font_size is 48 (DEFAULT_FONT_SIZE)."""
|
||||
m = Typst(r"$ a + b $")
|
||||
assert np.isclose(m.font_size, 48)
|
||||
|
||||
|
||||
def test_typst_custom_font_size(config):
|
||||
"""Passing a custom font_size scales the mobject accordingly."""
|
||||
m = Typst(r"$ a + b $", font_size=72)
|
||||
assert np.isclose(m.font_size, 72)
|
||||
|
||||
|
||||
def test_typst_font_size_property_setter(config):
|
||||
"""Setting font_size after creation rescales correctly."""
|
||||
m = Typst(r"$ a + b $")
|
||||
original_height = m.height
|
||||
m.font_size = 96
|
||||
assert np.isclose(m.font_size, 96)
|
||||
assert m.height > original_height
|
||||
|
||||
|
||||
def test_typst_font_size_scaling_also_scales_svg_strokes(config):
|
||||
"""Typst-authored stroke widths scale together with font_size."""
|
||||
m = TypstMath("frac(a,b)", font_size=48, use_svg_cache=False)
|
||||
original_stroke_width = max(submobject.stroke_width for submobject in m.submobjects)
|
||||
|
||||
m.font_size = 96
|
||||
scaled_stroke_width = max(submobject.stroke_width for submobject in m.submobjects)
|
||||
|
||||
assert np.isclose(scaled_stroke_width, 2 * original_stroke_width)
|
||||
|
||||
|
||||
def test_typst_font_size_error(config):
|
||||
"""Setting font_size to a non-positive value raises ValueError."""
|
||||
m = Typst(r"$ a + b $")
|
||||
with pytest.raises(ValueError, match="font_size must be greater than 0"):
|
||||
m.font_size = -1
|
||||
|
||||
|
||||
def test_typst_caching(config):
|
||||
"""Compiling the same source twice uses the cached SVG."""
|
||||
m1 = Typst(r"$ e^{i pi} + 1 = 0 $")
|
||||
m2 = Typst(r"$ e^{i pi} + 1 = 0 $")
|
||||
assert np.isclose(m1.height, m2.height)
|
||||
assert np.isclose(m1.width, m2.width)
|
||||
|
||||
|
||||
def test_typst_preamble(config):
|
||||
"""A custom preamble is accepted without error."""
|
||||
m = Typst(
|
||||
r"$ x^2 $",
|
||||
typst_preamble='#set text(font: "New Computer Modern")',
|
||||
)
|
||||
assert m.height > 0
|
||||
|
||||
|
||||
def test_typst_repr(config):
|
||||
"""__repr__ includes the Typst source."""
|
||||
m = Typst("hello")
|
||||
assert repr(m) == "Typst('hello')"
|
||||
|
||||
m2 = TypstMath("x")
|
||||
assert repr(m2) == "TypstMath('$ x $')"
|
||||
|
||||
|
||||
def test_typst_text_rendering(config):
|
||||
"""Non-math Typst markup renders correctly."""
|
||||
m = Typst(r"*Bold* and _italic_")
|
||||
assert m.height > 0
|
||||
assert len(m.submobjects) > 0
|
||||
|
||||
|
||||
def test_typst_preserves_svg_stroke_widths_by_default(config):
|
||||
"""Default stroke_width=None preserves Typst-authored SVG strokes."""
|
||||
m = Typst("#underline[abc]", use_svg_cache=False)
|
||||
assert any(submobject.stroke_width > 0 for submobject in m.submobjects)
|
||||
|
||||
|
||||
def test_typst_baseline_frames_empty_without_tracking(config):
|
||||
"""Baseline frames are only collected when requested."""
|
||||
m = Typst("Ggf", use_svg_cache=False)
|
||||
assert m.baseline_frames == []
|
||||
|
||||
|
||||
def test_typst_baseline_frames_track_scene_positions(config):
|
||||
"""Tracked baseline frames follow ordinary affine transformations."""
|
||||
m = Typst("Ggf", track_baselines=True, use_svg_cache=False)
|
||||
assert m.baseline_frames
|
||||
|
||||
orig, right, up = m.baseline_frames[0]
|
||||
delta = 2 * RIGHT
|
||||
m.shift(delta)
|
||||
shifted_orig, shifted_right, shifted_up = m.baseline_frames[0]
|
||||
|
||||
assert np.allclose(shifted_orig - orig, delta)
|
||||
assert np.allclose(shifted_right - right, delta)
|
||||
assert np.allclose(shifted_up - up, delta)
|
||||
|
||||
|
||||
def test_typst_text_font_size_matches_tex_closely(config):
|
||||
"""Typst text is calibrated close to Tex for the same font_size."""
|
||||
tex = Tex("Hello", font_size=48)
|
||||
typst = Typst("Hello", font_size=48, use_svg_cache=False)
|
||||
assert np.isclose(typst.height, tex.height, rtol=0.02)
|
||||
assert np.isclose(typst.width, tex.width, rtol=0.02)
|
||||
|
||||
|
||||
def test_typstmath_font_size_matches_mathtex_closely(config):
|
||||
"""Typst math is calibrated close to MathTex for the same font_size."""
|
||||
mathtex = MathTex(r"\frac{a}{b}", font_size=48)
|
||||
typstmath = TypstMath("frac(a,b)", font_size=48, use_svg_cache=False)
|
||||
assert np.isclose(typstmath.height, mathtex.height, rtol=0.02)
|
||||
assert np.isclose(typstmath.width, mathtex.width, rtol=0.02)
|
||||
|
||||
|
||||
# -- data-typst-label → id mapping tests ------------------------------------
|
||||
|
||||
MANIMGRP_PREAMBLE = "#let manimgrp(lbl, body) = [#box(body) #label(lbl)]"
|
||||
|
||||
|
||||
def test_typst_labels_mapped_to_vgroups(config):
|
||||
"""data-typst-label attributes are promoted to id and appear in id_to_vgroup_dict."""
|
||||
m = Typst(
|
||||
'$ #manimgrp("numerator", $a + b$) / #manimgrp("denom", $c - d$) $',
|
||||
typst_preamble=MANIMGRP_PREAMBLE,
|
||||
use_svg_cache=False,
|
||||
)
|
||||
assert "numerator" in m.id_to_vgroup_dict
|
||||
assert "denom" in m.id_to_vgroup_dict
|
||||
# a, +, b → 3 submobjects; c, -, d → 3 submobjects
|
||||
assert len(m.id_to_vgroup_dict["numerator"]) == 3
|
||||
assert len(m.id_to_vgroup_dict["denom"]) == 3
|
||||
|
||||
|
||||
def test_typst_nested_labels(config):
|
||||
"""Nested labeled boxes produce nested VGroups without cross-contamination."""
|
||||
m = Typst(
|
||||
'$ #manimgrp("outer", $#manimgrp("inner", $a$) + b$) $',
|
||||
typst_preamble=MANIMGRP_PREAMBLE,
|
||||
use_svg_cache=False,
|
||||
)
|
||||
assert "outer" in m.id_to_vgroup_dict
|
||||
assert "inner" in m.id_to_vgroup_dict
|
||||
# "inner" contains only "a" (1 submobject)
|
||||
assert len(m.id_to_vgroup_dict["inner"]) == 1
|
||||
# "outer" contains everything: a, +, b (3 submobjects)
|
||||
assert len(m.id_to_vgroup_dict["outer"]) == 3
|
||||
# The inner submobject is a subset of the outer one
|
||||
inner_mob = m.id_to_vgroup_dict["inner"][0]
|
||||
assert inner_mob in m.id_to_vgroup_dict["outer"]
|
||||
|
||||
|
||||
def test_typst_no_labels_no_extra_keys(config):
|
||||
"""Without labeled boxes, no extra label keys appear."""
|
||||
m = Typst(r"$ a + b $", use_svg_cache=False)
|
||||
label_keys = [
|
||||
k
|
||||
for k in m.id_to_vgroup_dict
|
||||
if not k.startswith(("numbered_group", "root", "g"))
|
||||
]
|
||||
assert label_keys == []
|
||||
|
||||
|
||||
def test_typst_select(config):
|
||||
"""select() returns the correct VGroup for a given label."""
|
||||
m = Typst(
|
||||
'$ #manimgrp("lhs", $a + b$) = #manimgrp("rhs", $c$) $',
|
||||
typst_preamble=MANIMGRP_PREAMBLE,
|
||||
use_svg_cache=False,
|
||||
)
|
||||
lhs = m.select("lhs")
|
||||
rhs = m.select("rhs")
|
||||
assert len(lhs) == 3 # a, +, b
|
||||
assert len(rhs) == 1 # c
|
||||
|
||||
|
||||
def test_typst_select_collects_duplicate_labels(config):
|
||||
"""Repeated Typst labels are combined into one selectable group."""
|
||||
m = Typst(
|
||||
'$ #manimgrp("picked", $a$) + #manimgrp("picked", $b$) $',
|
||||
typst_preamble=MANIMGRP_PREAMBLE,
|
||||
use_svg_cache=False,
|
||||
)
|
||||
picked = m.select("picked")
|
||||
assert len(picked) == 2
|
||||
|
||||
|
||||
def test_typst_get_baseline_frame_for_selected_submobjects(config):
|
||||
"""Tracked frames can be queried for submobjects returned by select()."""
|
||||
m = Typst(
|
||||
'$ #manimgrp("lhs", $a + b$) = c $',
|
||||
typst_preamble=MANIMGRP_PREAMBLE,
|
||||
track_baselines=True,
|
||||
use_svg_cache=False,
|
||||
)
|
||||
lhs = m.select("lhs")
|
||||
frames = [m.get_baseline_frame(submobject) for submobject in lhs]
|
||||
|
||||
assert len(frames) == len(lhs)
|
||||
for orig, right, up in frames:
|
||||
assert orig.shape == (3,)
|
||||
assert right.shape == (3,)
|
||||
assert up.shape == (3,)
|
||||
|
||||
|
||||
def test_typst_select_keyerror(config):
|
||||
"""select() raises KeyError for a nonexistent label."""
|
||||
m = Typst(r"$ a + b $", use_svg_cache=False)
|
||||
with pytest.raises(KeyError, match="No group with label 'missing'"):
|
||||
m.select("missing")
|
||||
|
||||
|
||||
def test_typst_select_keyerror_lists_labels_starting_with_g(config):
|
||||
"""Error messages keep user labels even when they start with ``g``."""
|
||||
m = Typst(
|
||||
'$ #manimgrp("gamma", $a$) $',
|
||||
typst_preamble=MANIMGRP_PREAMBLE,
|
||||
use_svg_cache=False,
|
||||
)
|
||||
with pytest.raises(KeyError, match="gamma"):
|
||||
m.select("missing")
|
||||
|
||||
|
||||
# -- {{ }} double-brace preprocessor tests ----------------------------------
|
||||
|
||||
|
||||
def test_typstmath_double_brace_auto_numbered(config):
|
||||
"""{{ }} groups are auto-numbered and selectable by index."""
|
||||
eq = TypstMath("{{ a + b }} / {{ c - d }} = {{ x }}", use_svg_cache=False)
|
||||
assert eq._group_labels == ["_grp-0", "_grp-1", "_grp-2"]
|
||||
assert len(eq.select(0)) == 3 # a, +, b
|
||||
assert len(eq.select(1)) == 3 # c, -, d
|
||||
assert len(eq.select(2)) == 1 # x
|
||||
|
||||
|
||||
def test_typstmath_double_brace_named(config):
|
||||
"""{{ content : label }} assigns an explicit label."""
|
||||
eq = TypstMath("{{ a + b : numerator }} / {{ c - d : denom }}", use_svg_cache=False)
|
||||
assert "numerator" in eq._group_labels
|
||||
assert "denom" in eq._group_labels
|
||||
assert len(eq.select("numerator")) == 3
|
||||
assert len(eq.select("denom")) == 3
|
||||
|
||||
|
||||
def test_typstmath_double_brace_mixed_named_auto(config):
|
||||
"""Named and auto-numbered groups can coexist."""
|
||||
eq = TypstMath("{{ a : lhs }} = {{ b }}", use_svg_cache=False)
|
||||
assert eq._group_labels == ["lhs", "_grp-0"]
|
||||
assert len(eq.select("lhs")) == 1
|
||||
assert len(eq.select(0)) == 1
|
||||
|
||||
|
||||
def test_typstmath_no_braces_no_preamble(config):
|
||||
"""Without {{ }}, the manimgrp preamble is not injected."""
|
||||
eq = TypstMath("a + b", use_svg_cache=False)
|
||||
assert eq._group_labels == []
|
||||
assert "manimgrp" not in eq.typst_preamble
|
||||
|
||||
|
||||
def test_typstmath_select_index_error(config):
|
||||
"""select(int) raises IndexError for out-of-range index."""
|
||||
eq = TypstMath("{{ a }}", use_svg_cache=False)
|
||||
with pytest.raises(IndexError, match="out of range"):
|
||||
eq.select(1)
|
||||
|
||||
|
||||
def test_typstmath_preprocessor_skips_strings():
|
||||
"""{{ }} inside string literals are not processed."""
|
||||
processed, labels = TypstMath._preprocess_groups('x =_("{{ not a group }}") z')
|
||||
assert labels == []
|
||||
assert "manimgrp" not in processed
|
||||
|
||||
|
||||
def test_typstmath_preprocessor_skips_content_blocks():
|
||||
"""{{ }} inside [...] content blocks are not processed."""
|
||||
processed, labels = TypstMath._preprocess_groups("[text {{ here }}] {{ real }}")
|
||||
assert labels == ["_grp-0"]
|
||||
assert processed.count("manimgrp") == 1
|
||||
|
||||
|
||||
# -- integration tests for existing APIs ------------------------------------
|
||||
|
||||
|
||||
def test_label_accepts_typst(config):
|
||||
"""Label accepts a prebuilt Typst mobject."""
|
||||
rendered = Typst("hello", use_svg_cache=False)
|
||||
label = Label(rendered)
|
||||
assert label.rendered_label is rendered
|
||||
|
||||
|
||||
def test_numberline_add_labels_with_typstmath_constructor_uses_typst(config):
|
||||
"""String labels use Typst text mode when label_constructor is TypstMath."""
|
||||
number_line = NumberLine(x_range=[-1, 1])
|
||||
number_line.add_labels({0: "origin"}, label_constructor=TypstMath)
|
||||
assert len(number_line.labels) == 1
|
||||
assert isinstance(number_line.labels[0], Typst)
|
||||
assert not isinstance(number_line.labels[0], TypstMath)
|
||||
|
||||
|
||||
def test_vector_scene_get_vector_label_accepts_typst(config):
|
||||
"""VectorScene accepts a prebuilt Typst label mobject."""
|
||||
scene = VectorScene()
|
||||
vector = Vector(RIGHT)
|
||||
label = Typst("v", use_svg_cache=False)
|
||||
returned = scene.get_vector_label(vector, label)
|
||||
assert returned is label
|
||||
30
uv.lock
generated
30
uv.lock
generated
|
|
@ -1435,6 +1435,9 @@ jupyterlab = [
|
|||
{ name = "jupyterlab" },
|
||||
{ name = "notebook" },
|
||||
]
|
||||
typst = [
|
||||
{ name = "typst" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
|
|
@ -1490,9 +1493,10 @@ requires-dist = [
|
|||
{ name = "svgelements", specifier = ">=1.9.0" },
|
||||
{ name = "tqdm", specifier = ">=4.21.0" },
|
||||
{ name = "typing-extensions", specifier = ">=4.12.0" },
|
||||
{ name = "typst", marker = "extra == 'typst'", specifier = ">=0.14" },
|
||||
{ name = "watchdog", specifier = ">=2.0.0" },
|
||||
]
|
||||
provides-extras = ["gui", "jupyterlab"]
|
||||
provides-extras = ["gui", "jupyterlab", "typst"]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
|
|
@ -3379,6 +3383,30 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typst"
|
||||
version = "0.14.8"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0e/17/011059074fe6c51ed775991d5066c73443f17d49b3d4ab9c1a969dcdb5cb/typst-0.14.8.tar.gz", hash = "sha256:8ffb8d5896aa6a20a7b88ae3fa1dfcf062fdd09b5b6a0a164f92f78ad1a2d8cd", size = 62369, upload-time = "2026-02-08T02:31:21.753Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/67/af5551e95261fc425f6dbf241ec08bf1172fd10ef239787ff6e009bb2f08/typst-0.14.8-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:4697b9de12d7b1bc85209960e1ef7e2c4947cffd7d6ef68201aea03597cf38bd", size = 22935370, upload-time = "2026-02-08T02:30:30.418Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/93/cbb32c7e830a806105ee0f6d9b6c780f2736a9c75d8121602e7842a316d2/typst-0.14.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e2ecb523ff7e3eb68667ad693ff4c460ac58aedfbeb6514054efce2718e7563f", size = 22624078, upload-time = "2026-02-08T02:30:33.762Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/38/070c068442a8be93125366b27e5cf1a6b1dd62c85dab62bd6d4355643d29/typst-0.14.8-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f9db137ca037bd12c0ebbbbfa1190fffaa75a2043d04adacb273cc98f0265a32", size = 26894087, upload-time = "2026-02-08T02:30:36.876Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/32/8754413c4cdf631c51e16690775dcfd28e783c1ccc0efc71d92ef73e0db3/typst-0.14.8-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d8c4ac751c3480b0fcfc7fce273025bb7392654db5a3aa65904f8678192c54f8", size = 26489748, upload-time = "2026-02-08T02:30:40.169Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/2b/3b1256033c7b971d0c79af41fadff552c1df7a9f9774a540f1a2ede97937/typst-0.14.8-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37da60ec4afcd82b55664612aab10cac11a8ebc075686057705261de9e901523", size = 28023293, upload-time = "2026-02-08T02:30:43.222Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/1b/8769c89998299525e4b04fddce1b15977d18051695c65760203b55f7ed47/typst-0.14.8-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e1cfbc313ba3b883da8c45233506766a503da307057a5d8d39e360023733c463", size = 27109055, upload-time = "2026-02-08T02:30:46.214Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/97/b1f43e29051401289b6ef37398eb83d78584f52e0b213f8675b9b10b0c0b/typst-0.14.8-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b509e7a599dd07e36e18495f0258511de527f5e0dc145622025d204c84db5246", size = 26017464, upload-time = "2026-02-08T02:30:49.271Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/ca/44732fc1e486be822ba65ee9a02f0bc5f28d1cb08284c9dd1286d975f9ce/typst-0.14.8-cp314-cp314t-win_amd64.whl", hash = "sha256:10710c58dbc8820a954970ba5d0af5611c7c57f8ddacfebb1a85ddb6449f01eb", size = 21471708, upload-time = "2026-02-08T02:30:54.185Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/cb/e49219a75d39ce866ae5d64e0a1d8d712b394ed3a1e7de3a8f4a35cde78e/typst-0.14.8-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:f47fe029f6ebe907f981ce0cb5208eab27eaf7342e319e6c798ac1dbae976f58", size = 22936285, upload-time = "2026-02-08T02:30:57.338Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/6b/d36f312c32b70303abd88d0abe6ffb50f8f7fcc0b457c914c78d791ed934/typst-0.14.8-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:aba11243463f6994ca1140b8515e70be1a98fd3025ae3211b84103499b0c5a5a", size = 22632767, upload-time = "2026-02-08T02:31:00.454Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/b4/87d2d24078b94645ba8788c8b4a5bbab6a3c779370141c31a02e2003ee0f/typst-0.14.8-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:544fcd9ce55b140115d7442b3661c45897778650c307e2eb0749efed29bbfcea", size = 26907232, upload-time = "2026-02-08T02:31:03.528Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/e8/3efdebcf37639daa4799e7a4c833a280f14685f7e6058fed576c6fb2e722/typst-0.14.8-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a296f85bf0d27043b031d1d2d74a34802e4876a8936f70784fbe99021b0dad4d", size = 26501791, upload-time = "2026-02-08T02:31:06.898Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/28/094d4b9f0ff4ee81f88eee2df00dbcfbd961070df981973bc385a1544ff8/typst-0.14.8-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6e3891a2e5551017c9030dd6de31587a29b97c18464df6bcff05f30f7cdab677", size = 28028881, upload-time = "2026-02-08T02:31:10.437Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/a1/15cd399dfc5ce0ea9e05d5bbc274c95f8ecababc04b4210bae8d583fe454/typst-0.14.8-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7a19cf938607c73fd8c5245a7cb32c94af413080a3d747fcf7e16df88713c686", size = 27128399, upload-time = "2026-02-08T02:31:13.597Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/6f/ff1c58dac9245d4c355bfced006090b14a2f17497e9cf79a84d9d720663a/typst-0.14.8-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a12766a83e390377008722a8c80afdd9195a297261fd3c9d1f3720f9aecd2b19", size = 26026753, upload-time = "2026-02-08T02:31:16.591Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/42/db15d775c09f0da92191ea1b50cee056e46b599bb5524e2d8ff51f973765/typst-0.14.8-cp38-abi3-win_amd64.whl", hash = "sha256:66eb2ebfe13275cf2a63ed7ff261eb5af3da5293077a5d6ca16e27a96d0d2f5e", size = 21475900, upload-time = "2026-02-08T02:31:19.484Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tzdata"
|
||||
version = "2025.3"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue