Merge branch 'main' into fix-transformable-label-todo

This commit is contained in:
Francisco Manríquez Novoa 2026-06-21 21:44:12 -04:00 committed by GitHub
commit e53a10e5cb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 2084 additions and 224 deletions

View file

@ -16,7 +16,7 @@ jobs:
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Login to DockerHub
uses: docker/login-action@v4
@ -41,7 +41,7 @@ jobs:
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Login to DockerHub
uses: docker/login-action@v4

View file

@ -33,7 +33,7 @@ jobs:
uv publish
- name: Store artifacts
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
path: dist/*.tar.gz
name: manim.tar.gz

View file

@ -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: |
@ -43,7 +43,7 @@ jobs:
tar -czvf ../html-docs.tar.gz *
- name: Store artifacts
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
path: ${{ github.workspace }}/docs/build/html-docs.tar.gz
name: html-docs.tar.gz

382
agents/typst_selector.md Normal file
View 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.

View file

@ -5,3 +5,4 @@ sphinx-copybutton
sphinxext-opengraph
sphinx-design
sphinx-reredirects
typst>=0.14

View file

@ -1,2 +1,3 @@
jupyterlab
sphinxcontrib-programoutput
typst>=0.14

View file

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

View file

@ -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 *

View file

@ -137,7 +137,7 @@ class Animation:
suspend_mobject_updating: bool = True,
introducer: bool = False,
*,
_on_finish: Callable[[], None] = lambda _: None,
_on_finish: Callable[[Scene], None] = lambda _: None,
use_override: bool = True, # included here to avoid TypeError if passed from a subclass' constructor
) -> None:
self._typecheck_input(mobject)

View file

@ -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,

View file

@ -669,8 +669,30 @@ class GenericGraph(VMobject, metaclass=ConvertToOpenGL):
"""Helper method for populating the edges of the graph."""
raise NotImplementedError("To be implemented in concrete subclasses")
def __getitem__(self: Graph, v: Hashable) -> Mobject:
return self.vertices[v]
def __getitem__(self: Graph, k: Hashable | tuple[Hashable, Hashable]) -> Mobject:
"""Get a vertex or edge by its name/identifier.
Parameters
----------
k
A vertex name (hashable) or an edge tuple ``(u, v)``.
Returns
-------
Mobject
The :class:`~.Mobject` corresponding to the given vertex or edge.
Raises
------
KeyError
If ``k`` is not a valid vertex or edge.
"""
if k in self.vertices:
return self.vertices[k]
elif k in self.edges:
return self.edges[k]
else:
raise ValueError(f"Could not find {k} in vertices or edges")
def _create_vertex(
self,
@ -1342,6 +1364,11 @@ class Graph(GenericGraph):
g[2].animate.move_to([-1, 1, 0]),
g[3].animate.move_to([1, -1, 0]),
g[4].animate.move_to([-1, -1, 0]))
self.play(LaggedStart(Wiggle(g[(1, 2)]),
Wiggle(g[(2, 3)]),
Wiggle(g[(3, 4)]),
Wiggle(g[(1, 3)]),
Wiggle(g[(1, 4)])))
self.wait()
There are several automatic positioning algorithms to choose from:

View file

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

View file

@ -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(

View file

@ -2162,13 +2162,31 @@ class Mobject:
def reduce_across_dimension(
self, reduce_func: Callable[[Iterable[float]], float], dim: int
) -> float:
"""Find the min or max value from a dimension across all points in this and submobjects."""
) -> float | None:
"""Find the min or max value from a dimension across all points in this Mobject and its
submobjects. This allows for using :meth:`~.length_over_dim` to calculate its length over
a dimension, i.e. its height, width or depth. If this Mobject is empty, return ``None``,
since this Mobject should not be taken into account when calculating lengths.
Parameters
----------
reduce_func
The reducer function to use in order to calculate a value over a dimension.
dim
The dimension to use. It should be 0, 1 or 2, representing the X, Y or Z coordinate,
respectively.
Returns
-------
float | None
The min or max value over the dimension specified by ``dim``, or ``None`` if this
Mobject is empty.
"""
assert dim >= 0
assert dim <= 2
if len(self.submobjects) == 0 and len(self.points) == 0:
# If we have no points and no submobjects, return 0 (e.g. center)
return 0
# If we have no points and no submobjects, return None
return None
# If we do not have points (but do have submobjects)
# use only the points from those.
@ -2181,8 +2199,10 @@ class Mobject:
# smallest dimension they have and compare it to the return value.
for mobj in self.submobjects:
value = mobj.reduce_across_dimension(reduce_func, dim)
rv = value if rv is None else reduce_func([value, rv])
assert rv is not None
if rv is None:
rv = value
elif value is not None:
rv = reduce_func([value, rv])
return rv
def nonempty_submobjects(self) -> Sequence[Mobject]:
@ -2336,11 +2356,10 @@ class Mobject:
def length_over_dim(self, dim: int) -> float:
"""Measure the length of an :class:`~.Mobject` in a certain direction."""
max_coord: float = self.reduce_across_dimension(
max,
dim,
)
min_coord: float = self.reduce_across_dimension(min, dim)
max_coord = self.reduce_across_dimension(max, dim)
min_coord = self.reduce_across_dimension(min, dim)
if max_coord is None or min_coord is None:
return 0
return max_coord - min_coord
def get_coord(self, dim: int, direction: Vector3DLike = ORIGIN) -> float:

View file

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

View file

@ -139,7 +139,10 @@ class Code(VMobject, metaclass=ConvertToOpenGL):
if code_file is not None:
code_file = Path(code_file)
code_string = code_file.read_text(encoding="utf-8")
lexer = guess_lexer_for_filename(code_file.name, code_string)
if language is not None:
lexer = get_lexer_by_name(language)
else:
lexer = guess_lexer_for_filename(code_file.name, code_string)
elif code_string is not None:
if language is not None:
lexer = get_lexer_by_name(language)

View 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

View file

@ -428,8 +428,8 @@ class ThreeDScene(Scene):
which have the same meaning as the parameters in set_camera_orientation.
"""
config = dict(
self.default_camera_orientation_kwargs,
) # Where doe this come from?
self.default_angled_camera_orientation_kwargs,
)
config.update(kwargs)
self.set_camera_orientation(**config)

View file

@ -48,6 +48,7 @@ if TYPE_CHECKING:
from typing import Self
from manim.typing import (
ManimTextLabel,
MappingFunction,
Point3D,
Point3DLike,
@ -337,13 +338,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.
@ -366,19 +367,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()
@ -401,8 +401,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.
@ -413,7 +417,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
@ -423,8 +427,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:

View file

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

View file

@ -33,8 +33,10 @@ class TexTemplate:
_body: str = field(default="", init=False)
"""A custom body, can be set from a file."""
tex_compiler: str = "latex"
"""The TeX compiler to be used, e.g. ``latex``, ``pdflatex`` or ``lualatex``."""
tex_compiler: str | list[str] = "latex"
"""The TeX compiler(s) to be used. Can be a single compiler (e.g. ``"latex"``,
``"pdflatex"``, ``"lualatex"``) or a list of compilers to compile in order
(e.g. ``["lualatex", "pdflatex"]``)."""
description: str = ""
"""A description of the template"""

View file

@ -178,7 +178,9 @@ def insight_package_not_found_error(matching: Match[str]) -> Generator[str]:
yield f"Install {matching[1]} it using your LaTeX package manager, or check for typos."
def compile_tex(tex_file: Path, tex_compiler: str, output_format: str) -> Path:
def compile_tex(
tex_file: Path, tex_compiler: str | list[str], output_format: str
) -> Path:
"""Compiles a tex_file into a .dvi or a .xdv or a .pdf
Parameters
@ -186,7 +188,8 @@ def compile_tex(tex_file: Path, tex_compiler: str, output_format: str) -> Path:
tex_file
File name of TeX file to be typeset.
tex_compiler
String containing the compiler to be used, e.g. ``pdflatex`` or ``lualatex``
The TeX compiler(s) to be used.
Can be a single compiler (e.g. ``"latex"``, ``"pdflatex"`` ``"lualatex"``) or a list of compilers to compile in order (e.g. ``["lualatex", "pdflatex"]``).
output_format
String containing the output format generated by the compiler, e.g. ``.dvi`` or ``.pdf``
@ -197,22 +200,26 @@ def compile_tex(tex_file: Path, tex_compiler: str, output_format: str) -> Path:
"""
result = tex_file.with_suffix(output_format)
tex_dir = config.get_dir("tex_dir")
tex_compilers = [tex_compiler] if isinstance(tex_compiler, str) else tex_compiler
if not result.exists():
command = make_tex_compilation_command(
tex_compiler,
output_format,
tex_file,
tex_dir,
)
cp = subprocess.run(command, stdout=subprocess.DEVNULL)
if cp.returncode != 0:
log_file = tex_file.with_suffix(".log")
print_all_tex_errors(log_file, tex_compiler, tex_file)
raise ValueError(
f"{tex_compiler} error converting to"
f" {output_format[1:]}. See log output above or"
f" the log file: {log_file}",
for i, compiler in enumerate(tex_compilers, start=1):
if len(tex_compilers) > 1:
logger.info(f"Compiling {i} of {len(tex_compilers)}: {compiler}")
command = make_tex_compilation_command(
compiler,
output_format,
tex_file,
tex_dir,
)
cp = subprocess.run(command, stdout=subprocess.DEVNULL)
if cp.returncode != 0:
log_file = tex_file.with_suffix(".log")
print_all_tex_errors(log_file, compiler, tex_file)
raise ValueError(
f"{compiler} error converting to"
f" {output_format[1:]}. See log output above or"
f" the log file: {log_file}",
)
return result

View 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

View file

@ -74,6 +74,9 @@ jupyterlab = [
"jupyterlab>=4.3.4",
"notebook>=7.3.2",
]
typst = [
"typst>=0.14",
]
[dependency-groups]
dev = [

View file

@ -3,7 +3,20 @@ from __future__ import annotations
import numpy as np
import pytest
from manim import DL, PI, UR, Circle, Mobject, Rectangle, Square, Triangle, VGroup
from manim import (
DL,
DR,
PI,
UL,
UR,
Circle,
Mobject,
Rectangle,
Square,
Triangle,
VGroup,
VMobject,
)
def test_mobject_add():
@ -135,22 +148,24 @@ def test_mobject_dimensions_nested_mobjects():
assert is_close(vg.depth, 0.775), vg.depth
def test_mobject_dimensions_mobjects_with_no_points_are_at_origin():
rect = Rectangle(width=2, height=3)
rect.move_to([-4, -5, 0])
outer_group = VGroup(rect)
def test_mobject_dimensions_mobjects_with_no_points():
empty_mob = VMobject()
assert empty_mob.width == 0
assert empty_mob.height == 0
# This is as one would expect
assert outer_group.width == 2
assert outer_group.height == 3
for direction in [DL, DR, UL, UR]:
rect = Rectangle(width=2, height=3)
rect.move_to(direction * 10)
outer_group = VGroup(rect)
# Adding a mobject with no points has a quirk of adding a "point"
# to [0, 0, 0] (the origin). This changes the size of the outer
# group because now the bottom left corner is at [-5, -6.5, 0]
# but the upper right corner is [0, 0, 0] instead of [-3, -3.5, 0]
outer_group.add(VGroup())
assert outer_group.width == 5
assert outer_group.height == 6.5
# This is as one would expect
assert outer_group.width == 2
assert outer_group.height == 3
# Adding a submobject with no points does not change the group size
outer_group.add(empty_mob)
assert outer_group.width == 2
assert outer_group.height == 3
def test_mobject_dimensions_has_points_and_children():

View file

@ -76,6 +76,19 @@ def test_graph_add_edges():
assert set(G._graph.edges()) == set(G.edges.keys())
def test_graph_getitem():
vertices = [1, 2, 3, 4]
edges = [(1, 2), (2, 3), (3, 4), (4, 1)]
G = Graph(vertices, edges)
# Vertex access
assert G[1] is G.vertices[1]
# Edge access via tuple key
assert G[(1, 2)] is G.edges[(1, 2)]
# DiGraph edge access
DG = DiGraph(vertices, edges)
assert DG[(1, 2)] is DG.edges[(1, 2)]
def test_graph_remove_edges():
G = Graph([1, 2, 3, 4, 5], [(1, 2), (2, 3), (3, 4), (4, 5), (1, 5)])
removed_mobjects = G.remove_edges((1, 2))

View 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

View file

@ -1,4 +1,4 @@
from manim import Circle, Square, ThreeDScene
from manim import DEGREES, Circle, Square, ThreeDScene
def test_fixed_mobjects():
@ -15,3 +15,12 @@ def test_fixed_mobjects():
assert set(scene.camera.fixed_orientation_mobjects) == {s}
scene.remove_fixed_orientation_mobjects(s)
assert len(scene.camera.fixed_orientation_mobjects) == 0
def test_set_to_default_angled_camera_orientation():
scene = ThreeDScene()
scene.set_to_default_angled_camera_orientation(phi=45 * DEGREES)
assert scene.camera.get_phi() == 45 * DEGREES
assert scene.camera.get_theta() == -135 * DEGREES

268
uv.lock generated
View file

@ -263,14 +263,14 @@ wheels = [
[[package]]
name = "bleach"
version = "6.3.0"
version = "6.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "webencodings" },
]
sdist = { url = "https://files.pythonhosted.org/packages/07/18/3c8523962314be6bf4c8989c79ad9531c825210dd13a8669f6b84336e8bd/bleach-6.3.0.tar.gz", hash = "sha256:6f3b91b1c0a02bb9a78b5a454c92506aa0fdf197e1d5e114d2e00c6f64306d22", size = 203533, upload-time = "2025-10-27T17:57:39.211Z" }
sdist = { url = "https://files.pythonhosted.org/packages/48/3c/e12ac860709702bd5ebeb9b56a4fe334f1001246ee1b8f2b7ee28912df7d/bleach-6.4.0.tar.gz", hash = "sha256:4202482733d85cedd04e59fcb2f89f4e4c7c385a78d3c3c23c30446843a37452", size = 204857, upload-time = "2026-06-05T13:01:13.734Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cd/3a/577b549de0cc09d95f11087ee63c739bba856cd3952697eec4c4bb91350a/bleach-6.3.0-py3-none-any.whl", hash = "sha256:fe10ec77c93ddf3d13a73b035abaac7a9f5e436513864ccdad516693213c65d6", size = 164437, upload-time = "2025-10-27T17:57:37.538Z" },
{ url = "https://files.pythonhosted.org/packages/58/9d/40b6267367182187139a4000b82a3b287d84d745bccd808e75d916920e9d/bleach-6.4.0-py3-none-any.whl", hash = "sha256:4b6b6a54fff2e69a3dde9d21cc6301220bee3c3cb792187d11403fd795031081", size = 165109, upload-time = "2026-06-05T13:01:12.504Z" },
]
[package.optional-dependencies]
@ -952,11 +952,11 @@ wheels = [
[[package]]
name = "idna"
version = "3.11"
version = "3.15"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
{ url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" },
]
[[package]]
@ -1203,7 +1203,7 @@ wheels = [
[[package]]
name = "jupyter-server"
version = "2.17.0"
version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
@ -1226,9 +1226,9 @@ dependencies = [
{ name = "traitlets" },
{ name = "websocket-client" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5b/ac/e040ec363d7b6b1f11304cc9f209dac4517ece5d5e01821366b924a64a50/jupyter_server-2.17.0.tar.gz", hash = "sha256:c38ea898566964c888b4772ae1ed58eca84592e88251d2cfc4d171f81f7e99d5", size = 731949, upload-time = "2025-08-21T14:42:54.042Z" }
sdist = { url = "https://files.pythonhosted.org/packages/6b/dc/db3a582633170186f8c8b31298d7eb26ad0eb031a1f53476c258b64eed05/jupyter_server-2.20.0.tar.gz", hash = "sha256:b5778ba337d8015a3dc2b80803ecdd5ac18d3797fddf61a50ea5fb472b4ebe14", size = 756523, upload-time = "2026-06-17T12:09:09.435Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/92/80/a24767e6ca280f5a49525d987bf3e4d7552bf67c8be07e8ccf20271f8568/jupyter_server-2.17.0-py3-none-any.whl", hash = "sha256:e8cb9c7db4251f51ed307e329b81b72ccf2056ff82d50524debde1ee1870e13f", size = 388221, upload-time = "2025-08-21T14:42:52.034Z" },
{ url = "https://files.pythonhosted.org/packages/f3/71/8c002223e873a870f5c41dc69b0a7c922301123e4a31d5d01ecb700aef77/jupyter_server-2.20.0-py3-none-any.whl", hash = "sha256:c3b67c93c471e947c18b5026f04f21614218adb706df8f48227d3ee8e0a7cdcc", size = 393143, upload-time = "2026-06-17T12:09:07.234Z" },
]
[[package]]
@ -1246,7 +1246,7 @@ wheels = [
[[package]]
name = "jupyterlab"
version = "4.5.2"
version = "4.5.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "async-lru" },
@ -1263,9 +1263,9 @@ dependencies = [
{ name = "tornado" },
{ name = "traitlets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/93/dc/2c8c4ff1aee27ac999ba04c373c5d0d7c6c181b391640d7b916b884d5985/jupyterlab-4.5.2.tar.gz", hash = "sha256:c80a6b9f6dace96a566d590c65ee2785f61e7cd4aac5b4d453dcc7d0d5e069b7", size = 23990371, upload-time = "2026-01-12T12:27:08.493Z" }
sdist = { url = "https://files.pythonhosted.org/packages/e8/52/a8d4895bef501ffeb6af448e8bf7079541c7772978211963aa653518c2d9/jupyterlab-4.5.9.tar.gz", hash = "sha256:dd79a073fecae7a39066ea99e4627ed6c76269ac926e95a810e1e1df6358d865", size = 23994445, upload-time = "2026-06-17T15:42:16.406Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a4/78/7e455920f104ef2aa94a4c0d2b40e5b44334ee7057eae1aa1fb97b9631ad/jupyterlab-4.5.2-py3-none-any.whl", hash = "sha256:76466ebcfdb7a9bb7e2fbd6459c0e2c032ccf75be673634a84bee4b3e6b13ab6", size = 12385807, upload-time = "2026-01-12T12:27:03.923Z" },
{ url = "https://files.pythonhosted.org/packages/c6/bb/2f9b425062416fba58f580c9b89c3b07277ccdf0a292501fedbca8ea00ea/jupyterlab-4.5.9-py3-none-any.whl", hash = "sha256:5ff0f908e8ac0afbed32b106fdef360f101c0a6654d1bf4a81e98a293ae1b336", size = 12449803, upload-time = "2026-06-17T15:42:12.18Z" },
]
[[package]]
@ -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 = [
@ -1781,11 +1785,11 @@ wheels = [
[[package]]
name = "mistune"
version = "3.2.0"
version = "3.2.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9d/55/d01f0c4b45ade6536c51170b9043db8b2ec6ddf4a35c7ea3f5f559ac935b/mistune-3.2.0.tar.gz", hash = "sha256:708487c8a8cdd99c9d90eb3ed4c3ed961246ff78ac82f03418f5183ab70e398a", size = 95467, upload-time = "2025-12-23T11:36:34.994Z" }
sdist = { url = "https://files.pythonhosted.org/packages/ca/84/620cc3f7e3adf6f5067e10f4dbae71295d8f9e16d5d3f9ef97c40f2f592c/mistune-3.2.1.tar.gz", hash = "sha256:7c8e5501d38bac1582e067e46c8343f17d57ea1aaa735823f3aba1fd59c88a28", size = 98003, upload-time = "2026-05-03T14:33:22.312Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9b/f7/4a5e785ec9fbd65146a27b6b70b6cdc161a66f2024e4b04ac06a67f5578b/mistune-3.2.0-py3-none-any.whl", hash = "sha256:febdc629a3c78616b94393c6580551e0e34cc289987ec6c35ed3f4be42d0eee1", size = 53598, upload-time = "2025-12-23T11:36:33.211Z" },
{ url = "https://files.pythonhosted.org/packages/2a/7f/a946aa4f8752b37102b41e64dca18a1976ac705c3a0d1dfe74d820a02552/mistune-3.2.1-py3-none-any.whl", hash = "sha256:78cdb0ba5e938053ccf63651b352508d2efa9411dc8810bfb05f2dc5140c0048", size = 53749, upload-time = "2026-05-03T14:33:20.551Z" },
]
[[package]]
@ -1873,7 +1877,7 @@ wheels = [
[[package]]
name = "nbconvert"
version = "7.17.0"
version = "7.17.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "beautifulsoup4" },
@ -1891,9 +1895,9 @@ dependencies = [
{ name = "pygments" },
{ name = "traitlets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/38/47/81f886b699450d0569f7bc551df2b1673d18df7ff25cc0c21ca36ed8a5ff/nbconvert-7.17.0.tar.gz", hash = "sha256:1b2696f1b5be12309f6c7d707c24af604b87dfaf6d950794c7b07acab96dda78", size = 862855, upload-time = "2026-01-29T16:37:48.478Z" }
sdist = { url = "https://files.pythonhosted.org/packages/01/b1/708e53fe2e429c103c6e6e159106bcf0357ac41aa4c28772bd8402339051/nbconvert-7.17.1.tar.gz", hash = "sha256:34d0d0a7e73ce3cbab6c5aae8f4f468797280b01fd8bd2ca746da8569eddd7d2", size = 865311, upload-time = "2026-04-08T00:44:14.914Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0d/4b/8d5f796a792f8a25f6925a96032f098789f448571eb92011df1ae59e8ea8/nbconvert-7.17.0-py3-none-any.whl", hash = "sha256:4f99a63b337b9a23504347afdab24a11faa7d86b405e5c8f9881cd313336d518", size = 261510, upload-time = "2026-01-29T16:37:46.322Z" },
{ url = "https://files.pythonhosted.org/packages/67/f8/bb0a9d5f46819c821dc1f004aa2cc29b1d91453297dbf5ff20470f00f193/nbconvert-7.17.1-py3-none-any.whl", hash = "sha256:aa85c087b435e7bf1ffd03319f658e285f2b89eccab33bc1ba7025495ab3e7c8", size = 261927, upload-time = "2026-04-08T00:44:12.845Z" },
]
[[package]]
@ -1940,7 +1944,7 @@ wheels = [
[[package]]
name = "notebook"
version = "7.5.2"
version = "7.5.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jupyter-server" },
@ -1949,9 +1953,9 @@ dependencies = [
{ name = "notebook-shim" },
{ name = "tornado" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3b/b6/6b2c653570b02e4ec2a94c0646a4a25132be0749617776d0b72a2bcedb9b/notebook-7.5.2.tar.gz", hash = "sha256:83e82f93c199ca730313bea1bb24bc279ea96f74816d038a92d26b6b9d5f3e4a", size = 14059605, upload-time = "2026-01-12T14:56:53.483Z" }
sdist = { url = "https://files.pythonhosted.org/packages/2a/c2/cf59bd2e6f2c8b976b52477e3e53bf6f97bc714ed046a51821afb428eaee/notebook-7.5.6.tar.gz", hash = "sha256:621174aade80108f0020b0f00738000b215f75fa3cd90771ad7aa0f24536a4e1", size = 14170814, upload-time = "2026-04-30T11:46:26.613Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/32/55/b754cd51c6011d90ef03e3f06136f1ebd44658b9529dbcf0c15fc0d6a0b7/notebook-7.5.2-py3-none-any.whl", hash = "sha256:17d078a98603d70d62b6b4b3fcb67e87d7a68c398a7ae9b447eb2d7d9aec9979", size = 14468915, upload-time = "2026-01-12T14:56:47.87Z" },
{ url = "https://files.pythonhosted.org/packages/e9/d6/1fd0646b9bbd9efbb0b8ae21b2325fbef515769a5621c03e31d8eb8da587/notebook-7.5.6-py3-none-any.whl", hash = "sha256:4dde3f8fb55fa8fb7946d58c6e869ce9baf46d00fc070664f62604569d0faca0", size = 14581730, upload-time = "2026-04-30T11:46:22.342Z" },
]
[[package]]
@ -2095,89 +2099,89 @@ wheels = [
[[package]]
name = "pillow"
version = "12.1.1"
version = "12.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" }
sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2b/46/5da1ec4a5171ee7bf1a0efa064aba70ba3d6e0788ce3f5acd1375d23c8c0/pillow-12.1.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:e879bb6cd5c73848ef3b2b48b8af9ff08c5b71ecda8048b7dd22d8a33f60be32", size = 5304084, upload-time = "2026-02-11T04:20:27.501Z" },
{ url = "https://files.pythonhosted.org/packages/78/93/a29e9bc02d1cf557a834da780ceccd54e02421627200696fcf805ebdc3fb/pillow-12.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:365b10bb9417dd4498c0e3b128018c4a624dc11c7b97d8cc54effe3b096f4c38", size = 4657866, upload-time = "2026-02-11T04:20:29.827Z" },
{ url = "https://files.pythonhosted.org/packages/13/84/583a4558d492a179d31e4aae32eadce94b9acf49c0337c4ce0b70e0a01f2/pillow-12.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d4ce8e329c93845720cd2014659ca67eac35f6433fd3050393d85f3ecef0dad5", size = 6232148, upload-time = "2026-02-11T04:20:31.329Z" },
{ url = "https://files.pythonhosted.org/packages/d5/e2/53c43334bbbb2d3b938978532fbda8e62bb6e0b23a26ce8592f36bcc4987/pillow-12.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc354a04072b765eccf2204f588a7a532c9511e8b9c7f900e1b64e3e33487090", size = 8038007, upload-time = "2026-02-11T04:20:34.225Z" },
{ url = "https://files.pythonhosted.org/packages/b8/a6/3d0e79c8a9d58150dd98e199d7c1c56861027f3829a3a60b3c2784190180/pillow-12.1.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e7976bf1910a8116b523b9f9f58bf410f3e8aa330cd9a2bb2953f9266ab49af", size = 6345418, upload-time = "2026-02-11T04:20:35.858Z" },
{ url = "https://files.pythonhosted.org/packages/a2/c8/46dfeac5825e600579157eea177be43e2f7ff4a99da9d0d0a49533509ac5/pillow-12.1.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:597bd9c8419bc7c6af5604e55847789b69123bbe25d65cc6ad3012b4f3c98d8b", size = 7034590, upload-time = "2026-02-11T04:20:37.91Z" },
{ url = "https://files.pythonhosted.org/packages/af/bf/e6f65d3db8a8bbfeaf9e13cc0417813f6319863a73de934f14b2229ada18/pillow-12.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c1fc0f2ca5f96a3c8407e41cca26a16e46b21060fe6d5b099d2cb01412222f5", size = 6458655, upload-time = "2026-02-11T04:20:39.496Z" },
{ url = "https://files.pythonhosted.org/packages/f9/c2/66091f3f34a25894ca129362e510b956ef26f8fb67a0e6417bc5744e56f1/pillow-12.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:578510d88c6229d735855e1f278aa305270438d36a05031dfaae5067cc8eb04d", size = 7159286, upload-time = "2026-02-11T04:20:41.139Z" },
{ url = "https://files.pythonhosted.org/packages/7b/5a/24bc8eb526a22f957d0cec6243146744966d40857e3d8deb68f7902ca6c1/pillow-12.1.1-cp311-cp311-win32.whl", hash = "sha256:7311c0a0dcadb89b36b7025dfd8326ecfa36964e29913074d47382706e516a7c", size = 6328663, upload-time = "2026-02-11T04:20:43.184Z" },
{ url = "https://files.pythonhosted.org/packages/31/03/bef822e4f2d8f9d7448c133d0a18185d3cce3e70472774fffefe8b0ed562/pillow-12.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:fbfa2a7c10cc2623f412753cddf391c7f971c52ca40a3f65dc5039b2939e8563", size = 7031448, upload-time = "2026-02-11T04:20:44.696Z" },
{ url = "https://files.pythonhosted.org/packages/49/70/f76296f53610bd17b2e7d31728b8b7825e3ac3b5b3688b51f52eab7c0818/pillow-12.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:b81b5e3511211631b3f672a595e3221252c90af017e399056d0faabb9538aa80", size = 2453651, upload-time = "2026-02-11T04:20:46.243Z" },
{ url = "https://files.pythonhosted.org/packages/07/d3/8df65da0d4df36b094351dce696f2989bec731d4f10e743b1c5f4da4d3bf/pillow-12.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab323b787d6e18b3d91a72fc99b1a2c28651e4358749842b8f8dfacd28ef2052", size = 5262803, upload-time = "2026-02-11T04:20:47.653Z" },
{ url = "https://files.pythonhosted.org/packages/d6/71/5026395b290ff404b836e636f51d7297e6c83beceaa87c592718747e670f/pillow-12.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:adebb5bee0f0af4909c30db0d890c773d1a92ffe83da908e2e9e720f8edf3984", size = 4657601, upload-time = "2026-02-11T04:20:49.328Z" },
{ url = "https://files.pythonhosted.org/packages/b1/2e/1001613d941c67442f745aff0f7cc66dd8df9a9c084eb497e6a543ee6f7e/pillow-12.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb66b7cc26f50977108790e2456b7921e773f23db5630261102233eb355a3b79", size = 6234995, upload-time = "2026-02-11T04:20:51.032Z" },
{ url = "https://files.pythonhosted.org/packages/07/26/246ab11455b2549b9233dbd44d358d033a2f780fa9007b61a913c5b2d24e/pillow-12.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aee2810642b2898bb187ced9b349e95d2a7272930796e022efaf12e99dccd293", size = 8045012, upload-time = "2026-02-11T04:20:52.882Z" },
{ url = "https://files.pythonhosted.org/packages/b2/8b/07587069c27be7535ac1fe33874e32de118fbd34e2a73b7f83436a88368c/pillow-12.1.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0b1cd6232e2b618adcc54d9882e4e662a089d5768cd188f7c245b4c8c44a397", size = 6349638, upload-time = "2026-02-11T04:20:54.444Z" },
{ url = "https://files.pythonhosted.org/packages/ff/79/6df7b2ee763d619cda2fb4fea498e5f79d984dae304d45a8999b80d6cf5c/pillow-12.1.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7aac39bcf8d4770d089588a2e1dd111cbaa42df5a94be3114222057d68336bd0", size = 7041540, upload-time = "2026-02-11T04:20:55.97Z" },
{ url = "https://files.pythonhosted.org/packages/2c/5e/2ba19e7e7236d7529f4d873bdaf317a318896bac289abebd4bb00ef247f0/pillow-12.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ab174cd7d29a62dd139c44bf74b698039328f45cb03b4596c43473a46656b2f3", size = 6462613, upload-time = "2026-02-11T04:20:57.542Z" },
{ url = "https://files.pythonhosted.org/packages/03/03/31216ec124bb5c3dacd74ce8efff4cc7f52643653bad4825f8f08c697743/pillow-12.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:339ffdcb7cbeaa08221cd401d517d4b1fe7a9ed5d400e4a8039719238620ca35", size = 7166745, upload-time = "2026-02-11T04:20:59.196Z" },
{ url = "https://files.pythonhosted.org/packages/1f/e7/7c4552d80052337eb28653b617eafdef39adfb137c49dd7e831b8dc13bc5/pillow-12.1.1-cp312-cp312-win32.whl", hash = "sha256:5d1f9575a12bed9e9eedd9a4972834b08c97a352bd17955ccdebfeca5913fa0a", size = 6328823, upload-time = "2026-02-11T04:21:01.385Z" },
{ url = "https://files.pythonhosted.org/packages/3d/17/688626d192d7261bbbf98846fc98995726bddc2c945344b65bec3a29d731/pillow-12.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:21329ec8c96c6e979cd0dfd29406c40c1d52521a90544463057d2aaa937d66a6", size = 7033367, upload-time = "2026-02-11T04:21:03.536Z" },
{ url = "https://files.pythonhosted.org/packages/ed/fe/a0ef1f73f939b0eca03ee2c108d0043a87468664770612602c63266a43c4/pillow-12.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:af9a332e572978f0218686636610555ae3defd1633597be015ed50289a03c523", size = 2453811, upload-time = "2026-02-11T04:21:05.116Z" },
{ url = "https://files.pythonhosted.org/packages/d5/11/6db24d4bd7685583caeae54b7009584e38da3c3d4488ed4cd25b439de486/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e", size = 4062689, upload-time = "2026-02-11T04:21:06.804Z" },
{ url = "https://files.pythonhosted.org/packages/33/c0/ce6d3b1fe190f0021203e0d9b5b99e57843e345f15f9ef22fcd43842fd21/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9", size = 4138535, upload-time = "2026-02-11T04:21:08.452Z" },
{ url = "https://files.pythonhosted.org/packages/a0/c6/d5eb6a4fb32a3f9c21a8c7613ec706534ea1cf9f4b3663e99f0d83f6fca8/pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6", size = 3601364, upload-time = "2026-02-11T04:21:10.194Z" },
{ url = "https://files.pythonhosted.org/packages/14/a1/16c4b823838ba4c9c52c0e6bbda903a3fe5a1bdbf1b8eb4fff7156f3e318/pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60", size = 5262561, upload-time = "2026-02-11T04:21:11.742Z" },
{ url = "https://files.pythonhosted.org/packages/bb/ad/ad9dc98ff24f485008aa5cdedaf1a219876f6f6c42a4626c08bc4e80b120/pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2", size = 4657460, upload-time = "2026-02-11T04:21:13.786Z" },
{ url = "https://files.pythonhosted.org/packages/9e/1b/f1a4ea9a895b5732152789326202a82464d5254759fbacae4deea3069334/pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850", size = 6232698, upload-time = "2026-02-11T04:21:15.949Z" },
{ url = "https://files.pythonhosted.org/packages/95/f4/86f51b8745070daf21fd2e5b1fe0eb35d4db9ca26e6d58366562fb56a743/pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289", size = 8041706, upload-time = "2026-02-11T04:21:17.723Z" },
{ url = "https://files.pythonhosted.org/packages/29/9b/d6ecd956bb1266dd1045e995cce9b8d77759e740953a1c9aad9502a0461e/pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e", size = 6346621, upload-time = "2026-02-11T04:21:19.547Z" },
{ url = "https://files.pythonhosted.org/packages/71/24/538bff45bde96535d7d998c6fed1a751c75ac7c53c37c90dc2601b243893/pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717", size = 7038069, upload-time = "2026-02-11T04:21:21.378Z" },
{ url = "https://files.pythonhosted.org/packages/94/0e/58cb1a6bc48f746bc4cb3adb8cabff73e2742c92b3bf7a220b7cf69b9177/pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a", size = 6460040, upload-time = "2026-02-11T04:21:23.148Z" },
{ url = "https://files.pythonhosted.org/packages/6c/57/9045cb3ff11eeb6c1adce3b2d60d7d299d7b273a2e6c8381a524abfdc474/pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029", size = 7164523, upload-time = "2026-02-11T04:21:25.01Z" },
{ url = "https://files.pythonhosted.org/packages/73/f2/9be9cb99f2175f0d4dbadd6616ce1bf068ee54a28277ea1bf1fbf729c250/pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b", size = 6332552, upload-time = "2026-02-11T04:21:27.238Z" },
{ url = "https://files.pythonhosted.org/packages/3f/eb/b0834ad8b583d7d9d42b80becff092082a1c3c156bb582590fcc973f1c7c/pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1", size = 7040108, upload-time = "2026-02-11T04:21:29.462Z" },
{ url = "https://files.pythonhosted.org/packages/d5/7d/fc09634e2aabdd0feabaff4a32f4a7d97789223e7c2042fd805ea4b4d2c2/pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a", size = 2453712, upload-time = "2026-02-11T04:21:31.072Z" },
{ url = "https://files.pythonhosted.org/packages/19/2a/b9d62794fc8a0dd14c1943df68347badbd5511103e0d04c035ffe5cf2255/pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da", size = 5264880, upload-time = "2026-02-11T04:21:32.865Z" },
{ url = "https://files.pythonhosted.org/packages/26/9d/e03d857d1347fa5ed9247e123fcd2a97b6220e15e9cb73ca0a8d91702c6e/pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc", size = 4660616, upload-time = "2026-02-11T04:21:34.97Z" },
{ url = "https://files.pythonhosted.org/packages/f7/ec/8a6d22afd02570d30954e043f09c32772bfe143ba9285e2fdb11284952cd/pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c", size = 6269008, upload-time = "2026-02-11T04:21:36.623Z" },
{ url = "https://files.pythonhosted.org/packages/3d/1d/6d875422c9f28a4a361f495a5f68d9de4a66941dc2c619103ca335fa6446/pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8", size = 8073226, upload-time = "2026-02-11T04:21:38.585Z" },
{ url = "https://files.pythonhosted.org/packages/a1/cd/134b0b6ee5eda6dc09e25e24b40fdafe11a520bc725c1d0bbaa5e00bf95b/pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20", size = 6380136, upload-time = "2026-02-11T04:21:40.562Z" },
{ url = "https://files.pythonhosted.org/packages/7a/a9/7628f013f18f001c1b98d8fffe3452f306a70dc6aba7d931019e0492f45e/pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13", size = 7067129, upload-time = "2026-02-11T04:21:42.521Z" },
{ url = "https://files.pythonhosted.org/packages/1e/f8/66ab30a2193b277785601e82ee2d49f68ea575d9637e5e234faaa98efa4c/pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf", size = 6491807, upload-time = "2026-02-11T04:21:44.22Z" },
{ url = "https://files.pythonhosted.org/packages/da/0b/a877a6627dc8318fdb84e357c5e1a758c0941ab1ddffdafd231983788579/pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524", size = 7190954, upload-time = "2026-02-11T04:21:46.114Z" },
{ url = "https://files.pythonhosted.org/packages/83/43/6f732ff85743cf746b1361b91665d9f5155e1483817f693f8d57ea93147f/pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986", size = 6336441, upload-time = "2026-02-11T04:21:48.22Z" },
{ url = "https://files.pythonhosted.org/packages/3b/44/e865ef3986611bb75bfabdf94a590016ea327833f434558801122979cd0e/pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c", size = 7045383, upload-time = "2026-02-11T04:21:50.015Z" },
{ url = "https://files.pythonhosted.org/packages/a8/c6/f4fb24268d0c6908b9f04143697ea18b0379490cb74ba9e8d41b898bd005/pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3", size = 2456104, upload-time = "2026-02-11T04:21:51.633Z" },
{ url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" },
{ url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" },
{ url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" },
{ url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" },
{ url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" },
{ url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" },
{ url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" },
{ url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" },
{ url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" },
{ url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" },
{ url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" },
{ url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" },
{ url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" },
{ url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" },
{ url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" },
{ url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" },
{ url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" },
{ url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" },
{ url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" },
{ url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" },
{ url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" },
{ url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" },
{ url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" },
{ url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" },
{ url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" },
{ url = "https://files.pythonhosted.org/packages/56/11/5d43209aa4cb58e0cc80127956ff1796a68b928e6324bbf06ef4db34367b/pillow-12.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:600fd103672b925fe62ed08e0d874ea34d692474df6f4bf7ebe148b30f89f39f", size = 5228606, upload-time = "2026-02-11T04:22:52.106Z" },
{ url = "https://files.pythonhosted.org/packages/5f/d5/3b005b4e4fda6698b371fa6c21b097d4707585d7db99e98d9b0b87ac612a/pillow-12.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:665e1b916b043cef294bc54d47bf02d87e13f769bc4bc5fa225a24b3a6c5aca9", size = 4622321, upload-time = "2026-02-11T04:22:53.827Z" },
{ url = "https://files.pythonhosted.org/packages/df/36/ed3ea2d594356fd8037e5a01f6156c74bc8d92dbb0fa60746cc96cabb6e8/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:495c302af3aad1ca67420ddd5c7bd480c8867ad173528767d906428057a11f0e", size = 5247579, upload-time = "2026-02-11T04:22:56.094Z" },
{ url = "https://files.pythonhosted.org/packages/54/9a/9cc3e029683cf6d20ae5085da0dafc63148e3252c2f13328e553aaa13cfb/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8fd420ef0c52c88b5a035a0886f367748c72147b2b8f384c9d12656678dfdfa9", size = 6989094, upload-time = "2026-02-11T04:22:58.288Z" },
{ url = "https://files.pythonhosted.org/packages/00/98/fc53ab36da80b88df0967896b6c4b4cd948a0dc5aa40a754266aa3ae48b3/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f975aa7ef9684ce7e2c18a3aa8f8e2106ce1e46b94ab713d156b2898811651d3", size = 5313850, upload-time = "2026-02-11T04:23:00.554Z" },
{ url = "https://files.pythonhosted.org/packages/30/02/00fa585abfd9fe9d73e5f6e554dc36cc2b842898cbfc46d70353dae227f8/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8089c852a56c2966cf18835db62d9b34fef7ba74c726ad943928d494fa7f4735", size = 5963343, upload-time = "2026-02-11T04:23:02.934Z" },
{ url = "https://files.pythonhosted.org/packages/f2/26/c56ce33ca856e358d27fda9676c055395abddb82c35ac0f593877ed4562e/pillow-12.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cb9bb857b2d057c6dfc72ac5f3b44836924ba15721882ef103cecb40d002d80e", size = 7029880, upload-time = "2026-02-11T04:23:04.783Z" },
{ url = "https://files.pythonhosted.org/packages/68/e1/748f5663efe6edcfc4e74b2b93edfb9b8b99b67f21a854c3ae416500a2d9/pillow-12.2.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab", size = 5354347, upload-time = "2026-04-01T14:42:44.255Z" },
{ url = "https://files.pythonhosted.org/packages/47/a1/d5ff69e747374c33a3b53b9f98cca7889fce1fd03d79cdc4e1bccc6c5a87/pillow-12.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65", size = 4695873, upload-time = "2026-04-01T14:42:46.452Z" },
{ url = "https://files.pythonhosted.org/packages/df/21/e3fbdf54408a973c7f7f89a23b2cb97a7ef30c61ab4142af31eee6aebc88/pillow-12.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7", size = 6280168, upload-time = "2026-04-01T14:42:49.228Z" },
{ url = "https://files.pythonhosted.org/packages/d3/f1/00b7278c7dd52b17ad4329153748f87b6756ec195ff786c2bdf12518337d/pillow-12.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e", size = 8088188, upload-time = "2026-04-01T14:42:51.735Z" },
{ url = "https://files.pythonhosted.org/packages/ad/cf/220a5994ef1b10e70e85748b75649d77d506499352be135a4989c957b701/pillow-12.2.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705", size = 6394401, upload-time = "2026-04-01T14:42:54.343Z" },
{ url = "https://files.pythonhosted.org/packages/e9/bd/e51a61b1054f09437acfbc2ff9106c30d1eb76bc1453d428399946781253/pillow-12.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176", size = 7079655, upload-time = "2026-04-01T14:42:56.954Z" },
{ url = "https://files.pythonhosted.org/packages/6b/3d/45132c57d5fb4b5744567c3817026480ac7fc3ce5d4c47902bc0e7f6f853/pillow-12.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b", size = 6503105, upload-time = "2026-04-01T14:42:59.847Z" },
{ url = "https://files.pythonhosted.org/packages/7d/2e/9df2fc1e82097b1df3dce58dc43286aa01068e918c07574711fcc53e6fb4/pillow-12.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909", size = 7203402, upload-time = "2026-04-01T14:43:02.664Z" },
{ url = "https://files.pythonhosted.org/packages/bd/2e/2941e42858ebb67e50ae741473de81c2984e6eff7b397017623c676e2e8d/pillow-12.2.0-cp311-cp311-win32.whl", hash = "sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808", size = 6378149, upload-time = "2026-04-01T14:43:05.274Z" },
{ url = "https://files.pythonhosted.org/packages/69/42/836b6f3cd7f3e5fa10a1f1a5420447c17966044c8fbf589cc0452d5502db/pillow-12.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60", size = 7082626, upload-time = "2026-04-01T14:43:08.557Z" },
{ url = "https://files.pythonhosted.org/packages/c2/88/549194b5d6f1f494b485e493edc6693c0a16f4ada488e5bd974ed1f42fad/pillow-12.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe", size = 2463531, upload-time = "2026-04-01T14:43:10.743Z" },
{ url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" },
{ url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" },
{ url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" },
{ url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" },
{ url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" },
{ url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" },
{ url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" },
{ url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" },
{ url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" },
{ url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" },
{ url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" },
{ url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" },
{ url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" },
{ url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" },
{ url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" },
{ url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" },
{ url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" },
{ url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" },
{ url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" },
{ url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" },
{ url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" },
{ url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" },
{ url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" },
{ url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" },
{ url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" },
{ url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" },
{ url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" },
{ url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" },
{ url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" },
{ url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" },
{ url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" },
{ url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" },
{ url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" },
{ url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" },
{ url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" },
{ url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" },
{ url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" },
{ url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" },
{ url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" },
{ url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" },
{ url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" },
{ url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" },
{ url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" },
{ url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" },
{ url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" },
{ url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" },
{ url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" },
{ url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" },
{ url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" },
{ url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" },
{ url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" },
{ url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" },
{ url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" },
{ url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" },
{ url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" },
{ url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" },
{ url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" },
{ url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" },
{ url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" },
{ url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" },
{ url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" },
{ url = "https://files.pythonhosted.org/packages/4e/b7/2437044fb910f499610356d1352e3423753c98e34f915252aafecc64889f/pillow-12.2.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f", size = 5273969, upload-time = "2026-04-01T14:45:55.538Z" },
{ url = "https://files.pythonhosted.org/packages/f6/f4/8316e31de11b780f4ac08ef3654a75555e624a98db1056ecb2122d008d5a/pillow-12.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d", size = 4659674, upload-time = "2026-04-01T14:45:58.093Z" },
{ url = "https://files.pythonhosted.org/packages/d4/37/664fca7201f8bb2aa1d20e2c3d5564a62e6ae5111741966c8319ca802361/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f", size = 5288479, upload-time = "2026-04-01T14:46:01.141Z" },
{ url = "https://files.pythonhosted.org/packages/49/62/5b0ed78fce87346be7a5cfcfaaad91f6a1f98c26f86bdbafa2066c647ef6/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e", size = 7032230, upload-time = "2026-04-01T14:46:03.874Z" },
{ url = "https://files.pythonhosted.org/packages/c3/28/ec0fc38107fc32536908034e990c47914c57cd7c5a3ece4d8d8f7ffd7e27/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0", size = 5355404, upload-time = "2026-04-01T14:46:06.33Z" },
{ url = "https://files.pythonhosted.org/packages/5e/8b/51b0eddcfa2180d60e41f06bd6d0a62202b20b59c68f5a132e615b75aecf/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1", size = 6002215, upload-time = "2026-04-01T14:46:08.83Z" },
{ url = "https://files.pythonhosted.org/packages/bc/60/5382c03e1970de634027cee8e1b7d39776b778b81812aaf45b694dfe9e28/pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e", size = 7080946, upload-time = "2026-04-01T14:46:11.734Z" },
]
[[package]]
@ -2448,7 +2452,7 @@ wheels = [
[[package]]
name = "pytest"
version = "9.0.2"
version = "9.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
@ -2457,9 +2461,9 @@ dependencies = [
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
]
[[package]]
@ -3295,19 +3299,19 @@ wheels = [
[[package]]
name = "tornado"
version = "6.5.5"
version = "6.5.7"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f8/f1/3173dfa4a18db4a9b03e5d55325559dab51ee653763bb8745a75af491286/tornado-6.5.5.tar.gz", hash = "sha256:192b8f3ea91bd7f1f50c06955416ed76c6b72f96779b962f07f911b91e8d30e9", size = 516006, upload-time = "2026-03-10T21:31:02.067Z" }
sdist = { url = "https://files.pythonhosted.org/packages/64/24/95ec527ad67b76d59299e5465b3935d05e4294b7e0290a3924b7487df30b/tornado-6.5.7.tar.gz", hash = "sha256:66c513a76cda70d53907bc27cf1447557699c2e95aa48ba27a442ff61c3ddfc2", size = 519252, upload-time = "2026-06-08T17:34:51.232Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/59/8c/77f5097695f4dd8255ecbd08b2a1ed8ba8b953d337804dd7080f199e12bf/tornado-6.5.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:487dc9cc380e29f58c7ab88f9e27cdeef04b2140862e5076a66fb6bb68bb1bfa", size = 445983, upload-time = "2026-03-10T21:30:44.28Z" },
{ url = "https://files.pythonhosted.org/packages/ab/5e/7625b76cd10f98f1516c36ce0346de62061156352353ef2da44e5c21523c/tornado-6.5.5-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:65a7f1d46d4bb41df1ac99f5fcb685fb25c7e61613742d5108b010975a9a6521", size = 444246, upload-time = "2026-03-10T21:30:46.571Z" },
{ url = "https://files.pythonhosted.org/packages/b2/04/7b5705d5b3c0fab088f434f9c83edac1573830ca49ccf29fb83bf7178eec/tornado-6.5.5-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e74c92e8e65086b338fd56333fb9a68b9f6f2fe7ad532645a290a464bcf46be5", size = 447229, upload-time = "2026-03-10T21:30:48.273Z" },
{ url = "https://files.pythonhosted.org/packages/34/01/74e034a30ef59afb4097ef8659515e96a39d910b712a89af76f5e4e1f93c/tornado-6.5.5-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:435319e9e340276428bbdb4e7fa732c2d399386d1de5686cb331ec8eee754f07", size = 448192, upload-time = "2026-03-10T21:30:51.22Z" },
{ url = "https://files.pythonhosted.org/packages/be/00/fe9e02c5a96429fce1a1d15a517f5d8444f9c412e0bb9eadfbe3b0fc55bf/tornado-6.5.5-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3f54aa540bdbfee7b9eb268ead60e7d199de5021facd276819c193c0fb28ea4e", size = 448039, upload-time = "2026-03-10T21:30:53.52Z" },
{ url = "https://files.pythonhosted.org/packages/82/9e/656ee4cec0398b1d18d0f1eb6372c41c6b889722641d84948351ae19556d/tornado-6.5.5-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:36abed1754faeb80fbd6e64db2758091e1320f6bba74a4cf8c09cd18ccce8aca", size = 447445, upload-time = "2026-03-10T21:30:55.541Z" },
{ url = "https://files.pythonhosted.org/packages/5a/76/4921c00511f88af86a33de770d64141170f1cfd9c00311aea689949e274e/tornado-6.5.5-cp39-abi3-win32.whl", hash = "sha256:dd3eafaaeec1c7f2f8fdcd5f964e8907ad788fe8a5a32c4426fbbdda621223b7", size = 448582, upload-time = "2026-03-10T21:30:57.142Z" },
{ url = "https://files.pythonhosted.org/packages/2c/23/f6c6112a04d28eed765e374435fb1a9198f73e1ec4b4024184f21faeb1ad/tornado-6.5.5-cp39-abi3-win_amd64.whl", hash = "sha256:6443a794ba961a9f619b1ae926a2e900ac20c34483eea67be4ed8f1e58d3ef7b", size = 448990, upload-time = "2026-03-10T21:30:58.857Z" },
{ url = "https://files.pythonhosted.org/packages/b7/c8/876602cbc96469911f0939f703453c1157b0c826ecb05bdd32e023397d4e/tornado-6.5.5-cp39-abi3-win_arm64.whl", hash = "sha256:2c9a876e094109333f888539ddb2de4361743e5d21eece20688e3e351e4990a6", size = 448016, upload-time = "2026-03-10T21:31:00.43Z" },
{ url = "https://files.pythonhosted.org/packages/02/dc/c7043cab6fed8ae159fc1923ce829ada35c4dbd797d408a43858ffaf9639/tornado-6.5.7-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:148b2eb15c2c765a50796172c1e499649b35f30d2e3c3d3e15913cfa56bfb163", size = 448543, upload-time = "2026-06-08T17:34:38.052Z" },
{ url = "https://files.pythonhosted.org/packages/92/4f/090b1431e5a43df696feceffc268c5383cc079ecb5f08ce58f917109aafe/tornado-6.5.7-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:9da38de27f1da3b78a966f0dae12b5a1ea9afe72ca805d84ff06508272ddf100", size = 446707, upload-time = "2026-06-08T17:34:39.594Z" },
{ url = "https://files.pythonhosted.org/packages/37/d8/ef374952fd5da67d4463122c2b8e5a96536ec10b4b339254c6dcde81d01c/tornado-6.5.7-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8d759e71906ee783f8867b93bf26a265743da4c1e2f4a018464c1ba019862972", size = 449774, upload-time = "2026-06-08T17:34:41.204Z" },
{ url = "https://files.pythonhosted.org/packages/35/37/d434c73f4c6e014b745b9b37085f34f40c022f007efff3d7fe65991899f3/tornado-6.5.7-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a46347a18f23fb92b396beebe0fb78f61dda0cc302445202c16203d8a18848b", size = 450745, upload-time = "2026-06-08T17:34:42.531Z" },
{ url = "https://files.pythonhosted.org/packages/b6/2b/56b9aff361d7f1ab728a805ec7d7ea835f8807afa9f5cc690ea0e630efb9/tornado-6.5.7-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7778b30bef919231265e91c69963ce0f49a1e9c07ac900bbe75b19ce2575ba92", size = 450578, upload-time = "2026-06-08T17:34:43.787Z" },
{ url = "https://files.pythonhosted.org/packages/02/30/a7444fb23aa76860a14198fab96ac79f1866b0a6e19e26c4381b0938e50f/tornado-6.5.7-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e726f0c75da7726eec023aa62751ff8878bd2737e34fbdd33b1ae5897d2200f5", size = 449985, upload-time = "2026-06-08T17:34:45.326Z" },
{ url = "https://files.pythonhosted.org/packages/5c/42/5f0e56c01e8d9d36f4e23f367b85ae6cae0c1ecddd5e6977d8388ad27488/tornado-6.5.7-cp39-abi3-win32.whl", hash = "sha256:f8de3bf12d3efdd0cbe7c8887868198f8a91415e3f29fcf258d9b8eb7b1d9ae4", size = 451047, upload-time = "2026-06-08T17:34:46.784Z" },
{ url = "https://files.pythonhosted.org/packages/c9/a4/b393076ffb21b469eec5b328a0534cf03a3b90bfc6b1f09507cdd075d938/tornado-6.5.7-cp39-abi3-win_amd64.whl", hash = "sha256:de942f843533a039ef9fa3d9c88c7cd8a7c94553fb5ad0154270989b3d99a2c4", size = 451485, upload-time = "2026-06-08T17:34:48.248Z" },
{ url = "https://files.pythonhosted.org/packages/71/2e/7b1c769803121b809112cf9a00681c472eae1d80e32d7ec0e0bd61d0d0e1/tornado-6.5.7-cp39-abi3-win_arm64.whl", hash = "sha256:ff934fce95643af5f11efdae618eaa73d469dc588641e5c8d19295a0c65c4796", size = 450506, upload-time = "2026-06-08T17:34:49.702Z" },
]
[[package]]
@ -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"
@ -3399,11 +3427,11 @@ wheels = [
[[package]]
name = "urllib3"
version = "2.6.3"
version = "2.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
]
[[package]]