Merge branch 'main' into dependabot/uv/mistune-3.2.1

This commit is contained in:
Francisco Manríquez Novoa 2026-06-16 19:41:35 -04:00 committed by GitHub
commit 059e7020ec
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 2526 additions and 235 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

@ -46,6 +46,7 @@ from ..constants import (
RendererType,
)
from ..mobject.mobject import Group, Mobject
from ..mobject.types.vectorized_mobject import VGroup
from ..utils.paths import path_along_arc, path_along_circles
from ..utils.rate_functions import smooth, squish_rate_func
@ -735,10 +736,13 @@ class CyclicReplace(Transform):
def __init__(
self, *mobjects: Mobject, path_arc: float = 90 * DEGREES, **kwargs
) -> None:
self.group = Group(*mobjects)
if len(mobjects) == 1 and isinstance(mobjects[0], (Group, VGroup)):
self.group = mobjects[0]
else:
self.group = Group(*mobjects)
super().__init__(self.group, path_arc=path_arc, **kwargs)
def create_target(self) -> Group:
def create_target(self) -> Group | VGroup:
target = self.group.copy()
cycled_targets = [target[-1], *target[:-1]]
for m1, m2 in zip(cycled_targets, self.group, strict=True):
@ -747,7 +751,21 @@ class CyclicReplace(Transform):
class Swap(CyclicReplace):
pass # Renaming, more understandable for two entries
"""Another name for :class:`~.CyclicReplace`, which is more understandable for two entries.
Examples
--------
.. manim :: SwapExample
class SwapExample(Scene):
def construct(self):
text_a = Text("A").move_to(LEFT)
text_b = Text("B").move_to(RIGHT)
text_group = Group(text_a, text_b)
self.play(FadeIn(text_group))
self.play(Swap(text_group))
self.wait()
"""
# TODO, this may be deprecated...worth reimplementing?
@ -835,7 +853,14 @@ class FadeTransform(Transform):
"""
def __init__(self, mobject, target_mobject, stretch=True, dim_to_match=1, **kwargs):
def __init__(
self,
mobject: Mobject,
target_mobject: Mobject,
stretch: bool = True,
dim_to_match: int = 1,
**kwargs: Any,
):
self.to_add_on_completion = target_mobject
self.stretch = stretch
self.dim_to_match = dim_to_match

View file

@ -4,12 +4,13 @@ from __future__ import annotations
__all__ = ["TransformMatchingShapes", "TransformMatchingTex"]
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any
import numpy as np
from manim.mobject.opengl.opengl_mobject import OpenGLGroup, OpenGLMobject
from manim.mobject.opengl.opengl_vectorized_mobject import OpenGLVGroup, OpenGLVMobject
from manim.mobject.text.tex_mobject import MathTexPart
from .._config import config
from ..constants import RendererType
@ -74,10 +75,10 @@ class TransformMatchingAbstractBase(AnimationGroup):
transform_mismatches: bool = False,
fade_transform_mismatches: bool = False,
key_map: dict | None = None,
**kwargs,
**kwargs: Any,
):
if isinstance(mobject, OpenGLVMobject):
group_type = OpenGLVGroup
group_type: type[OpenGLVGroup | OpenGLGroup | VGroup | Group] = OpenGLVGroup
elif isinstance(mobject, OpenGLMobject):
group_type = OpenGLGroup
elif isinstance(mobject, VMobject):
@ -141,7 +142,7 @@ class TransformMatchingAbstractBase(AnimationGroup):
self.to_add = target_mobject
def get_shape_map(self, mobject: Mobject) -> dict:
shape_map = {}
shape_map: dict[int | str, VGroup | OpenGLVGroup] = {}
for sm in self.get_mobject_parts(mobject):
key = self.get_mobject_key(sm)
if key not in shape_map:
@ -149,23 +150,25 @@ class TransformMatchingAbstractBase(AnimationGroup):
shape_map[key] = OpenGLVGroup()
else:
shape_map[key] = VGroup()
shape_map[key].add(sm)
# error: Argument 1 to "add" of "OpenGLVGroup" has incompatible type "Mobject"; expected "OpenGLVMobject" [arg-type]
shape_map[key].add(sm) # type: ignore[arg-type]
return shape_map
def clean_up_from_scene(self, scene: Scene) -> None:
# Interpolate all animations back to 0 to ensure source mobjects remain unchanged.
for anim in self.animations:
anim.interpolate(0)
scene.remove(self.mobject)
# error: Argument 1 to "remove" of "Scene" has incompatible type "OpenGLMobject"; expected "Mobject" [arg-type]
scene.remove(self.mobject) # type: ignore[arg-type]
scene.remove(*self.to_remove)
scene.add(self.to_add)
@staticmethod
def get_mobject_parts(mobject: Mobject):
def get_mobject_parts(mobject: Mobject) -> list[Mobject]:
raise NotImplementedError("To be implemented in subclass.")
@staticmethod
def get_mobject_key(mobject: Mobject):
def get_mobject_key(mobject: Mobject) -> int | str:
raise NotImplementedError("To be implemented in subclass.")
@ -205,7 +208,7 @@ class TransformMatchingShapes(TransformMatchingAbstractBase):
transform_mismatches: bool = False,
fade_transform_mismatches: bool = False,
key_map: dict | None = None,
**kwargs,
**kwargs: Any,
):
super().__init__(
mobject,
@ -269,7 +272,7 @@ class TransformMatchingTex(TransformMatchingAbstractBase):
transform_mismatches: bool = False,
fade_transform_mismatches: bool = False,
key_map: dict | None = None,
**kwargs,
**kwargs: Any,
):
super().__init__(
mobject,
@ -294,4 +297,5 @@ class TransformMatchingTex(TransformMatchingAbstractBase):
@staticmethod
def get_mobject_key(mobject: Mobject) -> str:
assert isinstance(mobject, MathTexPart)
return mobject.tex_string

View file

@ -16,7 +16,7 @@ __all__ = [
import inspect
from collections.abc import Callable
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, TypeVar
import numpy as np
@ -29,6 +29,9 @@ if TYPE_CHECKING:
from manim.animation.animation import Animation
M = TypeVar("M", bound=Mobject)
def assert_is_mobject_method(method: Callable) -> None:
assert inspect.ismethod(method)
mobject = method.__self__
@ -43,7 +46,7 @@ def always(method: Callable, *args, **kwargs) -> Mobject:
return mobject
def f_always(method: Callable[[Mobject], None], *arg_generators, **kwargs) -> Mobject:
def f_always(method: Callable[[M], None], *arg_generators, **kwargs) -> M:
"""
More functional version of always, where instead
of taking in args, it takes in functions which output
@ -61,7 +64,7 @@ def f_always(method: Callable[[Mobject], None], *arg_generators, **kwargs) -> Mo
return mobject
def always_redraw(func: Callable[[], Mobject]) -> Mobject:
def always_redraw(func: Callable[[], M]) -> M:
"""Redraw the mobject constructed by a function every frame.
This function returns a mobject with an attached updater that
@ -107,8 +110,8 @@ def always_redraw(func: Callable[[], Mobject]) -> Mobject:
def always_shift(
mobject: Mobject, direction: np.ndarray[np.float64] = RIGHT, rate: float = 0.1
) -> Mobject:
mobject: M, direction: np.ndarray[np.float64] = RIGHT, rate: float = 0.1
) -> M:
"""A mobject which is continuously shifted along some direction
at a certain rate.
@ -145,7 +148,7 @@ def always_shift(
return mobject
def always_rotate(mobject: Mobject, rate: float = 20 * DEGREES, **kwargs) -> Mobject:
def always_rotate(mobject: M, rate: float = 20 * DEGREES, **kwargs) -> M:
"""A mobject which is continuously rotated at a certain rate.
Parameters

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

@ -104,6 +104,8 @@ class Table(VGroup):
Horizontal buffer passed to :meth:`~.Mobject.arrange_in_grid`, by default 1.3.
include_outer_lines
``True`` if the table should include outer lines, by default False.
include_inner_lines
``True`` if the table should include inner lines, by default True.
add_background_rectangles_to_entries
``True`` if background rectangles should be added to entries, by default ``False``.
entries_background_color
@ -193,6 +195,7 @@ class Table(VGroup):
v_buff: float = 0.8,
h_buff: float = 1.3,
include_outer_lines: bool = False,
include_inner_lines: bool = True,
add_background_rectangles_to_entries: bool = False,
entries_background_color: ParsableManimColor = BLACK,
include_background_rectangle: bool = False,
@ -214,6 +217,7 @@ class Table(VGroup):
self.v_buff = v_buff
self.h_buff = h_buff
self.include_outer_lines = include_outer_lines
self.include_inner_lines = include_inner_lines
self.add_background_rectangles_to_entries = add_background_rectangles_to_entries
self.entries_background_color = ManimColor(entries_background_color)
self.include_background_rectangle = include_background_rectangle
@ -349,15 +353,19 @@ class Table(VGroup):
)
line_group.add(line)
self.add(line)
for k in range(len(self.mob_table) - 1):
anchor = self.get_rows()[k + 1].get_top()[1] + 0.5 * (
self.get_rows()[k].get_bottom()[1] - self.get_rows()[k + 1].get_top()[1]
)
line = Line(
[anchor_left, anchor, 0], [anchor_right, anchor, 0], **self.line_config
)
line_group.add(line)
self.add(line)
if self.include_inner_lines:
for k in range(len(self.mob_table) - 1):
anchor = self.get_rows()[k + 1].get_top()[1] + 0.5 * (
self.get_rows()[k].get_bottom()[1]
- self.get_rows()[k + 1].get_top()[1]
)
line = Line(
[anchor_left, anchor, 0],
[anchor_right, anchor, 0],
**self.line_config,
)
line_group.add(line)
self.add(line)
self.horizontal_lines = line_group
return self
@ -379,16 +387,19 @@ class Table(VGroup):
)
line_group.add(line)
self.add(line)
for k in range(len(self.mob_table[0]) - 1):
anchor = self.get_columns()[k + 1].get_left()[0] + 0.5 * (
self.get_columns()[k].get_right()[0]
- self.get_columns()[k + 1].get_left()[0]
)
line = Line(
[anchor, anchor_bottom, 0], [anchor, anchor_top, 0], **self.line_config
)
line_group.add(line)
self.add(line)
if self.include_inner_lines:
for k in range(len(self.mob_table[0]) - 1):
anchor = self.get_columns()[k + 1].get_left()[0] + 0.5 * (
self.get_columns()[k].get_right()[0]
- self.get_columns()[k + 1].get_left()[0]
)
line = Line(
[anchor, anchor_bottom, 0],
[anchor, anchor_top, 0],
**self.line_config,
)
line_group.add(line)
self.add(line)
self.vertical_lines = line_group
return self

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,
@ -301,13 +302,13 @@ class VectorScene(Scene):
def get_vector_label(
self,
vector: Vector,
label: MathTex | str,
label: ManimTextLabel | str,
at_tip: bool = False,
direction: str = "left",
rotate: bool = False,
color: ParsableManimColor | None = None,
label_scale_factor: float = LARGE_BUFF - 0.2,
) -> MathTex:
) -> ManimTextLabel:
"""
Returns naming labels for the passed vector.
@ -330,19 +331,18 @@ class VectorScene(Scene):
Returns
-------
MathTex
The MathTex of the label.
:class:`~.ManimTextLabel`
The rendered label mobject.
"""
if not isinstance(label, MathTex):
if isinstance(label, str):
if len(label) == 1:
label = "\\vec{\\textbf{%s}}" % label # noqa: UP031
label = rf"\vec{{\textbf{{{label}}}}}"
label = MathTex(label)
if color is None:
prepared_color: ParsableManimColor = vector.get_color()
else:
prepared_color = color
label.set_color(prepared_color)
assert isinstance(label, MathTex)
label.scale(label_scale_factor)
label.add_background_rectangle()
@ -365,8 +365,12 @@ class VectorScene(Scene):
return label
def label_vector(
self, vector: Vector, label: MathTex | str, animate: bool = True, **kwargs: Any
) -> MathTex:
self,
vector: Vector,
label: ManimTextLabel | str,
animate: bool = True,
**kwargs: Any,
) -> ManimTextLabel:
"""
Shortcut method for creating, and animating the addition of
a label for the vector.
@ -377,7 +381,7 @@ class VectorScene(Scene):
The vector for which the label must be added.
label
The MathTex/string of the label.
The rendered label mobject or the string used to create one.
animate
Whether or not to animate the labelling w/ Write
@ -387,8 +391,8 @@ class VectorScene(Scene):
Returns
-------
:class:`~.MathTex`
The MathTex of the label.
:class:`~.ManimTextLabel`
The rendered label mobject.
"""
mathtex_label = self.get_vector_label(vector, label, **kwargs)
if animate:
@ -703,7 +707,7 @@ class LinearTransformationScene(VectorScene):
self.foreground_mobjects: list[Mobject] = []
self.transformable_mobjects: list[Mobject] = []
self.moving_vectors: list[Mobject] = []
self.transformable_labels: list[MathTex] = []
self.transformable_labels: list[Any] = []
self.moving_mobjects: list[Mobject] = []
self.background_plane = NumberPlane(**self.background_plane_kwargs)
@ -971,16 +975,17 @@ class LinearTransformationScene(VectorScene):
"""
# TODO: Clear up types in this function. This is currently a mess.
label_mob = self.label_vector(vector, label, **kwargs)
label_mob_any = cast(Any, label_mob)
if new_label:
label_mob.target_text = new_label # type: ignore[attr-defined]
label_mob_any.target_text = new_label
else:
label_mob.target_text = ( # type: ignore[attr-defined]
label_mob_any.target_text = (
f"{transformation_name}({label_mob.get_tex_string()})"
)
label_mob.vector = vector # type: ignore[attr-defined]
label_mob.kwargs = kwargs # type: ignore[attr-defined]
if "animate" in label_mob.kwargs:
label_mob.kwargs.pop("animate")
label_mob_any.vector = vector
label_mob_any.kwargs = kwargs
if "animate" in label_mob_any.kwargs:
label_mob_any.kwargs.pop("animate")
self.transformable_labels.append(label_mob)
return cast(MathTex, label_mob)
@ -1149,11 +1154,12 @@ class LinearTransformationScene(VectorScene):
for label in self.transformable_labels:
# TODO: This location and lines 933 and 335 are the only locations in
# the code where the target_text property is referenced.
target_text: MathTex | str = label.target_text # type: ignore[assignment]
label_any = cast(Any, label)
target_text: MathTex | str = label_any.target_text
label.target = self.get_vector_label(
label.vector.target, # type: ignore[attr-defined]
label_any.vector.target,
target_text,
**label.kwargs, # type: ignore[arg-type]
**label_any.kwargs,
)
return self.get_piece_movement(self.transformable_labels)

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

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

@ -64,9 +64,6 @@ ignore_errors = True
[mypy-manim.animation.speedmodifier]
ignore_errors = True
[mypy-manim.animation.transform_matching_parts]
ignore_errors = True
[mypy-manim.animation.transform]
ignore_errors = True

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

@ -17,3 +17,27 @@ def test_highlighted_cell_color_access():
# Should not raise RecursionError
color = rect.color
assert color == GREEN
def test_table_include_inner_lines_false():
"""Verify that inner lines can be disabled while outer lines remain."""
table = Table(
[["A", "B"], ["C", "D"]],
include_outer_lines=True,
include_inner_lines=False,
)
assert len(table.get_horizontal_lines()) == 2
assert len(table.get_vertical_lines()) == 2
def test_table_include_inner_lines_true():
"""Verify that inner lines are present by default."""
table = Table(
[["A", "B"], ["C", "D"]],
include_outer_lines=True,
include_inner_lines=True,
)
assert len(table.get_horizontal_lines()) == 3
assert len(table.get_vertical_lines()) == 3

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

@ -61,3 +61,33 @@ def test_background_stroke_scale():
b.scale(0.5, scale_stroke=True)
assert a.get_stroke_width(background=True) == 50
assert b.get_stroke_width(background=True) == 25
def test_stroke_scale_preserves_relative_widths_in_compound_mobjects():
"""Regression test for fix 429f25328 (PR #4694).
When ``scale(..., scale_stroke=True)`` is called on a compound VMobject
whose submobjects have different stroke widths, the buggy version called
``self.set_stroke(width=abs(scale_factor) * self.get_stroke_width())``,
which uses the *parent's* stroke width and then propagates that single
scaled value to the whole family overwriting each submobject's own
width. In particular, a submobject with zero stroke would gain non-zero
stroke after scaling.
The fix iterates over ``self.get_family()`` and scales each submobject's
stroke individually with ``family=False`` so the relative widths are
preserved.
"""
from manim import VGroup
inner_with_stroke = VMobject()
inner_with_stroke.set_stroke(width=4)
inner_zero_stroke = VMobject()
inner_zero_stroke.set_stroke(width=0)
compound = VGroup(inner_with_stroke, inner_zero_stroke)
compound.scale(0.5, scale_stroke=True)
# Post-fix: each submob's width is scaled by 0.5 of its OWN value.
assert inner_with_stroke.get_stroke_width() == 2
assert inner_zero_stroke.get_stroke_width() == 0

View file

@ -96,6 +96,29 @@ def test_vmobject_add_points_as_corners():
np.testing.assert_allclose(obj1.points, obj3.points)
def test_add_points_as_corners_single_point_connects_to_existing_path():
"""Regression test for #4218 / fix f6cdb547 (PR #4219).
When ``add_points_as_corners`` is called with a single new point on a
VMobject whose last subpath is complete (so ``has_new_path_started()``
returns False), the buggy version silently dropped the new point the
``else`` branch computed ``start_corners = points[:-1]`` which is empty
for a one-point input. The fix unifies the two branches so the existing
path's last point is always used as the start corner.
"""
v = VMobject()
v.start_new_path(np.array([0.0, 0.0, 0.0]))
v.add_line_to(np.array([1.0, 0.0, 0.0]))
assert not v.has_new_path_started()
n_before = len(v.points)
v.add_points_as_corners([[2.0, 0.0, 0.0]])
# Post-fix: a cubic from [1, 0, 0] to [2, 0, 0] is appended.
assert len(v.points) > n_before
np.testing.assert_array_equal(v.points[-1], [2.0, 0.0, 0.0])
def test_vmobject_point_from_proportion():
obj = VMobject()
@ -528,6 +551,63 @@ def test_proportion_from_point():
np.testing.assert_allclose(props, [0, 1 / 3, 2 / 3])
def test_align_points_handles_vmobject_with_no_complete_cubic_curves():
"""Regression test for #3569 / #4629 (fix 21cf9998 / PR #4630).
When ``align_points`` encounters a VMobject whose points array is
non-empty but holds fewer than ``n_points_per_cubic_curve`` points,
``get_subpaths()`` returns ``[]`` while ``has_no_points()`` returns
``False`` so the pre-loop sanitization that would normally add a
null curve is skipped. The buggy ``get_nth_subpath`` closure then
indexed ``path_list[-1]`` on the empty list and raised
``IndexError: list index out of range``.
The fix returns a zero-valued null path in that case and ensures the
closure always returns a NumPy array (the previous list return type
broke downstream ``reshape`` calls).
"""
target = VMobject()
target.set_points(
np.array([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [2.0, 0.0, 0.0], [3.0, 0.0, 0.0]])
)
sub_cubic = VMobject()
sub_cubic.set_points(np.array([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [2.0, 0.0, 0.0]]))
assert sub_cubic.get_subpaths() == []
assert not sub_cubic.has_no_points()
# Pre-fix: raises IndexError. Post-fix: completes; points are ndarray.
target.align_points(sub_cubic)
assert isinstance(target.points, np.ndarray)
assert isinstance(sub_cubic.points, np.ndarray)
def test_pointwise_become_partial_preserves_target_when_source_has_no_curves():
"""Regression test for #4255 / fix 3d029c12 (PR #4320).
When ``pointwise_become_partial`` is called with a source ``VMobject`` that
has zero cubic curves (e.g. an empty ``VMobject`` or a ``VectorizedPoint``
holding a single point), the buggy version called ``self.clear_points()``
on the *target*, zeroing out its data. The fix removes that call.
This bug surfaced as ``Arrow3D.get_start()`` / ``get_end()`` returning
``[0, 0, 0]`` after a ``Create`` animation, because the arrow's
``end_point`` sub-mobject has 1 point but no cubic curves.
"""
target = VMobject()
original_points = np.array([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]])
target.set_points(original_points)
empty_source = VMobject()
assert empty_source.get_num_curves() == 0
# Choose a, b so the `(a <= 0 and b >= 1)` early-return is skipped
# and the `num_curves == 0` branch is exercised.
target.pointwise_become_partial(empty_source, 0.0, 0.5)
np.testing.assert_array_equal(target.points, original_points)
def test_pointwise_become_partial_where_vmobject_is_self():
sq = Square()
sq.pointwise_become_partial(vmobject=sq, a=0.2, b=0.7)

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

View file

@ -0,0 +1,264 @@
from __future__ import annotations
import numpy as np
import numpy.testing as nt
import pytest
from manim.utils.color import (
BLACK,
BLUE,
GREEN,
RED,
WHITE,
YELLOW,
ManimColor,
)
from manim.utils.color.core import (
RandomColorGenerator,
average_color,
color_gradient,
color_to_int_rgb,
color_to_int_rgba,
color_to_rgb,
color_to_rgba,
hex_to_rgb,
interpolate_color,
invert_color,
random_bright_color,
random_color,
rgb_to_color,
rgb_to_hex,
rgba_to_color,
)
# ---------------------------------------------------------------------------
# Parsing — one case per linearly independent input branch in ManimColor.__init__
# ---------------------------------------------------------------------------
@pytest.mark.parametrize( # : PT006
("color_input", "expected_rgb"),
[
("#FF0000", (1.0, 0.0, 0.0)),
("#F00", (1.0, 0.0, 0.0)),
("RED", (0xFC / 255, 0x62 / 255, 0x55 / 255)),
(0xFF0000, (1.0, 0.0, 0.0)),
((255, 0, 0), (1.0, 0.0, 0.0)),
((1.0, 0.0, 0.0), (1.0, 0.0, 0.0)),
(RED, (0xFC / 255, 0x62 / 255, 0x55 / 255)),
],
ids=[
"hex_long",
"hex_short",
"name",
"packed_int",
"int_tuple",
"float_tuple",
"ManimColor",
],
)
def test_color_to_rgb_accepts_all_parsable_forms(color_input, expected_rgb) -> None:
nt.assert_allclose(color_to_rgb(color_input), expected_rgb)
def test_color_to_rgb_returns_a_float64_array_of_length_3() -> None:
rgb = color_to_rgb("#123456")
assert isinstance(rgb, np.ndarray)
assert rgb.shape == (3,)
assert rgb.dtype == np.float64
def test_color_to_rgb_unknown_name_raises() -> None:
with pytest.raises(ValueError, match="Color TOMATO not found"):
color_to_rgb("TOMATO")
# ---------------------------------------------------------------------------
# Alpha & int conversions
# ---------------------------------------------------------------------------
def test_color_to_rgba_default_alpha_is_opaque() -> None:
nt.assert_array_equal(color_to_rgba("#FF0000"), (1.0, 0.0, 0.0, 1.0))
def test_color_to_rgba_uses_alpha_argument() -> None:
nt.assert_array_equal(color_to_rgba("#FF0000", alpha=0.25), (1.0, 0.0, 0.0, 0.25))
def test_color_to_int_rgb_returns_signed_ints_in_0_255_range() -> None:
int_rgb = color_to_int_rgb("#FF8040")
nt.assert_array_equal(int_rgb, (0xFF, 0x80, 0x40))
assert int_rgb.dtype.kind == "i"
def test_color_to_int_rgba_default_alpha_is_fully_opaque_byte() -> None:
# Pins the default alpha=1.0 in the signature (without this, mutations
# to the default value silently survive).
nt.assert_array_equal(color_to_int_rgba("#FF8040"), (0xFF, 0x80, 0x40, 255))
def test_color_to_int_rgba_appends_alpha_byte() -> None:
nt.assert_array_equal(
color_to_int_rgba("#FF8040", alpha=0.5),
(0xFF, 0x80, 0x40, int(0.5 * 255)),
)
# ---------------------------------------------------------------------------
# Inverse direction — rgb_to_color / rgba_to_color route through from_rgb /
# from_rgba, a different code path from ManimColor(value).
# ---------------------------------------------------------------------------
def test_rgb_to_color_normalizes_int_input_to_floats() -> None:
assert rgb_to_color((255, 128, 0)) == ManimColor((1.0, 128 / 255, 0.0))
def test_rgba_to_color_preserves_alpha() -> None:
assert rgba_to_color((1.0, 0.0, 0.0, 0.25)) == ManimColor((1.0, 0.0, 0.0, 0.25))
# ---------------------------------------------------------------------------
# Hex ↔ RGB
# ---------------------------------------------------------------------------
def test_rgb_to_hex_format_is_uppercase_with_hash() -> None:
nt.assert_equal(rgb_to_hex((1.0, 0.0, 0xA0 / 255)), "#FF00A0")
@pytest.mark.parametrize(
"hex_input",
["#000000", "#FFFFFF", "#FF0000", "#FF00A0"],
)
def test_hex_rgb_roundtrip_is_lossless_for_8bit_aligned_values(hex_input) -> None:
nt.assert_equal(rgb_to_hex(hex_to_rgb(hex_input)), hex_input)
def test_rgb_hex_roundtrip_drift_is_under_one_byte_per_channel() -> None:
rgb = np.array([0.42, 0.18, 0.93])
nt.assert_allclose(hex_to_rgb(rgb_to_hex(rgb)), rgb, atol=1 / 255)
# ---------------------------------------------------------------------------
# invert_color
# ---------------------------------------------------------------------------
def test_invert_color_flips_white_to_black() -> None:
# Anchors the actual semantic (1 - x), independent of the involution property.
assert invert_color(WHITE) == BLACK
@pytest.mark.parametrize("color", [RED, GREEN, BLUE, YELLOW])
def test_invert_color_is_an_involution(color) -> None:
# ManimColor.__eq__ uses np.allclose, absorbing the machine-epsilon drift.
assert invert_color(invert_color(color)) == color
def test_invert_color_preserves_alpha_by_default() -> None:
c = ManimColor("#FF0000", alpha=0.3)
nt.assert_equal(invert_color(c)._internal_value[3], 0.3)
# ---------------------------------------------------------------------------
# interpolate_color — three samples of a linear function in alpha
# ---------------------------------------------------------------------------
@pytest.mark.parametrize( # : PT006
("alpha", "expected"),
[
(0.0, BLACK),
(0.5, ManimColor((0.5, 0.5, 0.5))),
(1.0, WHITE),
],
ids=["start", "midpoint", "end"],
)
def test_interpolate_color_between_black_and_white(alpha, expected) -> None:
assert interpolate_color(BLACK, WHITE, alpha) == expected
# ---------------------------------------------------------------------------
# average_color
# ---------------------------------------------------------------------------
def test_average_color_of_black_and_white_is_mid_gray() -> None:
assert average_color(BLACK, WHITE) == ManimColor((0.5, 0.5, 0.5))
def test_average_color_always_returns_alpha_one() -> None:
# average_color drops input alpha per its docstring contract.
avg = average_color(
ManimColor("#FF0000", alpha=0.1), ManimColor("#FF0000", alpha=0.9)
)
nt.assert_equal(avg._internal_value[3], 1.0)
# ---------------------------------------------------------------------------
# color_gradient
# ---------------------------------------------------------------------------
def test_color_gradient_zero_length_returns_empty_list() -> None:
assert color_gradient([RED], 0) == []
def test_color_gradient_empty_reference_with_positive_length_raises() -> None:
with pytest.raises(ValueError, match="Expected 1 or more reference colors"):
color_gradient([], 5)
def test_color_gradient_single_reference_is_repeated_n_times() -> None:
gradient = color_gradient([RED], 5)
assert len(gradient) == 5
assert all(color == RED for color in gradient)
def test_color_gradient_interpolates_endpoints_and_respects_length() -> None:
gradient = color_gradient([BLACK, WHITE], 7)
assert len(gradient) == 7
assert gradient[0] == BLACK
assert gradient[-1] == WHITE
def test_color_gradient_passes_through_each_of_four_reference_colors() -> None:
# With >= 4 reference colors the internal `num_colors - 2` bookkeeping
# diverges from `num_colors % 2`; pins the former.
refs = [BLACK, RED, BLUE, WHITE]
gradient = color_gradient(refs, 4)
assert len(gradient) == 4
assert gradient[0] == BLACK
assert gradient[1] == RED
assert gradient[2] == BLUE
assert gradient[3] == WHITE
# ---------------------------------------------------------------------------
# Random color machinery
# ---------------------------------------------------------------------------
def test_random_color_returns_a_manim_color() -> None:
assert isinstance(random_color(), ManimColor)
def test_random_bright_color_has_every_channel_at_or_above_half() -> None:
# By construction: 0.5 * (random_rgb + 1) => each channel >= 0.5.
assert (random_bright_color().to_rgb() >= 0.5).all()
def test_random_color_generator_is_deterministic_under_a_fixed_seed() -> None:
a = RandomColorGenerator(seed=42)
b = RandomColorGenerator(seed=42)
for _ in range(5):
assert a.next() == b.next()
def test_random_color_generator_only_samples_from_custom_palette() -> None:
palette = [RED, GREEN, BLUE]
gen = RandomColorGenerator(seed=1, sample_colors=palette)
for _ in range(10):
assert gen.next() in palette

222
uv.lock generated
View file

@ -1203,7 +1203,7 @@ wheels = [
[[package]]
name = "jupyter-server"
version = "2.17.0"
version = "2.18.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/f1/ec/9302cec1ccacdd33c1b1312ac31681c8975cae56c626d783ab49edf9c681/jupyter_server-2.18.0.tar.gz", hash = "sha256:568b27bce4320a53c3eebf1bdcbee9acf48a8ab7f66ec83d900ca9909d4fb770", size = 751152, upload-time = "2026-05-04T13:39:29.685Z" }
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/cf/f9/050312d92072ddb9ce14c11171804c07435790c98d4350935a780d9e10c2/jupyter_server-2.18.0-py3-none-any.whl", hash = "sha256:69a5397a039d689da81a45955f9b23e95ee167f6d8a8d64372fb616f2aac650a", size = 391687, upload-time = "2026-05-04T13:39:27.549Z" },
]
[[package]]
@ -1246,7 +1246,7 @@ wheels = [
[[package]]
name = "jupyterlab"
version = "4.5.2"
version = "4.5.7"
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/2b/22/8440ec827762146e7cdecf04335bd348795899d29dc6ae82238707353a2c/jupyterlab-4.5.7.tar.gz", hash = "sha256:55a9822c4754da305f41e113452c68383e214dcf96de760146af89ce5d5117b0", size = 23992763, upload-time = "2026-04-29T16:43:51.328Z" }
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/3d/aa/537b8f7d80e799af19af35fb3ddfc970b951088a13c57dd9387dcfbb7f61/jupyterlab-4.5.7-py3-none-any.whl", hash = "sha256:fba4cb0e2c44a52859669d8c98b45de029d5e515f8407bf8534d2a8fc5f0964d", size = 12450123, upload-time = "2026-04-29T16:43:46.639Z" },
]
[[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 = [
@ -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]]
@ -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"