Compare commits

...

55 commits

Author SHA1 Message Date
dependabot[bot]
0e83f4b09a
chore(deps): bump jupyterlab from 4.5.7 to 4.5.9 (#4822)
---
updated-dependencies:
- dependency-name: jupyterlab
  dependency-version: 4.5.9
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-20 13:14:07 -04:00
dependabot[bot]
f7fd708276
chore(deps): bump jupyter-server from 2.18.0 to 2.20.0 (#4821)
Bumps [jupyter-server](https://github.com/jupyter-server/jupyter_server) from 2.18.0 to 2.20.0.
- [Release notes](https://github.com/jupyter-server/jupyter_server/releases)
- [Changelog](https://github.com/jupyter-server/jupyter_server/blob/main/CHANGELOG.md)
- [Commits](https://github.com/jupyter-server/jupyter_server/compare/v2.18.0...v2.20.0)

---
updated-dependencies:
- dependency-name: jupyter-server
  dependency-version: 2.20.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-20 11:22:50 -04:00
Caffein3
4c4622df54
feat: support multiple TeX compilers in TexTemplate (#4810)
* feat: support multiple compilations

Allow `tex_compiler` to be a `str | list[str]` instead of just
`str`. When a list is given, document will be compiled sequentially.

* fix: suppress compilation progress log when only one compiler is used

* fix: updated docstrings and variable naming
2026-06-16 22:35:14 -04:00
dependabot[bot]
66d5a4937a
chore(deps): bump bleach from 6.3.0 to 6.4.0 (#4814)
Bumps [bleach](https://github.com/mozilla/bleach) from 6.3.0 to 6.4.0.
- [Changelog](https://github.com/mozilla/bleach/blob/main/CHANGES)
- [Commits](https://github.com/mozilla/bleach/compare/v6.3.0...v6.4.0)

---
updated-dependencies:
- dependency-name: bleach
  dependency-version: 6.4.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-17 01:49:15 +00:00
dependabot[bot]
c07137dbed
chore(deps): bump tornado from 6.5.5 to 6.5.7 (#4813)
Bumps [tornado](https://github.com/tornadoweb/tornado) from 6.5.5 to 6.5.7.
- [Changelog](https://github.com/tornadoweb/tornado/blob/master/docs/releases.rst)
- [Commits](https://github.com/tornadoweb/tornado/compare/v6.5.5...v6.5.7)

---
updated-dependencies:
- dependency-name: tornado
  dependency-version: 6.5.7
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-17 01:33:08 +00:00
dependabot[bot]
71ab85f960
chore(deps): bump idna from 3.11 to 3.15 (#4736)
Bumps [idna](https://github.com/kjd/idna) from 3.11 to 3.15.
- [Release notes](https://github.com/kjd/idna/releases)
- [Changelog](https://github.com/kjd/idna/blob/master/HISTORY.md)
- [Commits](https://github.com/kjd/idna/compare/v3.11...v3.15)

---
updated-dependencies:
- dependency-name: idna
  dependency-version: '3.15'
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Francisco Manríquez Novoa <49853152+chopan050@users.noreply.github.com>
2026-06-17 01:09:58 +00:00
dependabot[bot]
16f0a3de3e
chore(deps): bump urllib3 from 2.6.3 to 2.7.0 (#4729)
Bumps [urllib3](https://github.com/urllib3/urllib3) from 2.6.3 to 2.7.0.
- [Release notes](https://github.com/urllib3/urllib3/releases)
- [Changelog](https://github.com/urllib3/urllib3/blob/main/CHANGES.rst)
- [Commits](https://github.com/urllib3/urllib3/compare/2.6.3...2.7.0)

---
updated-dependencies:
- dependency-name: urllib3
  dependency-version: 2.7.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Francisco Manríquez Novoa <49853152+chopan050@users.noreply.github.com>
2026-06-17 00:58:29 +00:00
dependabot[bot]
537a134360
chore(deps): bump mistune from 3.2.0 to 3.2.1 (#4725)
Bumps [mistune](https://github.com/lepture/mistune) from 3.2.0 to 3.2.1.
- [Release notes](https://github.com/lepture/mistune/releases)
- [Changelog](https://github.com/lepture/mistune/blob/main/docs/changes.rst)
- [Commits](https://github.com/lepture/mistune/compare/v3.2.0...v3.2.1)

---
updated-dependencies:
- dependency-name: mistune
  dependency-version: 3.2.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-16 23:49:46 +00:00
dependabot[bot]
b7bf2ea90f
chore(deps): bump docker/setup-buildx-action from 3 to 4 (#4716)
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3 to 4.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Francisco Manríquez Novoa <49853152+chopan050@users.noreply.github.com>
2026-06-16 22:42:37 +00:00
dependabot[bot]
bdaf4497b7
chore(deps): bump jupyterlab from 4.5.2 to 4.5.7 (#4713)
Bumps [jupyterlab](https://github.com/jupyterlab/jupyterlab) from 4.5.2 to 4.5.7.
- [Release notes](https://github.com/jupyterlab/jupyterlab/releases)
- [Changelog](https://github.com/jupyterlab/jupyterlab/blob/main/RELEASE.md)
- [Commits](https://github.com/jupyterlab/jupyterlab/compare/@jupyterlab/lsp@4.5.2...@jupyterlab/lsp@4.5.7)

---
updated-dependencies:
- dependency-name: jupyterlab
  dependency-version: 4.5.7
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-16 22:05:50 +00:00
dependabot[bot]
037b376ec2
chore(deps): bump notebook from 7.5.2 to 7.5.6 (#4712)
Bumps [notebook](https://github.com/jupyter/notebook) from 7.5.2 to 7.5.6.
- [Release notes](https://github.com/jupyter/notebook/releases)
- [Changelog](https://github.com/jupyter/notebook/blob/@jupyter-notebook/tree@7.5.6/CHANGELOG.md)
- [Commits](https://github.com/jupyter/notebook/compare/@jupyter-notebook/tree@7.5.2...@jupyter-notebook/tree@7.5.6)

---
updated-dependencies:
- dependency-name: notebook
  dependency-version: 7.5.6
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Francisco Manríquez Novoa <49853152+chopan050@users.noreply.github.com>
2026-06-16 17:46:31 -04:00
dependabot[bot]
e9639c2697
chore(deps): bump nbconvert from 7.17.0 to 7.17.1 (#4702)
Bumps [nbconvert](https://github.com/jupyter/nbconvert) from 7.17.0 to 7.17.1.
- [Release notes](https://github.com/jupyter/nbconvert/releases)
- [Changelog](https://github.com/jupyter/nbconvert/blob/main/CHANGELOG.md)
- [Commits](https://github.com/jupyter/nbconvert/compare/v7.17.0...v7.17.1)

---
updated-dependencies:
- dependency-name: nbconvert
  dependency-version: 7.17.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Francisco Manríquez Novoa <49853152+chopan050@users.noreply.github.com>
2026-06-16 21:13:42 +00:00
dependabot[bot]
1b2d5ce72b
chore(deps-dev): bump pytest from 9.0.2 to 9.0.3 (#4688)
Bumps [pytest](https://github.com/pytest-dev/pytest) from 9.0.2 to 9.0.3.
- [Release notes](https://github.com/pytest-dev/pytest/releases)
- [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest/compare/9.0.2...9.0.3)

---
updated-dependencies:
- dependency-name: pytest
  dependency-version: 9.0.3
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Francisco Manríquez Novoa <49853152+chopan050@users.noreply.github.com>
2026-06-16 20:38:28 +00:00
dependabot[bot]
c94a7ea9fc
chore(deps): bump pillow from 12.1.1 to 12.2.0 (#4687)
Bumps [pillow](https://github.com/python-pillow/Pillow) from 12.1.1 to 12.2.0.
- [Release notes](https://github.com/python-pillow/Pillow/releases)
- [Changelog](https://github.com/python-pillow/Pillow/blob/main/CHANGES.rst)
- [Commits](https://github.com/python-pillow/Pillow/compare/12.1.1...12.2.0)

---
updated-dependencies:
- dependency-name: pillow
  dependency-version: 12.2.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Francisco Manríquez Novoa <49853152+chopan050@users.noreply.github.com>
2026-06-16 20:10:01 +00:00
dependabot[bot]
bb1be6ef8a
chore(deps): bump actions/upload-artifact from 6 to 7 (#4620)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 6 to 7.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Francisco Manríquez Novoa <49853152+chopan050@users.noreply.github.com>
2026-06-16 19:48:22 +00:00
rsn
516c8c8ba7
Fix ThreeDScene.set_to_default_angled_camera_orientation() (#4704)
Co-authored-by: neeh <rapetisiddhu@gmail.com>
Co-authored-by: Francisco Manríquez Novoa <49853152+chopan050@users.noreply.github.com>
2026-06-16 15:20:18 -04:00
Mayank Basena
ccee37a614
Add docstring to GenericGraph.__getitem__() (#4761)
Allows accessing edges via tuple keys like g[(1, 2)] in addition to
vertex lookups like g[1]. Previously only vertex lookups were supported.

Fixes ManimCommunity/manim#3798

Co-authored-by: Francisco Manríquez Novoa <49853152+chopan050@users.noreply.github.com>
2026-06-16 18:52:42 +00:00
dependabot[bot]
31db147222
chore(deps): bump jupyter-server from 2.17.0 to 2.18.0 (#4720)
Bumps [jupyter-server](https://github.com/jupyter-server/jupyter_server) from 2.17.0 to 2.18.0.
- [Release notes](https://github.com/jupyter-server/jupyter_server/releases)
- [Changelog](https://github.com/jupyter-server/jupyter_server/blob/main/CHANGELOG.md)
- [Commits](https://github.com/jupyter-server/jupyter_server/compare/v2.17.0...v2.18.0)

---
updated-dependencies:
- dependency-name: jupyter-server
  dependency-version: 2.18.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Francisco Manríquez Novoa <49853152+chopan050@users.noreply.github.com>
2026-06-16 17:57:48 +00:00
GniLudio
34124c3f60
Fix wrong type annotation of Animation._on_finish (#4812) 2026-06-16 13:11:45 -04:00
Benjamin Hackl
d999d422c9
Introduce first-class support for rendering text and markup via Typst (optional dependency) (#4681)
* feat: add TypstMobject and TypstMathMobject for first-class Typst support

Implement the initial TypstMobject proposal (see agents/typst.md):

- TypstMobject: renders arbitrary Typst markup to SVG via the 'typst'
  Python package (self-contained Rust binary, no system install needed),
  then imports through SVGMobject.
- TypstMathMobject: convenience subclass that wraps input in Typst math
  delimiters ($ ... $).

Pipeline: user string → wrap in minimal Typst document (auto-sized page,
transparent background) → write .typ file → compile to SVG via
typst.compile() → import via SVGMobject → scale/center/recolor.

Key details:
- Compilation helper in manim/utils/typst_file_writing.py with SHA-256
  content-hash caching (same scheme as the LaTeX pipeline).
- font_size property mirrors SingleStringMathTex: compile at fixed 11pt,
  scale after import using initial_height / SCALE_FACTOR_PER_FONT_POINT.
- init_colors() recolors black submobjects to self.color (Typst default
  fill is black), preserving any explicit Typst colors.
- path_string_config enables curve subdivision for smooth animation.
- 'typst' added as optional dependency group in pyproject.toml.
- 10 tests covering creation, font_size, caching, preamble, and repr.

* feat(typst): add sub-expression selection via {{ }} groups and .select()

- Override modify_xml_tree in Typst to convert data-typst-label
  attributes to id attributes before svgelements parsing (avoids
  attribute inheritance issue with data-* attributes)
- Add Typst.select(key) method accepting str labels or int indices
  to retrieve VGroup sub-expressions from id_to_vgroup_dict
- Implement {{ }} double-brace preprocessor on TypstMath:
  - {{ content }} → manimgrp("_grp-N", content) (auto-numbered)
  - {{ content : label }} → manimgrp("label", content) (named)
  - Skips {{ }} inside string literals and [...] content blocks
  - Uses math-mode call convention (no # prefix) to keep args in
    math mode
- Auto-inject manimgrp preamble when groups are detected
- Add 12 new tests covering label mapping, nesting, select(),
  error handling, and preprocessor edge cases

* Remove unneeded assert line

* Generalize some more critical hard-coded LaTeX assumptions

* Fix syntax error

* Support Typst labels in existing APIs

* Add ManimTextLabel typing alias

* Preserve Typst SVG stroke widths by default

* Calibrate Typst font sizing against TeX

* Add Typst mobject documentation

* Track Typst baseline frames

* Document Typst baseline frame tracking

* Add Typst section to text guide

* Polish Typst docs and label handling

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Improve Typst stroke scaling and docs examples

* Tune Typst SVG stroke scaling

* Restore Typst math example wording

* Fix pre-commit issues for Typst branch

* Fix number line runtime typing import

* Exclude Typst autogenerated .rst files

* Use Manim directive in docstrings and fix wrong key in Typst.select() docstring

* Fix GroupedMath comments

---------

Co-authored-by: Toon Verstraelen <Toon.Verstraelen@UGent.be>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Francisco Manríquez Novoa <49853152+chopan050@users.noreply.github.com>
Co-authored-by: Francisco Manríquez Novoa <francisco.manriquezn@usm.cl>
2026-06-11 03:54:21 +00:00
GniLudio
561de9d72a
Use language parameter to format Code even when passing code_file (#4706)
Co-authored-by: Francisco Manríquez Novoa <49853152+chopan050@users.noreply.github.com>
2026-06-10 21:49:59 +00:00
Sai Sridhar Tarra
05b3042ab0
Add tests for edge lookup in GenericGraph.__getitem__() (#4753)
GenericGraph.__getitem__ only handled vertex lookup. Passing a 2-tuple
(u, v) to retrieve an edge mobject raised a KeyError against the
vertices dict instead of looking up self.edges.

Route tuple keys of length 2 to self.edges so that g[(u, v)] returns
the edge mobject, consistent with how edges are stored internally.

Adds a test covering vertex lookup, edge lookup (Graph), and edge
lookup (DiGraph).

Fixes #3798

Co-authored-by: Francisco Manríquez Novoa <49853152+chopan050@users.noreply.github.com>
2026-06-10 17:34:39 -04:00
HairlessVillager
2ece488b2c
Support edge lookup via tuple in GenericGraph.__getitem__() (#3799)
* feat: add tuple key support in GenericGraph.__getitem__()

* feat: add an error message for missing vertice or edge

Co-authored-by: adeshpande <110117391+JasonGrace2282@users.noreply.github.com>

* docs: add an example

* fix: tuple vertex return edge

---------

Co-authored-by: adeshpande <110117391+JasonGrace2282@users.noreply.github.com>
Co-authored-by: Francisco Manríquez Novoa <49853152+chopan050@users.noreply.github.com>
2026-06-10 17:19:24 -04:00
Guillaume Vauvert
852ebd1c60
Fix empty submobjects distorting width/height of parent Mobject (#4088)
* Issue-4087 Add bug test

* Issue-4087 Fix bug

* Issue-4087 Fix comment and add type hint

* Issue-4087 Fix comment

* Issue-4087 Improve previous test instead of adding a new one

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Update tests/module/mobject/mobject/test_mobject.py

* Enhance docstring for reduce_across_dimension method

Updated docstring for reduce_across_dimension method to clarify its purpose and parameters.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Remove float typehints for coords in length_over_dim

* Modify test for empty VMobject dimensions

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: Francisco Manríquez Novoa <49853152+chopan050@users.noreply.github.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-06-10 16:21:52 -04:00
Irvanal Haq
82522795f1
Allow CyclicReplace and Swap to accept single Group or VGroup and add example (#4211)
* Allow Swap to accept Group and VGroup and add example

* Modify create_target return type to Group | VGroup

Updated create_target method to allow returning either Group or VGroup.

---------

Co-authored-by: Francisco Manríquez Novoa <49853152+chopan050@users.noreply.github.com>
2026-06-09 17:33:12 -04:00
Henrik Skov Midtiby
12c5640a32
Add type annotations to transform_matching_parts.py (#4400)
* Add type annotations to `transform_matching_parts.py`

* Make two type errors quiet

* Make the pytests pass

---------

Co-authored-by: Francisco Manríquez Novoa <49853152+chopan050@users.noreply.github.com>
2026-06-09 17:11:01 -04:00
Laszlo
33424fe43d
More specific Callable type annotations in mobject_update_utils (#4728)
* More specific Callable type annotations in mobject_update_utils

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-06-03 17:02:00 +00:00
Leonardo Cariaggi
1b3390073c
Allow excluding inner lines of Table (#4731)
* Add option to hide/show inner lines in Table class

* Add tests for Table class inner lines visibility

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Correct indentation

* Fix os.startfile usage to check for availability on Windows

* Add ConversationFlowScene to animate user-chatbot interactions and metadata display

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Revert "Merge remote-tracking branch 'origin/conversation' into hide_table_inner_lines"

This reverts commit c0ba5b8511, reversing
changes made to 1f71f4b0e8.

* Revert change

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Leonardo Cariaggi <leonardo.cariaggi@kbc.be>
Co-authored-by: Francisco Manríquez Novoa <49853152+chopan050@users.noreply.github.com>
2026-06-03 09:06:03 -04:00
Rafael Brusiquesi Martins
56f7eb2a1f
Add regression tests for four recent fixes in vectorized_mobject.py (#4750)
* Add regression tests for four recent fixes in vectorized_mobject.py

Adds four regression tests for fixes that landed without tests in the same PR:

  * 21cf9998 (PR #4630, fixes #3569 + #4629) -- IndexError in
    `get_nth_subpath` when `path_list` is empty; ensure it always returns
    a NumPy array.

  * f6cdb547 (PR #4219) -- `add_points_as_corners` silently dropped a
    single new point when called on a VMobject whose last subpath was
    complete.

  * 3d029c12 (PR #4320, fixes #4255) -- `pointwise_become_partial` cleared
    the target's points when the source had no cubic curves, surfacing as
    `Arrow3D.get_start()` / `get_end()` returning the origin after a
    `Create` animation.

  * 429f25328 (PR #4694) -- `scale(scale_stroke=True)` on a compound
    VMobject propagated the parent's scaled stroke width to every
    submobject, overwriting submobjects with their own (e.g. zero) stroke.

Each test reproduces the original failing condition at the unit level
and asserts the post-fix behavior. Validation: every test was confirmed
to fail when the corresponding fix is reverted on the source file, and
pass when the fix is restored.

4 tests, ~0.2s runtime. Tests go in the existing files
`tests/module/mobject/types/vectorized_mobject/test_vectorized_mobject.py`
(three tests, adjacent to their topically-related siblings) and
`tests/module/mobject/types/vectorized_mobject/test_stroke.py` (one test,
next to the existing `test_stroke_scale`).

Co-authored-by: LetMarq <LetMarq@users.noreply.github.com>

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: THE-RAF <THE-RAF@users.noreply.github.com>
Co-authored-by: LetMarq <LetMarq@users.noreply.github.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Francisco Manríquez Novoa <49853152+chopan050@users.noreply.github.com>
2026-06-02 21:53:49 +00:00
Rafael Brusiquesi Martins
cfb5c684b7
Add unit tests for module-level color helpers in manim/utils/color/core.py (#4749)
* Add unit tests for module-level color helpers in manim/utils/color/core.py

Adds tests/module/utils/test_color_helpers.py covering the standalone
helper functions exported from manim.utils.color.core (color_to_rgb,
color_to_rgba, color_to_int_rgb, color_to_int_rgba, rgb_to_color,
rgba_to_color, rgb_to_hex, hex_to_rgb, invert_color, color_gradient,
interpolate_color, average_color, random_bright_color, random_color)
and the RandomColorGenerator class.

Before this PR, only the ManimColor class itself was tested
(via tests/module/utils/test_manim_color.py); the standalone helpers
had zero direct test coverage. Coverage on manim/utils/color/core.py
goes from 71% to 80% (line+branch), and 17 of the 18 testable symbols
in the file's __all__ are now exercised. The only deferred symbol is
get_shaded_rgb, which concerns lighting math rather than color
conversion and is better suited to a follow-up.

42 tests, ~0.3s runtime, no new dependencies.

Co-authored-by: LetMarq <LetMarq@users.noreply.github.com>

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Update tests/module/utils/test_color_helpers.py

Increased assertion scheme to cover all colors.

Co-authored-by: Francisco Manríquez Novoa <49853152+chopan050@users.noreply.github.com>

---------

Co-authored-by: THE-RAF <THE-RAF@users.noreply.github.com>
Co-authored-by: LetMarq <LetMarq@users.noreply.github.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Francisco Manríquez Novoa <49853152+chopan050@users.noreply.github.com>
2026-05-29 17:23:28 -04:00
Vihaan Dutta
429f25328d
Fixed inconsistent stroke width scaling for text in compound objects (#4694)
* fixed zero-stroke mobject scaling

* updated vectorized_mobject scale docstring text
2026-04-19 16:10:14 -04:00
GoThrones
c45724989d
Refactor`Mobject.put_start_and_end_on() to shift Mobject to start when it's a closed curve (#4658)
* fix: preserve geometry in put_start_and_end_on for zero-vector mobjects

* fix: preserve geometry in put_start_and_end_on for zero-vector mobjects

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* fixed asser_array_equal line

* removed commets in mobject.py and opengl_mobject.py as suggested

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* added stacklevel=2 suggestion

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Francisco Manríquez Novoa <49853152+chopan050@users.noreply.github.com>
2026-04-14 19:57:06 +00:00
dependabot[bot]
af70b6fef2
chore(deps-dev): bump requests from 2.32.5 to 2.33.0 (#4659)
Bumps [requests](https://github.com/psf/requests) from 2.32.5 to 2.33.0.
- [Release notes](https://github.com/psf/requests/releases)
- [Changelog](https://github.com/psf/requests/blob/main/HISTORY.md)
- [Commits](https://github.com/psf/requests/compare/v2.32.5...v2.33.0)

---
updated-dependencies:
- dependency-name: requests
  dependency-version: 2.33.0
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-07 01:57:29 +00:00
dependabot[bot]
4b32312dd1
chore(deps): bump tornado from 6.5.4 to 6.5.5 (#4635)
Bumps [tornado](https://github.com/tornadoweb/tornado) from 6.5.4 to 6.5.5.
- [Changelog](https://github.com/tornadoweb/tornado/blob/master/docs/releases.rst)
- [Commits](https://github.com/tornadoweb/tornado/compare/v6.5.4...v6.5.5)

---
updated-dependencies:
- dependency-name: tornado
  dependency-version: 6.5.5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Francisco Manríquez Novoa <49853152+chopan050@users.noreply.github.com>
2026-04-07 01:43:47 +00:00
GoThrones
90141df105
Change VMobject._bezier_t_values typehint to ndarray of np.float64 (#4675)
Co-authored-by: Francisco Manríquez Novoa <49853152+chopan050@users.noreply.github.com>
2026-04-06 17:02:25 +00:00
Benjamin Hackl
82f93b6c3c
chore: combine dependabot updates for CI retest (#4677)
Includes the changes from PRs #4666, #4668, #4669, #4670, and #4671.

Co-authored-by: Francisco Manríquez Novoa <49853152+chopan050@users.noreply.github.com>
2026-04-06 12:19:30 -04:00
Francisco Manríquez Novoa
752b46a003
Update TinyTeX Windows/macOS installation in ci.yml to fix failing pipelines (#4679)
* Update TinyTeX installation method in CI workflow

* Fix path execution for TinyTex Windows installer
2026-04-06 11:59:03 -04:00
Mingqi Geng
21cf9998cc
Fix IndexError in get_nth_subpath() and ensure it always returns a NumPy array (#4630)
* fix #3569 and #4629

* Add changes to opengl_vectorized_mobject.py

---------

Co-authored-by: Francisco Manríquez Novoa <49853152+chopan050@users.noreply.github.com>
2026-03-18 11:15:45 -03:00
Henrik Skov Midtiby
ebb230f6f1
Update link in pyproject.toml to X / Twitter (#4642) 2026-03-18 13:40:16 +00:00
Xiuyuan (Jack) Yuan
46177d247e
Fix wrong angle ranges in Sphere documentation and add more examples (#3973)
* Made the document changes

* added additional example

* Apply some suggestions from code review

Co-authored-by: Francisco Manríquez Novoa <49853152+chopan050@users.noreply.github.com>

* Shorten sphere examples

* Fix line breaks and remove trailing whitespace

* Add example for showing portions of spheres

* Undo accidental overwrite of ExampleSphereOverlap

* Change name of manim code block

---------

Co-authored-by: Xiuyuan <u7678992@anu.edu.au>
Co-authored-by: TahitiX <136950383+TahitiX@users.noreply.github.com>
Co-authored-by: Francisco Manríquez Novoa <49853152+chopan050@users.noreply.github.com>
Co-authored-by: Francisco Manríquez Novoa <francisco.manriquezn@usm.cl>
2026-03-18 00:28:52 -03:00
Henrik Skov Midtiby
468929889b
Add type annotations to mobject.py (#4388)
* Add type annotations to `mobject.py`

* More work on type annotations for mobject.py

* ...

* Almost handled all mypy errors

* Add the mypy error messages to the lines that trigger them

* Use typing.cast to avoid some mypy errors, as suggested by JasonGrace2282

* Used the ruff linter

* Fixed one typing issue and added the error descriptions to the source code for the remaning 10 errors

* Set the type of the elements in a VGroup to VMobject

* Use typing.cast to handle some specific cases.

* Code cleaning

* Updates

* When started to use typing.cast it is needed to import / define certain elements during runtime and not only during type checking

* Fix bug introduced with the type annotations.

* Made it work again

* ..

* Fixed more issues.

* Code cleanup

* Code cleanup.

Replace self.lines with self.lines_chars and self.lines_alignment.

* Handle slicing when accessing elements inside a VGroup

* Fix missing issues.

* Silence the last mypy error

* Make _Updater, _NonTimeBasedUpdater and _TimeBasedUpdater private.

* Replace | with Union[...] in one location

* Move import of Union

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Remove comment that is no longer relevant

* Overload the auto_zoom method in MovingCamera to provide better type hinting

* Codecleanup

* Code cleanup

* Renamed lines_alignment to lines_alignments and added a TODO about a future cleanup task

* More code cleanup

* Update manim/mobject/matrix.py

Co-authored-by: Francisco Manríquez Novoa <49853152+chopan050@users.noreply.github.com>

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Addressing multiple comments from Chopan50

* Implementing more suggestions from Chopan50

* More suggestions by Chopan50

* ...

* More renaming and code cleanup, as suggested by Chopan50

* Restructure code

* anim_args

* Update typing in vector_space_scene

* Implemented a number of suggestions from chopan50

* Make a list of faces with the type list[ThreeDVMobject]

* Ensure to return a VGroup if slicing is used.

* Revert back to the original code in text_mobject.py

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Add the propagate_colors option to Mobject as it is used by VMobject.

Replace Self with _UpdateBuilder

* Simplify code

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Francisco Manríquez Novoa <49853152+chopan050@users.noreply.github.com>
2026-03-11 20:33:21 -03:00
xuruidong
98c458b6b2
docs(quickstart): fix incorrect terminology for SquareToCircle (#4631) 2026-03-08 09:47:57 +01:00
Colin Belhomme
d4af5b2baa
fix: fix cast crash in OpenGLMobject.arrange_in_grid() and OpenGLPoint.get_location() (#4622)
Added quotes to Vector3D and to Point3D to match other calls to `cast`, such has Chopan suggested

Issue #4550: https://github.com/ManimCommunity/manim/issues/4550

Co-authored-by: Colin Belhomme <colin.belhomme@telecom-paris.fr>
2026-03-03 23:48:31 -03:00
Benjamin Hackl
1157b746c3
Prepare new release: v0.20.1 (#4615)
* bump version, regenerate lockfile, changelog 1st pass

* fix two docbuild warnings

* update changelog, include newly merged PR + review suggestion
2026-02-27 08:09:59 +01:00
Irvanal Haq
6f825e8513
Fix unintended kwargs propagation in LaggedStartMap (#4613)
* Fix unintended kwargs propagation in LaggedStartMap

Ensure  intended for  are not forwarded to the superclass, and make  explicit.

* add test for keyword propagation in LaggedStartMap

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Update test_composition.py

update the test

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Update test_composition.py

add there_and_back

---------

Co-authored-by: Benjamin Hackl <mail@behackl.dev>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-02-27 07:14:28 +01:00
Henrik Skov Midtiby
a0414dccec
Add type annotations to manim/_config/utils.py (#4230)
* Stop ignoring  manim._config erros in mypy.ini

* Aspect ratio should be a float.

* Handled more mypy issues in _config/utils.py

* Handled more mypy issues in _config/utils.py

* Removed two assert statements that triggered errors in the unittests.

* Fix last mypy issue in utils.py and activate mypy checking

* Fix type of window_size in opengl_renderer_window.py

* ...

---------

Co-authored-by: F. Muenkel <25496279+fmuenkel@users.noreply.github.com>
Co-authored-by: Francisco Manríquez Novoa <49853152+chopan050@users.noreply.github.com>
Co-authored-by: Benjamin Hackl <devel@benjamin-hackl.at>
2026-02-22 22:15:16 +00:00
Benjamin Hackl
33a0e56d73
enhancement: optimize Docker image build and runtime footprint (#4604)
* docker: add libgl1 runtime dependency for OpenGL

* docker: tighten .dockerignore for smaller build context

* docs(docker): add runtime notes for latest image

* docs(docker): note ctex is not included by default

---------

Co-authored-by: Francisco Manríquez Novoa <49853152+chopan050@users.noreply.github.com>
2026-02-22 17:29:18 -03:00
Benjamin Hackl
80fd11efbc
Suppress pydub ffmpeg warning on import (#4603)
Co-authored-by: Henrik Skov Midtiby <hemi@mmmi.sdu.dk>
2026-02-22 19:30:13 +00:00
Piyush Acharya
498f0b9c89
docs: add alt text to all images for accessibility (#4064)
Co-authored-by: Benjamin Hackl <devel@benjamin-hackl.at>
2026-02-22 12:51:39 +01:00
Matvey Merzlikin
87cd63549c
Add support for negative z-index in AnimationGroup (#4389)
* Add support for negative z-index in AnimationGroup

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Refactor animation unpacking logic for clarity

* Fix unpacking logic to handle Mobject instances correctly

* Fix mypy check

* Fix tests

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* fix: simplify AnimationGroup unpacking for moving mobjects

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Benjamin Hackl <devel@benjamin-hackl.at>
2026-02-22 11:24:32 +00:00
u7920349
cd370610c5
Documentation: manual installation of manim as a local package (#4456)
* Documentation: additonal instalation method

* Typo fix

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* docs: simplify dev-install section and restore PR template checklist

---------

Co-authored-by: CA3000 <93896083+CA3000@users.noreply.github.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Benjamin Hackl <devel@benjamin-hackl.at>
2026-02-22 11:59:40 +01:00
Josie Thompson
a6af7f3d76
Cleanup TipableVMobject: avoid mutable default and fix assign_tip_attr typo (#4503)
* Small touch-ups before fixing tip bugs
- fixed mutable default argument in Tipable VMobject constructor
- fixed typo in method name assign_tip_attr
- added two todo's which outline unexpected behavior in tip placement and how to solve

* refactor: align tip attr naming and remove TODO comments

---------

Co-authored-by: Benjamin Hackl <devel@benjamin-hackl.at>
2026-02-22 11:36:29 +01:00
Abdelsalam
e34e707858
Fix creation or animation of a zero-length DashedLine (#4606)
* fixing #4591

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-02-22 11:10:21 +01:00
Benjamin Hackl
7c1c9258d0
Fix publish release workflow (#4600)
* ci: fix publish release workflow

- Add sudo to apt-get calls (runner is non-root)
- Add contents: write permission for uploading release assets
- Drop uv sync before uv build (unnecessary)
- Replace archived upload-release-asset action and redundant
  Python/requests API script with gh release upload; tag name
  is available directly from the release event context
- Strip leading v from tag name to match built artifact filename

* ci: set bash shell for release asset upload step
2026-02-22 11:05:09 +01:00
Benjamin Hackl
000e7792bd
fix: MathTex double-brace splitting no longer fires on natural LaTeX }} (#4602)
* fix: replace double-brace splitting regex with state-machine parser

The previous re.split(r'{{|}}', ...) call split on any occurrence of
{{ or }} in the input string, which broke strings whose only }} came
from closing two nested LaTeX brace groups (e.g. ^{\frac{Mq}{M+m}}).

The new _split_double_braces() static method uses a character-level
state machine with three guards:

* Escape priority: \\ is consumed before \{ / \}, so \\}} is
  correctly read as an escaped backslash followed by a real }}, not
  misinterpreted as \ + \} + lone }.

* Whitespace-gated opener: {{ is only treated as a Manim group opener
  at the start of the string or after whitespace. Naturally-occurring
  {{ in LaTeX is usually preceded by non-whitespace (e.g. \frac{{{n}}}
  or a^{{2}}), so this eliminates the most common false positives.

* Depth-tracking closer: inside a Manim group, }} only closes the
  group when the inner brace depth is zero, so {{ a^{b^{c}} }} is
  handled correctly and nested LaTeX }} cannot trigger an early close.

Fixes #4601.

* docs: document double-brace whitespace requirement

Add a Notes section to the MathTex docstring explaining:
- how {{ }} splits a string into submobjects
- that {{ must be at start-of-string or preceded by whitespace
- that this leaves natural LaTeX like \frac{{{n}}}{k} untouched
- the { { ... } } escape hatch when a split is not wanted

Apply the same explanation to the double-brace paragraph in
docs/source/guides/using_text.rst.

* fix: use r-string for _split_double_braces docstring, halve backslash escaping
2026-02-22 10:48:51 +01:00
76 changed files with 3580 additions and 694 deletions

View file

@ -1 +1,20 @@
.git
# Development / test artifacts
__pycache__
**/__pycache__
*.pyc
*.pyo
*.pyd
*.egg-info
dist/
build/
coverage.xml
# Not needed to install the package
docs/
tests/
example_scenes/
media/
logo/
scripts/

View file

@ -104,8 +104,8 @@ jobs:
oriPath=$PATH
sudo mkdir -p $PWD/macos-cache
echo "Install TinyTeX"
sudo curl -L -o "/tmp/TinyTeX.tgz" "https://github.com/yihui/tinytex-releases/releases/download/daily/TinyTeX-1.tgz"
sudo tar zxf "/tmp/TinyTeX.tgz" -C "$PWD/macos-cache"
sudo curl -L -o "/tmp/TinyTeX.tar.xz" "https://github.com/rstudio/tinytex-releases/releases/download/daily/TinyTeX-1-darwin.tar.xz"
sudo tar xJf "/tmp/TinyTeX.tar.xz" -C "$PWD/macos-cache"
export PATH="$PWD/macos-cache/TinyTeX/bin/universal-darwin:$PATH"
sudo tlmgr update --self
for i in "${ttp[@]}"; do
@ -129,7 +129,7 @@ jobs:
path: ${{ github.workspace }}\ManimCache
key: ${{ runner.os }}-dependencies-tinytex-${{ hashFiles('.github/manimdependency.json') }}-${{ steps.cache-vars.outputs.date }}-1
- uses: ssciwr/setup-mesa-dist-win@v2
- uses: ssciwr/setup-mesa-dist-win@v3
- name: Install system dependencies (Windows)
if: runner.os == 'Windows' && steps.cache-windows.outputs.cache-hit != 'true'
@ -137,8 +137,8 @@ jobs:
$tinyTexPackages = $(python -c "import json;print(' '.join(json.load(open('.github/manimdependency.json'))['windows']['tinytex']))") -Split ' '
$OriPath = $env:PATH
echo "Install Tinytex"
Invoke-WebRequest "https://github.com/yihui/tinytex-releases/releases/download/daily/TinyTeX-1.zip" -OutFile "$($env:TMP)\TinyTex.zip"
Expand-Archive -LiteralPath "$($env:TMP)\TinyTex.zip" -DestinationPath "$($PWD)\ManimCache\LatexWindows"
Invoke-WebRequest "https://github.com/rstudio/tinytex-releases/releases/download/daily/TinyTeX-1-windows.exe" -OutFile "$($env:TMP)\TinyTex.exe"
.$env:TMP\TinyTex.exe -o"$($PWD)\ManimCache\LatexWindows"
$env:Path = "$($PWD)\ManimCache\LatexWindows\TinyTeX\bin\windows;$($env:PATH)"
tlmgr update --self
tlmgr install $tinyTexPackages

View file

@ -13,19 +13,19 @@ jobs:
if: github.event_name != 'release'
steps:
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
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@v3
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
platforms: linux/arm64,linux/amd64
push: true
@ -38,13 +38,13 @@ jobs:
if: github.event_name == 'release'
steps:
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
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@v3
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
@ -61,7 +61,7 @@ jobs:
print(f"tag_name={ref_tag}", file=f)
- name: Build and push
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
platforms: linux/arm64,linux/amd64
push: true

View file

@ -11,6 +11,7 @@ jobs:
environment: release
permissions:
id-token: write
contents: write
steps:
- uses: actions/checkout@v6
@ -28,46 +29,19 @@ jobs:
- name: Build and push release to PyPI
run: |
uv sync
uv build
uv publish
- name: Store artifacts
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
path: dist/*.tar.gz
name: manim.tar.gz
- name: Install Dependency
run: pip install requests
- name: Get Upload URL
id: create_release
shell: python
env:
access_token: ${{ secrets.GITHUB_TOKEN }}
tag_act: ${{ github.ref }}
run: |
import requests
import os
ref_tag = os.getenv('tag_act').split('/')[-1]
access_token = os.getenv('access_token')
headers = {
"Accept":"application/vnd.github.v3+json",
"Authorization": f"token {access_token}"
}
url = f"https://api.github.com/repos/ManimCommunity/manim/releases/tags/{ref_tag}"
c = requests.get(url,headers=headers)
upload_url=c.json()['upload_url']
with open(os.getenv('GITHUB_OUTPUT'), 'w') as f:
print(f"upload_url={upload_url}", file=f)
print(f"tag_name={ref_tag[1:]}", file=f)
- name: Upload Release Asset
id: upload-release
uses: actions/upload-release-asset@v1
shell: bash
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: dist/manim-${{ steps.create_release.outputs.tag_name }}.tar.gz
asset_name: manim-${{ steps.create_release.outputs.tag_name }}.tar.gz
asset_content_type: application/gzip
run: |
TAG=${{ github.event.release.tag_name }}
gh release upload "$TAG" "dist/manim-${TAG#v}.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

View file

@ -4,10 +4,10 @@ authors:
-
name: "The Manim Community Developers"
cff-version: "1.2.0"
date-released: 2026-02-20
date-released: 2026-02-27
license: MIT
message: "We acknowledge the importance of good software to support research, and we note that research becomes more valuable when it is communicated effectively. To demonstrate the value of Manim, we ask that you cite Manim in your work."
title: Manim Mathematical Animation Framework
url: "https://www.manim.community/"
version: "v0.20.0"
version: "v0.20.1"
...

View file

@ -1,10 +1,10 @@
<p align="center">
<a href="https://www.manim.community/"><img src="https://raw.githubusercontent.com/ManimCommunity/manim/main/logo/cropped.png"></a>
<a href="https://www.manim.community/"><img src="https://raw.githubusercontent.com/ManimCommunity/manim/main/logo/cropped.png" alt="Manim Community logo"></a>
<br />
<br />
<a href="https://pypi.org/project/manim/"><img src="https://img.shields.io/pypi/v/manim.svg?style=flat&logo=pypi" alt="PyPI Latest Release"></a>
<a href="https://hub.docker.com/r/manimcommunity/manim"><img src="https://img.shields.io/docker/v/manimcommunity/manim?color=%23099cec&label=docker%20image&logo=docker" alt="Docker image"> </a>
<a href="https://mybinder.org/v2/gh/ManimCommunity/jupyter_examples/HEAD?filepath=basic_example_scenes.ipynb"><img src="https://mybinder.org/badge_logo.svg"></a>
<a href="https://mybinder.org/v2/gh/ManimCommunity/jupyter_examples/HEAD?filepath=basic_example_scenes.ipynb"><img src="https://mybinder.org/badge_logo.svg" alt="Launch Binder"></a>
<a href="http://choosealicense.com/licenses/mit/"><img src="https://img.shields.io/badge/license-MIT-red.svg?style=flat" alt="MIT License"></a>
<a href="https://www.reddit.com/r/manim/"><img src="https://img.shields.io/reddit/subreddit-subscribers/manim.svg?color=orange&label=reddit&logo=reddit" alt="Reddit" href=></a>
<a href="https://twitter.com/manimcommunity/"><img src="https://img.shields.io/twitter/url/https/twitter.com/cloudposse.svg?style=social&label=Follow%20%40manimcommunity" alt="Twitter">

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

@ -1,42 +1,74 @@
FROM python:3.11-slim
# ── Stage 1: builder ─────────────────────────────────────────────────────────
FROM python:3.14-slim AS builder
RUN apt-get update -qq \
&& apt-get install --no-install-recommends -y \
build-essential \
gcc \
cmake \
make \
pkg-config \
wget \
libcairo2-dev \
libffi-dev \
libpango1.0-dev \
freeglut3-dev \
ffmpeg \
pkg-config \
make \
wget \
libegl-dev \
&& rm -rf /var/lib/apt/lists/*
# Setup a minimal TeX Live installation (no ctex: drops ~100 MB of CJK fonts/packages)
COPY docker/texlive-profile.txt /tmp/
ENV PATH=/usr/local/texlive/bin/armhf-linux:/usr/local/texlive/bin/aarch64-linux:/usr/local/texlive/bin/x86_64-linux:$PATH
RUN wget -O /tmp/install-tl-unx.tar.gz http://mirror.ctan.org/systems/texlive/tlnet/install-tl-unx.tar.gz \
&& mkdir /tmp/install-tl \
&& tar -xzf /tmp/install-tl-unx.tar.gz -C /tmp/install-tl --strip-components=1 \
&& /tmp/install-tl/install-tl --profile=/tmp/texlive-profile.txt \
&& tlmgr install \
amsmath babel-english cbfonts-fd cm-super count1to doublestroke dvisvgm everysel \
fontspec frcursive fundus-calligra gnu-freefont jknapltx latex-bin \
mathastext microtype multitoc physics prelim2e preview ragged2e relsize rsfs \
setspace standalone tipa wasy wasysym xcolor xetex xkeyval \
&& rm -rf /tmp/install-tl /tmp/install-tl-unx.tar.gz
# Install manim into an isolated virtualenv
ENV VIRTUAL_ENV=/opt/venv
RUN python -m venv $VIRTUAL_ENV
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
COPY . /opt/manim
WORKDIR /opt/manim
RUN pip install --no-cache-dir .[jupyterlab]
# ── Stage 2: runtime ─────────────────────────────────────────────────────────
FROM python:3.14-slim
# Runtime libs only:
# - no ffmpeg: PyAV (av package) bundles its own ffmpeg libraries in av.libs/
# - OpenGL: keep EGL for headless rendering and libGL as required by moderngl/glcontext
# - fonts-noto-core instead of fonts-noto (drops CJK noto fonts)
RUN apt-get update -qq \
&& apt-get install --no-install-recommends -y \
libcairo2 \
libpango-1.0-0 \
libpangocairo-1.0-0 \
libpangoft2-1.0-0 \
libffi8 \
libegl1 \
libgl1 \
ghostscript \
fonts-noto
fonts-noto-core \
fontconfig \
&& rm -rf /var/lib/apt/lists/*
RUN fc-cache -fv
# setup a minimal texlive installation
COPY docker/texlive-profile.txt /tmp/
# Copy TeX Live from builder
ENV PATH=/usr/local/texlive/bin/armhf-linux:/usr/local/texlive/bin/aarch64-linux:/usr/local/texlive/bin/x86_64-linux:$PATH
RUN wget -O /tmp/install-tl-unx.tar.gz http://mirror.ctan.org/systems/texlive/tlnet/install-tl-unx.tar.gz && \
mkdir /tmp/install-tl && \
tar -xzf /tmp/install-tl-unx.tar.gz -C /tmp/install-tl --strip-components=1 && \
/tmp/install-tl/install-tl --profile=/tmp/texlive-profile.txt \
&& tlmgr install \
amsmath babel-english cbfonts-fd cm-super count1to ctex doublestroke dvisvgm everysel \
fontspec frcursive fundus-calligra gnu-freefont jknapltx latex-bin \
mathastext microtype multitoc physics prelim2e preview ragged2e relsize rsfs \
setspace standalone tipa wasy wasysym xcolor xetex xkeyval
COPY --from=builder /usr/local/texlive /usr/local/texlive
# clone and build manim
COPY . /opt/manim
WORKDIR /opt/manim
RUN pip install --no-cache .[jupyterlab]
RUN pip install -r docs/requirements.txt
# Copy the pre-built virtualenv from builder
ENV VIRTUAL_ENV=/opt/venv
COPY --from=builder /opt/venv /opt/venv
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
ARG NB_USER=manimuser
ARG NB_UID=1000
@ -49,11 +81,8 @@ RUN adduser --disabled-password \
--uid ${NB_UID} \
${NB_USER}
# create working directory for user to mount local directory into
WORKDIR ${HOME}
USER root
RUN chown -R ${NB_USER}:${NB_USER} ${HOME}
RUN chmod 777 ${HOME}
RUN chown -R ${NB_USER}:${NB_USER} ${HOME} && chmod 777 ${HOME}
USER ${NB_USER}
CMD [ "/bin/bash" ]
CMD ["/bin/bash"]

View file

@ -13,3 +13,11 @@ Multi-platform builds are possible by running
docker buildx build --push --platform linux/arm64/v8,linux/amd64 --tag manimcommunity/manim:TAG -f docker/Dockerfile .
```
from the root directory of the repository.
# Runtime notes
- The image is built via a multi-stage Dockerfile (build dependencies are not
carried into the runtime stage).
- The image does not include the `ffmpeg` CLI binary.
- The default TeX installation is minimal and does not include `ctex`.
- Headless OpenGL rendering relies on EGL/GL runtime libraries available in the
image.

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

@ -7,6 +7,7 @@ This page contains a list of changes made between releases.
.. toctree::
:maxdepth: 1
changelog/0.20.1-changelog
changelog/0.20.0-changelog
changelog/0.19.2-changelog
changelog/0.19.1-changelog

View file

@ -0,0 +1,41 @@
---
short-title: v0.20.1
description: Changelog for v0.20.1
---
# v0.20.1
Date
: February 27, 2026
## What's Changed
### Enhancements 🚀
* Cleanup `TipableVMobject`: avoid mutable default and fix `assign_tip_attr` typo by {user}`josiest` in {pr}`4503`
* enhancement: optimize Docker image build and runtime footprint by {user}`behackl` in {pr}`4604`
### Bug Fixes 🐛
* fix: MathTex double-brace splitting no longer fires on natural LaTeX `}}` by {user}`behackl` in {pr}`4602`
* Fix creation or animation of a zero-length `DashedLine` by {user}`SORVER` in {pr}`4606`
* Fix moving-object detection for nested AnimationGroups with z-indexed mobjects by {user}`Merzlikin-Matvey` in {pr}`4389`
* Fix unintended propagation of `kwargs` in `LaggedStartMap` by {user}`irvanalhaq9` in {pr}`4613`
### Documentation 📚
* Documentation: manual installation of manim as a local package by {user}`u7920349` in {pr}`4456`
* Add alt text to all images in `README.md` by {user}`VerisimilitudeX` in {pr}`4064`
### Code Quality & Refactoring 🧹
* Fix publish release workflow by {user}`behackl` in {pr}`4600`
* Silence pydub ffmpeg/avconv import warning when ffmpeg CLI is absent by {user}`behackl` in {pr}`4603`
### Type Hints 📝
* Add type annotations to `manim/_config/utils.py` by {user}`henrikmidtiby` in {pr}`4230`
## New Contributors
* {user}`SORVER` made their first contribution in {pr}`4606`
* {user}`josiest` made their first contribution in {pr}`4503`
* {user}`u7920349` made their first contribution in {pr}`4456`
* {user}`Merzlikin-Matvey` made their first contribution in {pr}`4389`
* {user}`VerisimilitudeX` made their first contribution in {pr}`4064`
**Full Changelog**: [Compare view](https://github.com/ManimCommunity/manim/compare/v0.20.0...v0.20.1)

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
@ -389,8 +444,9 @@ Substrings and parts
The TeX mobject can accept multiple strings as arguments. Afterwards you can
refer to the individual parts either by their index (like ``tex[1]``), or by
selecting parts of the tex code. In this example, we set the color
of the ``\bigstar`` using :func:`~.set_color_by_tex`:
using :func:`~.set_color_by_tex`, which matches the argument exactly against
the strings passed to the constructor. In this example, we color the
``\bigstar`` part:
.. manim:: LaTeXSubstrings
:save_last_frame:
@ -398,25 +454,13 @@ of the ``\bigstar`` using :func:`~.set_color_by_tex`:
class LaTeXSubstrings(Scene):
def construct(self):
tex = Tex('Hello', r'$\bigstar$', r'\LaTeX', font_size=144)
tex.set_color_by_tex('igsta', RED)
tex.set_color_by_tex(r'$\bigstar$', RED)
self.add(tex)
Note that :func:`~.set_color_by_tex` colors the entire substring containing
the Tex, not just the specific symbol or Tex expression. Consider the following example:
.. manim:: IncorrectLaTeXSubstringColoring
:save_last_frame:
class IncorrectLaTeXSubstringColoring(Scene):
def construct(self):
equation = MathTex(
r"e^x = x^0 + x^1 + \frac{1}{2} x^2 + \frac{1}{6} x^3 + \cdots + \frac{1}{n!} x^n + \cdots"
)
equation.set_color_by_tex("x", YELLOW)
self.add(equation)
As you can see, this colors the entire equation yellow, contrary to what
may be expected. To color only ``x`` yellow, we have to do the following:
Because :func:`~.set_color_by_tex` requires an exact match, it cannot directly
target a token inside a string that was passed as a single argument. To color
every ``x`` in a formula, use ``substrings_to_isolate`` to split the string at
each occurrence first:
.. manim:: CorrectLaTeXSubstringColoring
:save_last_frame:
@ -430,21 +474,27 @@ may be expected. To color only ``x`` yellow, we have to do the following:
equation.set_color_by_tex("x", YELLOW)
self.add(equation)
By setting ``substrings_to_isolate`` to ``x``, we split up the
:class:`~.MathTex` into substrings automatically and isolate the ``x`` components
into individual substrings. Only then can :meth:`~.set_color_by_tex` be used
to achieve the desired result.
Each isolated occurrence of ``x`` becomes its own sub-mobject that
:meth:`~.set_color_by_tex` can match exactly.
If one of the ``substrings_to_isolate`` is in a sub or superscript, it needs
to be enclosed by curly brackets.
Note that Manim also supports a custom syntax that allows splitting
a TeX string into substrings easily: simply enclose parts of your formula
that you want to isolate with double braces. In the string
``MathTex(r"{{ a^2 }} + {{ b^2 }} = {{ c^2 }}")``, the rendered mobject
``MathTex(r"{{ a^2 }} + {{ b^2 }} = {{ c^2 }}")``, the rendered mobject
will consist of the substrings ``a^2``, ``+``, ``b^2``, ``=``, and ``c^2``.
This makes transformations between similar text fragments easy
to write using :class:`~.TransformMatchingTex`.
For Manim to recognise a ``{{`` as a group opener, it must appear either
at the very start of the string or be immediately preceded by a whitespace
character. This means that ``{{`` embedded directly after non-whitespace
LaTeX — such as ``\frac{{{n}}}{k}`` or ``a^{{2}}`` — is left untouched,
which prevents accidental splitting of ordinary nested-brace expressions.
To stop a leading ``{{`` from being treated as a group opener, insert a
space between the two braces: ``{{ ... }}````{ { ... } }``.
Using ``index_labels`` to work with complicated strings
=======================================================

View file

@ -18,6 +18,13 @@ For our image ``manimcommunity/manim``, there are the following tags:
``-p`` (preview file) and ``-f`` (show output file in the file browser)
are not supported.
.. note::
The Docker image ships with a minimal TeX Live installation. In particular,
``ctex`` is not installed by default. If your scenes rely on
``TexTemplateLibrary.ctex``, install it in the container via
``tlmgr install ctex``.
Basic usage of the Docker container
-----------------------------------

View file

@ -329,3 +329,13 @@ version satisfies the requirement. Change the line to, for example
to pin the python version to `3.12`. Finally, run `uv sync`, and your
environment is updated!
:::
:::{dropdown} Installing the latest development version
If you want to install the latest (potentially unstable!)
development version of Manim from our source repository
[on GitHub](https://github.com/ManimCommunity/manim), then
simply run
```bash
uv add git+https://github.com/ManimCommunity/manim.git@main
```
:::

View file

@ -195,7 +195,7 @@ Positioning ``Mobject``\s
Next, let's go over some basic techniques for positioning ``Mobject``\s.
1. Open ``scene.py``, and add the following code snippet below the ``SquareToCircle`` method:
1. Open ``scene.py``, and add the following code snippet below the ``SquareToCircle`` class:
.. code-block:: python

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

@ -20,7 +20,7 @@ import logging
import os
import re
import sys
from collections.abc import Iterable, Iterator, Mapping, MutableMapping
from collections.abc import Iterator, Mapping, MutableMapping
from pathlib import Path
from typing import TYPE_CHECKING, Any, ClassVar, NoReturn
@ -134,7 +134,7 @@ def make_config_parser(
return parser
def _determine_quality(qual: str) -> str:
def _determine_quality(qual: str | None) -> str:
for quality, values in constants.QUALITIES.items():
if values["flag"] is not None and values["flag"] == qual:
return quality
@ -338,6 +338,7 @@ class ManimConfig(MutableMapping):
def __contains__(self, key: object) -> bool:
try:
assert isinstance(key, str)
self.__getitem__(key)
return True
except AttributeError:
@ -428,7 +429,7 @@ class ManimConfig(MutableMapping):
# Deepcopying the underlying dict is enough because all properties
# either read directly from it or compute their value on the fly from
# values read directly from it.
c._d = copy.deepcopy(self._d, memo)
c._d = copy.deepcopy(self._d, memo) # type: ignore[arg-type]
return c
# helper type-checking methods
@ -655,13 +656,15 @@ class ManimConfig(MutableMapping):
"window_size"
] # if not "default", get a tuple of the position
if window_size != "default":
window_size = tuple(map(int, re.split(r"[;,\-]", window_size)))
self.window_size = window_size
window_size_numbers = tuple(map(int, re.split(r"[;,\-]", window_size)))
self.window_size = window_size_numbers
else:
self.window_size = window_size
# plugins
plugins = parser["CLI"].get("plugins", fallback="", raw=True)
plugins = [] if plugins == "" else plugins.split(",")
self.plugins = plugins
plugin_list = [] if plugins is None or plugins == "" else plugins.split(",")
self.plugins = plugin_list
# the next two must be set AFTER digesting pixel_width and pixel_height
self["frame_height"] = parser["CLI"].getfloat("frame_height", 8.0)
width = parser["CLI"].getfloat("frame_width", None)
@ -671,31 +674,31 @@ class ManimConfig(MutableMapping):
self["frame_width"] = width
# other logic
val = parser["CLI"].get("tex_template_file")
if val:
self.tex_template_file = val
tex_template_file = parser["CLI"].get("tex_template_file")
if tex_template_file:
self.tex_template_file = Path(tex_template_file)
val = parser["CLI"].get("progress_bar")
if val:
self.progress_bar = val
progress_bar = parser["CLI"].get("progress_bar")
if progress_bar:
self.progress_bar = progress_bar
val = parser["ffmpeg"].get("loglevel")
if val:
self.ffmpeg_loglevel = val
ffmpeg_loglevel = parser["ffmpeg"].get("loglevel")
if ffmpeg_loglevel:
self.ffmpeg_loglevel = ffmpeg_loglevel
try:
val = parser["jupyter"].getboolean("media_embed")
media_embed = parser["jupyter"].getboolean("media_embed")
except ValueError:
val = None
self.media_embed = val
media_embed = None
self.media_embed = media_embed
val = parser["jupyter"].get("media_width")
if val:
self.media_width = val
media_width = parser["jupyter"].get("media_width")
if media_width:
self.media_width = media_width
val = parser["CLI"].get("quality", fallback="", raw=True)
if val:
self.quality = _determine_quality(val)
quality = parser["CLI"].get("quality", fallback="", raw=True)
if quality:
self.quality = _determine_quality(quality)
return self
@ -1044,7 +1047,7 @@ class ManimConfig(MutableMapping):
logger.setLevel(val)
@property
def format(self) -> str:
def format(self) -> str | None:
"""File format; "png", "gif", "mp4", "webm" or "mov"."""
return self._d["format"]
@ -1076,7 +1079,7 @@ class ManimConfig(MutableMapping):
logging.getLogger("libav").setLevel(self.ffmpeg_loglevel)
@property
def media_embed(self) -> bool:
def media_embed(self) -> bool | None:
"""Whether to embed videos in Jupyter notebook."""
return self._d["media_embed"]
@ -1112,8 +1115,10 @@ class ManimConfig(MutableMapping):
self._set_pos_number("pixel_height", value, False)
@property
def aspect_ratio(self) -> int:
def aspect_ratio(self) -> float:
"""Aspect ratio (width / height) in pixels (--resolution, -r)."""
assert isinstance(self._d["pixel_width"], int)
assert isinstance(self._d["pixel_height"], int)
return self._d["pixel_width"] / self._d["pixel_height"]
@property
@ -1137,22 +1142,22 @@ class ManimConfig(MutableMapping):
@property
def frame_y_radius(self) -> float:
"""Half the frame height (no flag)."""
return self._d["frame_height"] / 2
return self._d["frame_height"] / 2 # type: ignore[operator]
@frame_y_radius.setter
def frame_y_radius(self, value: float) -> None:
self._d.__setitem__("frame_y_radius", value) or self._d.__setitem__(
self._d.__setitem__("frame_y_radius", value) or self._d.__setitem__( # type: ignore[func-returns-value]
"frame_height", 2 * value
)
@property
def frame_x_radius(self) -> float:
"""Half the frame width (no flag)."""
return self._d["frame_width"] / 2
return self._d["frame_width"] / 2 # type: ignore[operator]
@frame_x_radius.setter
def frame_x_radius(self, value: float) -> None:
self._d.__setitem__("frame_x_radius", value) or self._d.__setitem__(
self._d.__setitem__("frame_x_radius", value) or self._d.__setitem__( # type: ignore[func-returns-value]
"frame_width", 2 * value
)
@ -1285,7 +1290,7 @@ class ManimConfig(MutableMapping):
@frame_size.setter
def frame_size(self, value: tuple[int, int]) -> None:
self._d.__setitem__("pixel_width", value[0]) or self._d.__setitem__(
self._d.__setitem__("pixel_width", value[0]) or self._d.__setitem__( # type: ignore[func-returns-value]
"pixel_height", value[1]
)
@ -1295,7 +1300,7 @@ class ManimConfig(MutableMapping):
keys = ["pixel_width", "pixel_height", "frame_rate"]
q = {k: self[k] for k in keys}
for qual in constants.QUALITIES:
if all(q[k] == constants.QUALITIES[qual][k] for k in keys):
if all(q[k] == constants.QUALITIES[qual][k] for k in keys): # type: ignore[literal-required]
return qual
return None
@ -1312,6 +1317,7 @@ class ManimConfig(MutableMapping):
@property
def transparent(self) -> bool:
"""Whether the background opacity is less than 1.0 (-t)."""
assert isinstance(self._d["background_opacity"], float)
return self._d["background_opacity"] < 1.0
@transparent.setter
@ -1421,12 +1427,12 @@ class ManimConfig(MutableMapping):
self._d.__setitem__("window_position", value)
@property
def window_size(self) -> str:
"""The size of the opengl window as 'width,height' or 'default' to automatically scale the window based on the display monitor."""
def window_size(self) -> str | tuple[int, ...]:
"""The size of the opengl window. 'default' to automatically scale the window based on the display monitor."""
return self._d["window_size"]
@window_size.setter
def window_size(self, value: str) -> None:
def window_size(self, value: str | tuple[int, ...]) -> None:
self._d.__setitem__("window_size", value)
def resolve_movie_file_extension(self, is_transparent: bool) -> None:
@ -1455,7 +1461,7 @@ class ManimConfig(MutableMapping):
self._set_boolean("enable_gui", value)
@property
def gui_location(self) -> tuple[Any]:
def gui_location(self) -> tuple[int, ...]:
"""Location parameters for the GUI window (e.g., screen coordinates or layout settings)."""
return self._d["gui_location"]
@ -1639,6 +1645,7 @@ class ManimConfig(MutableMapping):
all_args["quality"] = f"{self.pixel_height}p{self.frame_rate:g}"
path = self._d[key]
assert isinstance(path, str)
while "{" in path:
try:
path = path.format(**all_args)
@ -1738,7 +1745,7 @@ class ManimConfig(MutableMapping):
self._set_dir("custom_folders", value)
@property
def input_file(self) -> str:
def input_file(self) -> str | Path:
"""Input file name."""
return self._d["input_file"]
@ -1767,7 +1774,7 @@ class ManimConfig(MutableMapping):
@property
def tex_template(self) -> TexTemplate:
"""Template used when rendering Tex. See :class:`.TexTemplate`."""
if not hasattr(self, "_tex_template") or not self._tex_template:
if not hasattr(self, "_tex_template") or not self._tex_template: # type: ignore[has-type]
fn = self._d["tex_template_file"]
if fn:
self._tex_template = TexTemplate.from_file(fn)
@ -1803,7 +1810,7 @@ class ManimConfig(MutableMapping):
return self._d["plugins"]
@plugins.setter
def plugins(self, value: list[str]):
def plugins(self, value: list[str]) -> None:
self._d["plugins"] = value
@property
@ -1861,7 +1868,7 @@ class ManimFrame(Mapping):
self.__dict__["_c"] = c
# there are required by parent class Mapping to behave like a dict
def __getitem__(self, key: str | int) -> Any:
def __getitem__(self, key: str) -> Any:
if key in self._OPTS:
return self._c[key]
elif key in self._CONSTANTS:
@ -1869,7 +1876,7 @@ class ManimFrame(Mapping):
else:
raise KeyError(key)
def __iter__(self) -> Iterable[str]:
def __iter__(self) -> Iterator[Any]:
return iter(list(self._OPTS) + list(self._CONSTANTS))
def __len__(self) -> int:
@ -1887,4 +1894,4 @@ class ManimFrame(Mapping):
for opt in list(ManimFrame._OPTS) + list(ManimFrame._CONSTANTS):
setattr(ManimFrame, opt, property(lambda self, o=opt: self[o]))
setattr(ManimFrame, opt, property(lambda self, o=opt: self[o])) # type: ignore[misc]

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

@ -353,7 +353,7 @@ class LaggedStartMap(LaggedStart):
Parameters
----------
AnimationClass
animation_class
:class:`~.Animation` to apply to mobject.
mobject
:class:`~.Mobject` whose submobjects the animation, and optionally the function,
@ -362,6 +362,17 @@ class LaggedStartMap(LaggedStart):
Function which will be applied to :class:`~.Mobject`.
run_time
The duration of the animation in seconds.
lag_ratio
Defines the delay after which the animation is applied to submobjects. A lag_ratio of
``n.nn`` means the next animation will play when ``nnn%`` of the current animation has played.
Defaults to 0.05, meaning that the next animation will begin when 5% of the current
animation has played.
This does not influence the total runtime of the animation. Instead the runtime
of individual animations is adjusted so that the complete animation has the defined
run time.
kwargs
Further keyword arguments that are passed to `animation_class`.
Examples
--------
@ -392,6 +403,7 @@ class LaggedStartMap(LaggedStart):
mobject: Mobject,
arg_creator: Callable[[Mobject], Iterable[Any]] | None = None,
run_time: float = 2,
lag_ratio: float = DEFAULT_LAGGED_START_LAG_RATIO,
**kwargs: Any,
):
if arg_creator is None:
@ -406,4 +418,4 @@ class LaggedStartMap(LaggedStart):
if "lag_ratio" in anim_kwargs:
anim_kwargs.pop("lag_ratio")
animations = [animation_class(*args, **anim_kwargs) for args in args_list]
super().__init__(*animations, run_time=run_time, **kwargs)
super().__init__(*animations, run_time=run_time, lag_ratio=lag_ratio)

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

@ -10,7 +10,7 @@ from __future__ import annotations
__all__ = ["MovingCamera"]
from collections.abc import Iterable
from typing import Any
from typing import Any, Literal, overload
from cairo import Context
@ -20,7 +20,7 @@ from .. import config
from ..camera.camera import Camera
from ..constants import DOWN, LEFT, RIGHT, UP
from ..mobject.frame import ScreenRectangle
from ..mobject.mobject import Mobject
from ..mobject.mobject import Mobject, _AnimationBuilder
from ..utils.color import WHITE, ManimColor
@ -166,13 +166,31 @@ class MovingCamera(Camera):
"""
return [self.frame]
@overload
def auto_zoom(
self,
mobjects: Iterable[Mobject],
margin: float,
only_mobjects_in_frame: bool,
animate: Literal[False],
) -> Mobject: ...
@overload
def auto_zoom(
self,
mobjects: Iterable[Mobject],
margin: float,
only_mobjects_in_frame: bool,
animate: Literal[True],
) -> _AnimationBuilder: ...
def auto_zoom(
self,
mobjects: Iterable[Mobject],
margin: float = 0,
only_mobjects_in_frame: bool = False,
animate: bool = True,
) -> Mobject:
) -> _AnimationBuilder | Mobject:
"""Zooms on to a given array of mobjects (or a singular mobject)
and automatically resizes to frame all the mobjects.

View file

@ -101,12 +101,12 @@ class TipableVMobject(VMobject, metaclass=ConvertToOpenGL):
self,
tip_length: float = DEFAULT_ARROW_TIP_LENGTH,
normal_vector: Vector3DLike = OUT,
tip_style: dict = {},
tip_style: dict | None = None,
**kwargs: Any,
) -> None:
self.tip_length: float = tip_length
self.normal_vector = normal_vector
self.tip_style: dict = tip_style
self.tip_style: dict = tip_style if tip_style is not None else {}
super().__init__(**kwargs)
# Adding, Creating, Modifying tips
@ -128,7 +128,7 @@ class TipableVMobject(VMobject, metaclass=ConvertToOpenGL):
else:
self.position_tip(tip, at_start)
self.reset_endpoints_based_on_tip(tip, at_start)
self.asign_tip_attr(tip, at_start)
self.assign_tip_attr(tip, at_start)
self.add(tip)
return self
@ -201,6 +201,7 @@ class TipableVMobject(VMobject, metaclass=ConvertToOpenGL):
axis=axis,
) # Rotates the tip along the vertical wrt the axis
self._init_positioning_axis = axis
tip.shift(anchor - tip.tip_point)
return tip
@ -215,7 +216,7 @@ class TipableVMobject(VMobject, metaclass=ConvertToOpenGL):
self.put_start_and_end_on(self.get_start(), tip.base)
return self
def asign_tip_attr(self, tip: tips.ArrowTip, at_start: bool) -> Self:
def assign_tip_attr(self, tip: tips.ArrowTip, at_start: bool) -> Self:
if at_start:
self.start_tip = tip
else:
@ -241,7 +242,8 @@ class TipableVMobject(VMobject, metaclass=ConvertToOpenGL):
if self.has_start_tip():
result.add(self.start_tip)
self.remove(self.start_tip)
self.put_start_and_end_on(start, end)
if result.submobjects:
self.put_start_and_end_on(start, end)
return result
def get_tips(self) -> VGroup:

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

@ -14,14 +14,14 @@ __all__ = [
"RightAngle",
]
from typing import TYPE_CHECKING, Any, Literal
from typing import TYPE_CHECKING, Any, Literal, cast
import numpy as np
from manim import config
from manim.constants import *
from manim.mobject.geometry.arc import Arc, ArcBetweenPoints, Dot, TipableVMobject
from manim.mobject.geometry.tips import ArrowTriangleFilledTip
from manim.mobject.geometry.tips import ArrowTip, ArrowTriangleFilledTip
from manim.mobject.mobject import Mobject
from manim.mobject.opengl.opengl_compatibility import ConvertToOpenGL
from manim.mobject.opengl.opengl_mobject import OpenGLMobject
@ -648,9 +648,11 @@ class Arrow(Line):
self._set_stroke_width_from_length()
if has_tip:
self.add_tip(tip=old_tips[0])
# error: Argument "tip" to "add_tip" of "TipableVMobject" has incompatible type "VMobject"; expected "ArrowTip | None" [arg-type]
self.add_tip(tip=cast(ArrowTip, old_tips[0]))
if has_start_tip:
self.add_tip(tip=old_tips[1], at_start=True)
# error: Argument "tip" to "add_tip" of "TipableVMobject" has incompatible type "VMobject"; expected "ArrowTip | None" [arg-type]
self.add_tip(tip=cast(ArrowTip, old_tips[1]), at_start=True)
return self
def get_normal_vector(self) -> Vector3D:

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

@ -207,13 +207,11 @@ class SampleSpace(Rectangle):
if hasattr(parts, subattr):
self.add(getattr(parts, subattr))
def __getitem__(self, index: int) -> SampleSpace:
def __getitem__(self, index: int) -> VMobject:
if hasattr(self, "horizontal_parts"):
val: SampleSpace = self.horizontal_parts[index]
return val
return self.horizontal_parts[index]
elif hasattr(self, "vertical_parts"):
val = self.vertical_parts[index]
return val
return self.vertical_parts[index]
return self.split()[index]
@ -373,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

@ -40,15 +40,15 @@ __all__ = [
import itertools as it
from collections.abc import Callable, Iterable, Sequence
from collections.abc import Callable, Iterable
from typing import Any, Self
import numpy as np
from manim.mobject.mobject import Mobject
from manim.mobject.opengl.opengl_compatibility import ConvertToOpenGL
from manim.mobject.text.numbers import DecimalNumber, Integer
from manim.mobject.text.tex_mobject import MathTex, Tex
from manim.typing import Vector2DLike, Vector3DLike
from ..constants import *
from ..mobject.types.vectorized_mobject import VGroup, VMobject
@ -164,16 +164,16 @@ class Matrix(VMobject, metaclass=ConvertToOpenGL):
def __init__(
self,
matrix: Iterable,
matrix: Iterable[Iterable[Any] | Vector2DLike],
v_buff: float = 0.8,
h_buff: float = 1.3,
bracket_h_buff: float = MED_SMALL_BUFF,
bracket_v_buff: float = MED_SMALL_BUFF,
add_background_rectangles_to_entries: bool = False,
include_background_rectangle: bool = False,
element_to_mobject: type[Mobject] | Callable[..., Mobject] = MathTex,
element_to_mobject_config: dict = {},
element_alignment_corner: Sequence[float] = DR,
element_to_mobject: type[VMobject] | Callable[..., VMobject] = MathTex,
element_to_mobject_config: dict[str, Any] = {},
element_alignment_corner: Vector3DLike = DR,
left_bracket: str = "[",
right_bracket: str = "]",
stretch_brackets: bool = True,
@ -206,7 +206,9 @@ class Matrix(VMobject, metaclass=ConvertToOpenGL):
if self.include_background_rectangle:
self.add_background_rectangle()
def _matrix_to_mob_matrix(self, matrix: np.ndarray) -> list[list[Mobject]]:
def _matrix_to_mob_matrix(
self, matrix: Iterable[Iterable[Any]]
) -> list[list[VMobject]]:
return [
[
self.element_to_mobject(item, **self.element_to_mobject_config)
@ -215,7 +217,7 @@ class Matrix(VMobject, metaclass=ConvertToOpenGL):
for row in matrix
]
def _organize_mob_matrix(self, matrix: list[list[Mobject]]) -> Self:
def _organize_mob_matrix(self, matrix: list[list[VMobject]]) -> Self:
for i, row in enumerate(matrix):
for j, _ in enumerate(row):
mob = matrix[i][j]
@ -401,7 +403,7 @@ class Matrix(VMobject, metaclass=ConvertToOpenGL):
mob.add_background_rectangle()
return self
def get_mob_matrix(self) -> list[list[Mobject]]:
def get_mob_matrix(self) -> list[list[VMobject]]:
"""Return the underlying mob matrix mobjects.
Returns
@ -483,8 +485,8 @@ class DecimalMatrix(Matrix):
def __init__(
self,
matrix: Iterable,
element_to_mobject: type[Mobject] = DecimalNumber,
matrix: Iterable[Iterable[Any]],
element_to_mobject: type[VMobject] | Callable[..., VMobject] = DecimalNumber,
element_to_mobject_config: dict[str, Any] = {"num_decimal_places": 1},
**kwargs: Any,
):
@ -528,8 +530,8 @@ class IntegerMatrix(Matrix):
def __init__(
self,
matrix: Iterable,
element_to_mobject: type[Mobject] = Integer,
matrix: Iterable[Iterable[Any]],
element_to_mobject: type[VMobject] | Callable[..., VMobject] = Integer,
**kwargs: Any,
):
"""
@ -566,8 +568,8 @@ class MobjectMatrix(Matrix):
def __init__(
self,
matrix: Iterable,
element_to_mobject: type[Mobject] | Callable[..., Mobject] = lambda m: m,
matrix: Iterable[Iterable[Any]],
element_to_mobject: type[VMobject] | Callable[..., VMobject] = lambda m: m,
**kwargs: Any,
):
super().__init__(matrix, element_to_mobject=element_to_mobject, **kwargs)

File diff suppressed because it is too large Load diff

View file

@ -107,7 +107,7 @@ class OpenGLTipableVMobject(OpenGLVMobject):
"""
tip = self.create_tip(at_start, **kwargs)
self.reset_endpoints_based_on_tip(tip, at_start)
self.asign_tip_attr(tip, at_start)
self.assign_tip_attr(tip, at_start)
self.add(tip)
return self
@ -160,7 +160,7 @@ class OpenGLTipableVMobject(OpenGLVMobject):
self.put_start_and_end_on(start, end)
return self
def asign_tip_attr(self, tip: OpenGLArrowTip, at_start: bool) -> Self:
def assign_tip_attr(self, tip: OpenGLArrowTip, at_start: bool) -> Self:
if at_start:
self.start_tip = tip
else:

View file

@ -6,6 +6,7 @@ import itertools as it
import random
import sys
import types
import warnings
from collections.abc import Callable, Iterable, Iterator, Sequence
from functools import partialmethod, wraps
from math import ceil
@ -1222,7 +1223,7 @@ class OpenGLMobject:
) -> Sequence[Vector3D]:
if str_alignments is None:
# Use cell_alignment as fallback
return [cast(Vector3D, cell_alignment * direction)] * num
return [cast("Vector3D", cell_alignment * direction)] * num
if len(str_alignments) != num:
raise ValueError(f"{name}_alignments has a mismatching size.")
return [mapping[letter] for letter in str_alignments]
@ -2134,26 +2135,33 @@ class OpenGLMobject:
return self
def put_start_and_end_on(self, start: Point3DLike, end: Point3DLike) -> Self:
curr_start, curr_end = self.get_start_and_end()
curr_vect = curr_end - curr_start
if np.all(curr_vect == 0):
raise Exception("Cannot position endpoints of closed loop")
target_vect = np.array(end) - np.array(start)
current_start, current_end = self.get_start_and_end()
current_vector = current_end - current_start
if np.all(current_vector == 0):
warnings.warn(
"put_start_and_end_on has been called on a closed loop or zero-length mobject. "
f"{type(self).__name__} will be shifted to start point instead.",
stacklevel=2,
)
self.shift(np.asarray(start) - current_start)
return self
target_vector = np.asarray(end) - np.asarray(start)
axis = (
normalize(np.cross(curr_vect, target_vect))
if np.linalg.norm(np.cross(curr_vect, target_vect)) != 0
normalize(np.cross(current_vector, target_vector))
if np.linalg.norm(np.cross(current_vector, target_vector)) != 0
else OUT
)
self.scale(
float(np.linalg.norm(target_vect) / np.linalg.norm(curr_vect)),
about_point=curr_start,
np.linalg.norm(target_vector) / np.linalg.norm(current_vector),
about_point=current_start,
)
self.rotate(
angle_between_vectors(curr_vect, target_vect),
about_point=curr_start,
angle_between_vectors(current_vector, target_vector),
about_point=current_start,
axis=axis,
)
self.shift(start - curr_start)
self.shift(np.asarray(start) - current_start)
return self
# Color functions
@ -3037,7 +3045,7 @@ class OpenGLPoint(OpenGLMobject):
return self.artificial_height
def get_location(self) -> Point3D:
return cast(Point3D, self.points[0]).copy()
return cast("Point3D", self.points[0]).copy()
@override
def get_bounding_box_point(self, *args: object, **kwargs: Any) -> Point3D:

View file

@ -1227,7 +1227,9 @@ class OpenGLVMobject(OpenGLMobject):
def get_nth_subpath(path_list, n):
if n >= len(path_list):
# Create a null path at the very end
return [path_list[-1][-1]] * nppc
if len(path_list) == 0:
return np.tile(np.zeros(3), (nppc, 1))
return np.tile(path_list[-1][-1], (nppc, 1))
path = path_list[n]
# Check for useless points at the end of the path and remove them
# https://github.com/ManimCommunity/manim/issues/1959

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

@ -237,6 +237,26 @@ class MathTex(SingleStringMathTex):
t = MathTex(r"\int_a^b f'(x) dx = f(b)- f(a)")
self.add(t)
Notes
-----
Double-brace notation ``{{ ... }}`` can be used to split a single
string argument into multiple submobjects without having to pass
separate strings::
MathTex(r"{{ a^2 }} + {{ b^2 }} = {{ c^2 }}")
Each ``{{ ... }}`` group and every piece of text between groups
becomes its own submobject, which is useful for
:class:`~.TransformMatchingTex` animations.
For ``{{`` to be recognised as a group opener it must appear either
at the very start of the string or be immediately preceded by a
whitespace character. ``{{`` that follows non-whitespace such as
in ``\frac{{{n}}}{k}`` or ``a^{{2}}`` is left untouched, so
ordinary nested-brace LaTeX is not accidentally split. To prevent
an unintentional split, insert a space between the two braces:
``{{ ... }}`` ``{ { ... } }``.
Tests
-----
Check that creating a :class:`~.MathTex` works::
@ -318,15 +338,94 @@ class MathTex(SingleStringMathTex):
tex_strings_validated = [
string if isinstance(string, str) else str(string) for string in tex_strings
]
# Locate double curly bracers
# Locate double curly bracers and split on them.
tex_strings_validated_two = []
for tex_string in tex_strings_validated:
split = re.split(r"{{|}}", tex_string)
split = self._split_double_braces(tex_string)
tex_strings_validated_two.extend(split)
if len(tex_strings_validated_two) > len(tex_strings_validated):
self.brace_notation_split_occurred = True
return [string for string in tex_strings_validated_two if len(string) > 0]
@staticmethod
def _split_double_braces(tex_string: str) -> list[str]:
r"""Split *tex_string* on Manim's ``{{ ... }}`` double-brace notation.
Rules that avoid false positives on ordinary LaTeX source:
* ``{{`` is only treated as a group opener when it appears at the very
start of the string or is immediately preceded by a whitespace
character. Naturally-occurring ``{{`` in LaTeX is usually preceded
by non-whitespace (e.g. ``\frac{{{n}}}{k}`` or ``a^{{2}}``), so
the whitespace guard eliminates the most common false positives
without any brace-depth bookkeeping on the outer string.
* Inside an open group the depth of *real* LaTeX braces is tracked.
``}}`` only closes the Manim group when the inner depth is zero,
so ``{{ a^{b^{c}} }}`` is handled correctly.
* Escape sequences are consumed as two-character units in priority
order: ``\\`` first (escaped backslash), then ``\{`` / ``\}``
(escaped braces). This ensures e.g. ``\\}}`` is read as an
escaped backslash followed by a real ``}}`` rather than as
``\`` + ``\}`` + lone ``}``.
"""
segments: list[str] = []
current = ""
i = 0
inside_manim = False
inner_depth = 0
while i < len(tex_string):
# --- consume escape sequences as atomic units ---
if tex_string[i] == "\\" and i + 1 < len(tex_string):
next_ch = tex_string[i + 1]
if next_ch == "\\" or next_ch in "{}":
# \\ (escaped backslash) checked before \{ / \} so that
# the second \ in \\ is never mistaken for an escape prefix.
current += tex_string[i : i + 2]
i += 2
continue
if not inside_manim:
# {{ opens a Manim group only at start-of-string or after whitespace.
if tex_string[i : i + 2] == "{{" and (
i == 0 or tex_string[i - 1].isspace()
):
segments.append(current)
current = ""
inside_manim = True
inner_depth = 0
i += 2
else:
current += tex_string[i]
i += 1
else:
if tex_string[i] == "{":
inner_depth += 1
current += tex_string[i]
i += 1
elif (
tex_string[i] == "}"
and inner_depth == 0
and tex_string[i : i + 2] == "}}"
):
# }} at inner depth 0 closes the Manim group.
segments.append(current)
current = ""
inside_manim = False
i += 2
elif tex_string[i] == "}":
inner_depth -= 1
current += tex_string[i]
i += 1
else:
current += tex_string[i]
i += 1
segments.append(current)
return segments
def _join_tex_strings_with_unique_deliminters(
self, tex_strings: list[str], substrings_to_isolate: Iterable[str]
) -> str:
@ -488,7 +587,7 @@ class MathTex(SingleStringMathTex):
self.id_to_vgroup_dict[match[1]].set_color(color)
return self
def index_of_part(self, part: MathTex) -> int:
def index_of_part(self, part: VMobject) -> int:
split_self = self.split()
if part not in split_self:
raise ValueError("Trying to get index of part not in MathTex")

View file

@ -166,9 +166,12 @@ class Paragraph(VGroup):
lines_str_list = lines_str.split("\n")
self.chars = self._gen_chars(lines_str_list)
self.lines = [list(self.chars), [self.alignment] * len(self.chars)]
self.lines_initial_positions = [line.get_center() for line in self.lines[0]]
self.add(*self.lines[0])
# TODO: If possible get rid of self.lines_chars, as it seems to be a
# listified duplicate of self.chars.
self.lines_chars = list(self.chars)
self.lines_alignments = [self.alignment] * len(self.chars)
self.lines_initial_positions = [line.get_center() for line in self.lines_chars]
self.add(*self.lines_chars)
self.move_to(np.array([0, 0, 0]))
if self.alignment:
self._set_all_lines_alignments(self.alignment)
@ -221,7 +224,7 @@ class Paragraph(VGroup):
alignment
Defines the alignment of paragraph. Possible values are "left", "right", "center".
"""
for line_no in range(len(self.lines[0])):
for line_no in range(len(self.lines_chars)):
self._change_alignment_for_a_line(alignment, line_no)
return self
@ -240,8 +243,8 @@ class Paragraph(VGroup):
def _set_all_lines_to_initial_positions(self) -> Paragraph:
"""Set all lines to their initial positions."""
self.lines[1] = [None] * len(self.lines[0])
for line_no in range(len(self.lines[0])):
self.lines_alignments = [None] * len(self.lines_chars)
for line_no in range(len(self.lines_chars)):
self[line_no].move_to(
self.get_center() + self.lines_initial_positions[line_no],
)
@ -255,7 +258,7 @@ class Paragraph(VGroup):
line_no
Defines the line number for which we want to set given alignment.
"""
self.lines[1][line_no] = None
self.lines_alignments[line_no] = None
self[line_no].move_to(self.get_center() + self.lines_initial_positions[line_no])
return self
@ -269,12 +272,12 @@ class Paragraph(VGroup):
line_no
Defines the line number for which we want to set given alignment.
"""
self.lines[1][line_no] = alignment
if self.lines[1][line_no] == "center":
self.lines_alignments[line_no] = alignment
if self.lines_alignments[line_no] == "center":
self[line_no].move_to(
np.array([self.get_center()[0], self[line_no].get_center()[1], 0]),
)
elif self.lines[1][line_no] == "right":
elif self.lines_alignments[line_no] == "right":
self[line_no].move_to(
np.array(
[
@ -284,7 +287,7 @@ class Paragraph(VGroup):
],
),
)
elif self.lines[1][line_no] == "left":
elif self.lines_alignments[line_no] == "left":
self[line_no].move_to(
np.array(
[

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

@ -149,6 +149,7 @@ class Surface(VGroup, metaclass=ConvertToOpenGL):
self.pre_function_handle_to_anchor_scale_factor = (
pre_function_handle_to_anchor_scale_factor
)
self.list_of_faces: list[ThreeDVMobject] = []
self._func = func
self._setup_in_uv_space()
self.apply_function(lambda p: func(p[0], p[1]))
@ -172,6 +173,7 @@ class Surface(VGroup, metaclass=ConvertToOpenGL):
def _setup_in_uv_space(self) -> None:
u_values, v_values = self._get_u_values_and_v_values()
faces = VGroup()
self.list_of_faces = []
for i in range(len(u_values) - 1):
for j in range(len(v_values) - 1):
u1, u2 = u_values[i : i + 2]
@ -193,6 +195,7 @@ class Surface(VGroup, metaclass=ConvertToOpenGL):
face.u2 = u2
face.v1 = v1
face.v2 = v2
self.list_of_faces.append(face)
faces.set_fill(color=self.fill_color, opacity=self.fill_opacity)
faces.set_stroke(
color=self.stroke_color,
@ -223,7 +226,7 @@ class Surface(VGroup, metaclass=ConvertToOpenGL):
The parametric surface with an alternating pattern.
"""
n_colors = len(colors)
for face in self:
for face in self.list_of_faces:
c_index = (face.u_index + face.v_index) % n_colors
face.set_fill(colors[c_index], opacity=opacity)
return self
@ -376,13 +379,7 @@ class Sphere(Surface):
class ExampleSphere(ThreeDScene):
def construct(self):
self.set_camera_orientation(phi=PI / 6, theta=PI / 6)
sphere1 = Sphere(
center=(3, 0, 0),
radius=1,
resolution=(20, 20),
u_range=[0.001, PI - 0.001],
v_range=[0, TAU]
)
sphere1 = Sphere(center=(3, 0, 0), radius=1, resolution=(20, 20))
sphere1.set_color(RED)
self.add(sphere1)
sphere2 = Sphere(center=(-1, -3, 0), radius=2, resolution=(18, 18))
@ -391,6 +388,57 @@ class Sphere(Surface):
sphere3 = Sphere(center=(-1, 2, 0), radius=2, resolution=(16, 16))
sphere3.set_color(BLUE)
self.add(sphere3)
This example shows that overlapping spheres can intersect with rough transitions.
.. manim:: ExampleSphereOverlap
:save_last_frame:
class ExampleSphereOverlap(ThreeDScene):
def construct(self):
self.set_camera_orientation(phi=PI / 4, theta=PI / 4)
sphere1 = Sphere(center=(0, 0, 0), radius=1, resolution=(20, 20))
sphere1.set_color(RED)
self.add(sphere1)
sphere2 = Sphere(center=(-0.5, -1, 0.5), radius=1.2, resolution=(20, 20))
sphere2.set_color(GREEN)
self.add(sphere2)
sphere3 = Sphere(center=(1, -1, 0), radius=1.1, resolution=(20, 20))
sphere3.set_color(BLUE)
self.add(sphere3)
In this example, by modifying ``u_range`` (the range of the azimuthal angle) and
``v_range`` (the range of the polar angle), it is possible to obtain a portion of a
sphere:
.. manim:: ExamplePartialSpheres
:save_last_frame:
class ExamplePartialSpheres(ThreeDScene):
def construct(self):
self.set_camera_orientation(phi=PI / 4)
sphere1 = Sphere(
center=(-3, 0, 0),
resolution=(10, 20),
u_range=[TAU / 4, 3 * TAU / 4],
)
sphere1.set_color(RED)
self.add(sphere1)
sphere2 = Sphere(
center=(0, 0, 0),
resolution=(20, 10),
v_range=[0, TAU / 4],
)
sphere2.set_color(GREEN)
self.add(sphere2)
sphere3 = Sphere(
center=(3, 0, 0),
resolution=(5, 10),
u_range=[3 * TAU / 4, TAU],
v_range=[TAU / 4, TAU / 2],
)
sphere3.set_color(BLUE)
self.add(sphere3)
"""
def __init__(

View file

@ -68,7 +68,7 @@ class AbstractImageMobject(Mobject):
def get_pixel_array(self) -> PixelArray:
raise NotImplementedError()
def set_color( # type: ignore[override]
def set_color(
self,
color: ParsableManimColor = YELLOW_C,
alpha: Any = None,
@ -217,7 +217,7 @@ class ImageMobject(AbstractImageMobject):
"""A simple getter method."""
return self.pixel_array
def set_color( # type: ignore[override]
def set_color(
self,
color: ParsableManimColor = YELLOW_C,
alpha: Any = None,

View file

@ -47,6 +47,7 @@ from manim.utils.iterables import (
from manim.utils.space_ops import rotate_vector, shoelace_direction
if TYPE_CHECKING:
from collections.abc import Iterator
from typing import Self
import numpy.typing as npt
@ -103,6 +104,7 @@ class VMobject(Mobject):
"""
sheen_factor = 0.0
target: VMobject
def __init__(
self,
@ -153,7 +155,7 @@ class VMobject(Mobject):
self.shade_in_3d: bool = shade_in_3d
self.tolerance_for_point_equality: float = tolerance_for_point_equality
self.n_points_per_cubic_curve: int = n_points_per_cubic_curve
self._bezier_t_values: npt.NDArray[float] = np.linspace(
self._bezier_t_values: npt.NDArray[np.float64] = np.linspace(
0, 1, n_points_per_cubic_curve
)
self.cap_style: CapStyleType = cap_style
@ -172,6 +174,9 @@ class VMobject(Mobject):
def _assert_valid_submobjects(self, submobjects: Iterable[VMobject]) -> Self:
return self._assert_valid_submobjects_internal(submobjects, VMobject)
def __iter__(self) -> Iterator[VMobject]:
return iter(self.split())
# OpenGL compatibility
@property
def n_points_per_curve(self) -> int:
@ -495,8 +500,10 @@ class VMobject(Mobject):
will shrink, and for :math:`|\alpha| > 1` it will grow. Furthermore,
if :math:`\alpha < 0`, the mobject is also flipped.
scale_stroke
Boolean determining if the object's outline is scaled when the object is scaled.
If enabled, and object with 2px outline is scaled by a factor of .5, it will have an outline of 1px.
Boolean determining if each submobject's outline is scaled when the object
is scaled. If enabled, each submobject keeps its relative stroke width (for
example, a submobject with a 2px outline scaled by a factor of .5 will have
a 1px outline, while a submobject with 0px stroke remains at 0px).
kwargs
Additional keyword arguments passed to
:meth:`~.Mobject.scale`.
@ -533,11 +540,17 @@ class VMobject(Mobject):
"""
if scale_stroke:
self.set_stroke(width=abs(scale_factor) * self.get_stroke_width())
self.set_stroke(
width=abs(scale_factor) * self.get_stroke_width(background=True),
background=True,
)
for mob in self.get_family():
if isinstance(mob, VMobject):
mob.set_stroke(
width=abs(scale_factor) * mob.get_stroke_width(),
family=False,
)
mob.set_stroke(
width=abs(scale_factor) * mob.get_stroke_width(background=True),
background=True,
family=False,
)
super().scale(scale_factor, about_point=about_point, about_edge=about_edge)
return self
@ -630,6 +643,17 @@ class VMobject(Mobject):
color: ManimColor = property(get_color, set_color)
def nonempty_submobjects(self) -> Sequence[VMobject]:
return [
submob
for submob in self.submobjects
if len(submob.submobjects) != 0 or len(submob.points) != 0
]
def split(self) -> list[VMobject]:
result: list[VMobject] = [self] if len(self.points) > 0 else []
return result + self.submobjects
def set_sheen_direction(self, direction: Vector3DLike, family: bool = True) -> Self:
"""Sets the direction of the applied sheen.
@ -1769,7 +1793,9 @@ class VMobject(Mobject):
def get_nth_subpath(path_list, n):
if n >= len(path_list):
# Create a null path at the very end
return [path_list[-1][-1]] * nppcc
if len(path_list) == 0:
return np.tile(np.zeros(3), (nppcc, 1))
return np.tile(path_list[-1][-1], (nppcc, 1))
path = path_list[n]
# Check for useless points at the end of the path and remove them
# https://github.com/ManimCommunity/manim/issues/1959
@ -2303,6 +2329,11 @@ class VGroup(VMobject, metaclass=ConvertToOpenGL):
self._assert_valid_submobjects(tuplify(value))
self.submobjects[key] = value
def __getitem__(self, key: int | slice) -> VMobject:
if isinstance(key, slice):
return VGroup(self.submobjects[key])
return self.submobjects[key]
class VDict(VMobject, metaclass=ConvertToOpenGL):
"""A VGroup-like class, also offering submobject access by

View file

@ -25,14 +25,23 @@ class Window(PygletWindow):
def __init__(
self,
renderer: OpenGLRenderer,
window_size: str = config.window_size,
window_size: str | tuple[int, ...] = config.window_size,
**kwargs: Any,
) -> None:
monitors = get_monitors()
mon_index = config.window_monitor
monitor = monitors[min(mon_index, len(monitors) - 1)]
if window_size == "default":
invalid_window_size_error_message = (
"window_size must be specified either as 'default', a string of the form "
"'width,height', or a tuple of 2 ints of the form (width, height)."
)
if isinstance(window_size, tuple):
if len(window_size) != 2:
raise ValueError(invalid_window_size_error_message)
size = window_size
elif window_size == "default":
# make window_width half the width of the monitor
# but make it full screen if --fullscreen
window_width = monitor.width
@ -48,9 +57,7 @@ class Window(PygletWindow):
(window_width, window_height) = tuple(map(int, window_size.split(",")))
size = (window_width, window_height)
else:
raise ValueError(
"Window_size must be specified as 'width,height' or 'default'.",
)
raise ValueError(invalid_window_size_error_message)
super().__init__(size=size)

View file

@ -903,7 +903,24 @@ class Scene:
# as soon as there's one that needs updating of
# some kind per frame, return the list from that
# point forward.
animation_mobjects = [anim.mobject for anim in animations]
# Imported inside the method to avoid cyclic import.
from ..animation.composition import AnimationGroup
def _collect_animation_mobjects(
nested_animations: Iterable[Animation],
) -> list[Mobject | OpenGLMobject]:
animation_mobjects: list[Mobject | OpenGLMobject] = []
for anim in nested_animations:
if isinstance(anim, AnimationGroup):
animation_mobjects.extend(
_collect_animation_mobjects(anim.animations),
)
else:
animation_mobjects.extend(anim.mobject.get_family())
return animation_mobjects
animation_mobjects = _collect_animation_mobjects(animations)
mobjects = self.get_mobject_family_members()
for i, mob in enumerate(mobjects):
update_possibilities = [

View file

@ -6,6 +6,7 @@ __all__ = ["SceneFileWriter"]
import json
import shutil
import warnings
from fractions import Fraction
from pathlib import Path
from queue import Queue
@ -17,7 +18,17 @@ import av
import numpy as np
import srt
from PIL import Image
from pydub import AudioSegment
# Manim handles audio conversion through PyAV directly. Importing pydub emits a
# RuntimeWarning if ffmpeg/avconv is not on PATH, even when only WAV code paths
# are used (which do not need ffmpeg). Silence this specific warning.
with warnings.catch_warnings():
warnings.filterwarnings(
"ignore",
message=r".*ffmpeg or avconv.*",
category=RuntimeWarning,
)
from pydub import AudioSegment
from manim import __version__

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,
@ -281,15 +282,19 @@ class VectorScene(Scene):
color (str),
label_scale_factor=VECTOR_LABEL_SCALE_FACTOR (int, float),
"""
i_hat, j_hat = self.get_basis_vectors()
i_hat = self.get_basis_vectors().submobjects[0]
j_hat = self.get_basis_vectors().submobjects[1]
return VGroup(
*(
self.get_vector_label(
vect, label, color=color, label_scale_factor=1, **kwargs
)
for vect, label, color in [
(i_hat, "\\hat{\\imath}", X_COLOR),
(j_hat, "\\hat{\\jmath}", Y_COLOR),
# Casting i_hat and j_hat to Vector, as the VGroup from
# self.get_basis_vectors() contains two vectors, but the
# type checker is currently not aware of that.
(cast(Vector, i_hat), "\\hat{\\imath}", X_COLOR),
(cast(Vector, j_hat), "\\hat{\\jmath}", Y_COLOR),
]
)
)
@ -297,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.
@ -326,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()
@ -361,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.
@ -373,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
@ -383,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:
@ -517,7 +525,9 @@ class VectorScene(Scene):
y_line = Line(x_line.get_end(), arrow.get_end())
x_line.set_color(X_COLOR)
y_line.set_color(Y_COLOR)
x_coord, y_coord = cast(VGroup, array.get_entries())
temp = array.get_entries()
x_coord = temp.submobjects[0]
y_coord = temp.submobjects[1]
x_coord_start = self.position_x_coordinate(x_coord.copy(), x_line, vector)
y_coord_start = self.position_y_coordinate(y_coord.copy(), y_line, vector)
brackets = array.get_brackets()
@ -697,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)
@ -965,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)
@ -1143,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

@ -90,6 +90,7 @@ def bezier(
containing :math:`n` values to evaluate the Bézier curve at, returning instead
an :math:`(n, 3)`-shaped :class:`~.Point3D_Array` containing the points
resulting from evaluating the Bézier at each of the :math:`n` values.
.. warning::
If passing a vector of :math:`t`-values to ``bezier_func``, it **must**
be a column vector/matrix of shape :math:`(n, 1)`. Passing an 1D array of
@ -106,6 +107,7 @@ def bezier(
Bézier curve defined by ``points`` is evaluated at the corresponding :math:`i`-th
value in ``t``, returning again an :math:`(M, 3)`-shaped :class:`~.Point3D_Array`
containing those :math:`M` evaluations.
.. warning::
Unlike the previous case, if you pass a :class:`~.ColVector` to ``bezier_func``,
it **must** contain exactly :math:`M` values, each value for each of the :math:`M`

View file

@ -250,6 +250,9 @@ def deprecated(
if type(func).__name__ != "function":
deprecate_docs(func)
# The following line raises this mypy error:
# Accessing "__init__" on an instance is unsound, since instance.__init__
# could be from an incompatible subclass [misc]</pre>
func.__init__ = decorate(func.__init__, deprecate)
return func

View file

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

View file

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

View file

@ -0,0 +1,106 @@
"""Interface for writing, compiling, and converting ``.typ`` files via the ``typst`` Python package.
.. SEEALSO::
:mod:`.mobject.text.typst_mobject`
"""
from __future__ import annotations
import hashlib
from pathlib import Path
from manim import config, logger
__all__ = ["typst_to_svg_file"]
# Use 10pt instead of Typst's 11pt default so that the post-import scaling
# based on Manim's font_size property matches TeX / MathTex more closely.
TYPST_COMPILATION_FONT_SIZE = 10 # pt
TYPST_TEMPLATE = """\
#set page(width: auto, height: auto, margin: 0pt, fill: none)
#set text(size: {text_size}pt)
{preamble}
{body}
"""
def _typst_hash(content: str) -> str:
"""Return a truncated SHA-256 hex digest of *content*."""
return hashlib.sha256(content.encode()).hexdigest()[:16]
def typst_to_svg_file(
typst_code: str,
preamble: str = "",
text_size: float = TYPST_COMPILATION_FONT_SIZE,
font_paths: list[str | Path] | None = None,
) -> Path:
"""Compile a Typst string to SVG via the ``typst`` Python package.
The compiled SVG and the intermediate ``.typ`` source are cached
under :func:`config.get_dir("tex_dir") <manim.ManimConfig.get_dir>`
using a content-hash filename scheme (identical to the LaTeX pipeline).
Parameters
----------
typst_code
The body of the Typst document (user-supplied markup).
preamble
Extra Typst code inserted between the ``#set`` rules and the body.
Useful for ``#import``, ``#set``, or ``#show`` rules.
text_size
Font size in Typst points used during compilation.
font_paths
Optional list of additional font directories passed to the Typst
compiler.
Returns
-------
:class:`Path`
Path to the generated SVG file.
Raises
------
ImportError
If the ``typst`` Python package is not installed.
"""
try:
import typst as typst_compiler
except ImportError as err:
raise ImportError(
"TypstMobject requires the 'typst' Python package. "
"Install it with: pip install typst>=0.14"
) from err
full_source = TYPST_TEMPLATE.format(
text_size=text_size,
preamble=preamble,
body=typst_code,
)
content_hash = _typst_hash(full_source)
typst_dir = config.get_dir("tex_dir")
typst_dir.mkdir(parents=True, exist_ok=True)
typ_file = typst_dir / f"{content_hash}.typ"
svg_file = typst_dir / f"{content_hash}.svg"
if svg_file.exists():
return svg_file
typ_file.write_text(full_source, encoding="utf-8")
logger.info(
"Compiling Typst source %(path)s ...",
{"path": f"{typ_file}"},
)
svg_bytes = typst_compiler.compile(
str(typ_file),
format="svg",
font_paths=font_paths or [],
)
svg_file.write_bytes(svg_bytes)
return svg_file

View file

@ -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
@ -85,9 +82,6 @@ ignore_errors = True
[mypy-manim.mobject.logo]
ignore_errors = True
[mypy-manim.mobject.mobject]
ignore_errors = True
[mypy-manim.mobject.opengl.opengl_point_cloud_mobject]
ignore_errors = True
@ -100,6 +94,9 @@ ignore_errors = True
[mypy-manim.mobject.table]
ignore_errors = True
[mypy-manim.mobject.types.point_cloud_mobject]
ignore_errors = True
[mypy-manim.mobject.types.vectorized_mobject]
ignore_errors = True

View file

@ -1,6 +1,6 @@
[project]
name = "manim"
version = "0.20.0"
version = "0.20.1"
description = "Animation engine for explanatory math videos."
authors = [
{name = "The Manim Community Developers", email = "contact@manim.community"},
@ -62,7 +62,7 @@ documentation = "https://docs.manim.community/"
homepage = "https://www.manim.community/"
"Bug Tracker" = "https://github.com/ManimCommunity/manim/issues"
"Changelog" = "https://docs.manim.community/en/stable/changelog.html"
"X / Twitter" = "https://x.com/manim_community"
"X / Twitter" = "https://x.com/manimcommunity"
"Bluesky" = "https://bsky.app/profile/manim.community"
"Discord" = "https://www.manim.community/discord/"
@ -74,6 +74,9 @@ jupyterlab = [
"jupyterlab>=4.3.4",
"notebook>=7.3.2",
]
typst = [
"typst>=0.14",
]
[dependency-groups]
dev = [

View file

@ -5,14 +5,16 @@ from unittest.mock import MagicMock
import pytest
from manim.animation.animation import Animation, Wait
from manim.animation.composition import AnimationGroup, Succession
from manim.animation.composition import AnimationGroup, LaggedStartMap, Succession
from manim.animation.creation import Create, Write
from manim.animation.fading import FadeIn, FadeOut
from manim.constants import DOWN, UP
from manim.mobject.geometry.arc import Circle
from manim.mobject.geometry.line import Line
from manim.mobject.geometry.polygram import RegularPolygon, Square
from manim.mobject.types.vectorized_mobject import VGroup
from manim.scene.scene import Scene
from manim.utils.rate_functions import linear, there_and_back
def test_succession_timing():
@ -189,6 +191,23 @@ def test_animationgroup_calls_finish():
assert circ_animation.finished
def test_laggedstartmap_only_passes_kwargs_to_subanimations():
mobject = VGroup(Square(), Circle())
animation = LaggedStartMap(
FadeIn,
mobject,
rate_func=there_and_back,
lag_ratio=0.3,
)
assert animation.rate_func is linear
assert animation.lag_ratio == 0.3
assert all(
subanimation.rate_func is there_and_back
for subanimation in animation.animations
)
def test_empty_animation_group_fails():
with pytest.raises(ValueError, match="Please add at least one subanimation."):
AnimationGroup().begin()

View file

@ -129,4 +129,6 @@ def test_start_and_end_at_same_point():
line = DashedLine(np.zeros(3), np.zeros(3))
line.put_start_and_end_on(np.zeros(3), np.array([0, 0, 0]))
np.testing.assert_array_equal(np.round(np.zeros(3), 4), np.round(line.points, 4))
np.testing.assert_array_equal(
np.round(line.points, 4), np.round(np.zeros((4, 3)), 4)
)

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

@ -20,13 +20,122 @@ def test_SingleStringMathTex(config):
@pytest.mark.parametrize( # : PT006
("text_input", "length_sub"),
[("{{ a }} + {{ b }} = {{ c }}", 5), (r"\frac{1}{a+b\sqrt{2}}", 1)],
[
("{{ a }} + {{ b }} = {{ c }}", 5),
(r"\frac{1}{a+b\sqrt{2}}", 1),
# Regression test for https://github.com/ManimCommunity/manim/issues/4601:
# a string whose only }} comes from closing two nested LaTeX brace groups
# (not from the {{ }} notation) must not be split.
(r"\\+\int_{0}^{\frac{Mq}{M+m}}", 1),
],
)
def test_double_braces_testing(text_input, length_sub):
t1 = MathTex(text_input)
assert len(t1.submobjects) == length_sub
# ---------------------------------------------------------------------------
# Unit tests for MathTex._split_double_braces — no LaTeX compilation needed.
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
("tex_string", "expected_segments"),
[
# ---- intended notation ----
# Basic split: each {{ }} group and the text between become segments.
(
"{{ a }} + {{ b }}",
["", " a ", " + ", " b ", ""],
),
# {{ }} at the very start of the string (no preceding character).
(
"{{x}}",
["", "x", ""],
),
# Content with arbitrarily nested LaTeX braces: inner }} must NOT close
# the Manim group early.
(
r"{{ a^{b^{c}} }}",
["", r" a^{b^{c}} ", ""],
),
# \frac inside a Manim group — the }} from {1}{a} are at inner_depth > 0.
(
r"{{ \frac{1}{a} }}",
["", r" \frac{1}{a} ", ""],
),
# ---- false-positive guards: {{ not preceded by whitespace ----
# \text{{word}}: {{ preceded by {, must not split.
(
r"\text{{word}}",
[r"\text{{word}}"],
),
# ^{{\alpha}}: {{ preceded by {, must not split.
(
r"^{{\alpha}}",
[r"^{{\alpha}}"],
),
# +{{a}}: {{ preceded by non-whitespace, must not split.
(
r"+{{a}}",
[r"+{{a}}"],
),
# ---- bug case: }} without any {{ must not split ----
(
r"\\+\int_{0}^{\frac{Mq}{M+m}}",
[r"\\+\int_{0}^{\frac{Mq}{M+m}}"],
),
# ---- backslash escape handling ----
# \}} — \} consumed as unit, remaining } is a lone close, not }}.
(
r"\}}",
[r"\}}"],
),
# \\}} — \\ consumed as unit, leaving real }} which is not inside any
# Manim group so it passes through unchanged.
(
r"\\}}",
[r"\\}}"],
),
# \\\}} — \\ consumed, then \} consumed; lone } passes through.
(
r"\\\}}",
[r"\\\}}"],
),
# \\\\}} — two \\ consumed; lone }} passes through (no Manim group open).
(
r"\\\\}}",
[r"\\\\}}"],
),
# Same backslash cases *inside* a Manim group.
# The escape sequence is placed right before the Manim }} close.
#
# {{ a \}}} — \} consumed as escaped brace (content), }} closes the group.
(
r"{{ a \}}}",
["", r" a \}", ""],
),
# {{ a \\}} — \\ consumed as escaped backslash (content), }} closes.
(
r"{{ a \\}}",
["", r" a \\", ""],
),
# {{ a \\\}}} — \\ then \} consumed (content), }} closes.
(
r"{{ a \\\}}}",
["", r" a \\\}", ""],
),
# {{ a \\\\}} — \\ then \\ consumed (content), }} closes.
(
r"{{ a \\\\}}",
["", r" a \\\\", ""],
),
],
)
def test_split_double_braces(tex_string, expected_segments):
assert MathTex._split_double_braces(tex_string) == expected_segments
def test_tex(config):
Tex("The horse does not eat cucumber salad.")
assert Path(config.media_dir, "Tex", "5384b41741a246bd.svg").exists()

View file

@ -0,0 +1,339 @@
from __future__ import annotations
import numpy as np
import pytest
from manim import (
RIGHT,
Label,
MathTex,
NumberLine,
Tex,
Typst,
TypstMath,
Vector,
VectorScene,
)
def test_Typst(config):
"""Basic Typst creation produces an SVG file."""
m = Typst(r"$ x^2 $")
assert m.height > 0
assert m.width > 0
assert len(m.submobjects) > 0
def test_TypstMath(config):
"""TypstMath wraps the expression in math delimiters."""
m = TypstMath(r"alpha + beta")
assert m.typst_code == "$ alpha + beta $"
assert m.height > 0
def test_typst_default_font_size(config):
"""Default font_size is 48 (DEFAULT_FONT_SIZE)."""
m = Typst(r"$ a + b $")
assert np.isclose(m.font_size, 48)
def test_typst_custom_font_size(config):
"""Passing a custom font_size scales the mobject accordingly."""
m = Typst(r"$ a + b $", font_size=72)
assert np.isclose(m.font_size, 72)
def test_typst_font_size_property_setter(config):
"""Setting font_size after creation rescales correctly."""
m = Typst(r"$ a + b $")
original_height = m.height
m.font_size = 96
assert np.isclose(m.font_size, 96)
assert m.height > original_height
def test_typst_font_size_scaling_also_scales_svg_strokes(config):
"""Typst-authored stroke widths scale together with font_size."""
m = TypstMath("frac(a,b)", font_size=48, use_svg_cache=False)
original_stroke_width = max(submobject.stroke_width for submobject in m.submobjects)
m.font_size = 96
scaled_stroke_width = max(submobject.stroke_width for submobject in m.submobjects)
assert np.isclose(scaled_stroke_width, 2 * original_stroke_width)
def test_typst_font_size_error(config):
"""Setting font_size to a non-positive value raises ValueError."""
m = Typst(r"$ a + b $")
with pytest.raises(ValueError, match="font_size must be greater than 0"):
m.font_size = -1
def test_typst_caching(config):
"""Compiling the same source twice uses the cached SVG."""
m1 = Typst(r"$ e^{i pi} + 1 = 0 $")
m2 = Typst(r"$ e^{i pi} + 1 = 0 $")
assert np.isclose(m1.height, m2.height)
assert np.isclose(m1.width, m2.width)
def test_typst_preamble(config):
"""A custom preamble is accepted without error."""
m = Typst(
r"$ x^2 $",
typst_preamble='#set text(font: "New Computer Modern")',
)
assert m.height > 0
def test_typst_repr(config):
"""__repr__ includes the Typst source."""
m = Typst("hello")
assert repr(m) == "Typst('hello')"
m2 = TypstMath("x")
assert repr(m2) == "TypstMath('$ x $')"
def test_typst_text_rendering(config):
"""Non-math Typst markup renders correctly."""
m = Typst(r"*Bold* and _italic_")
assert m.height > 0
assert len(m.submobjects) > 0
def test_typst_preserves_svg_stroke_widths_by_default(config):
"""Default stroke_width=None preserves Typst-authored SVG strokes."""
m = Typst("#underline[abc]", use_svg_cache=False)
assert any(submobject.stroke_width > 0 for submobject in m.submobjects)
def test_typst_baseline_frames_empty_without_tracking(config):
"""Baseline frames are only collected when requested."""
m = Typst("Ggf", use_svg_cache=False)
assert m.baseline_frames == []
def test_typst_baseline_frames_track_scene_positions(config):
"""Tracked baseline frames follow ordinary affine transformations."""
m = Typst("Ggf", track_baselines=True, use_svg_cache=False)
assert m.baseline_frames
orig, right, up = m.baseline_frames[0]
delta = 2 * RIGHT
m.shift(delta)
shifted_orig, shifted_right, shifted_up = m.baseline_frames[0]
assert np.allclose(shifted_orig - orig, delta)
assert np.allclose(shifted_right - right, delta)
assert np.allclose(shifted_up - up, delta)
def test_typst_text_font_size_matches_tex_closely(config):
"""Typst text is calibrated close to Tex for the same font_size."""
tex = Tex("Hello", font_size=48)
typst = Typst("Hello", font_size=48, use_svg_cache=False)
assert np.isclose(typst.height, tex.height, rtol=0.02)
assert np.isclose(typst.width, tex.width, rtol=0.02)
def test_typstmath_font_size_matches_mathtex_closely(config):
"""Typst math is calibrated close to MathTex for the same font_size."""
mathtex = MathTex(r"\frac{a}{b}", font_size=48)
typstmath = TypstMath("frac(a,b)", font_size=48, use_svg_cache=False)
assert np.isclose(typstmath.height, mathtex.height, rtol=0.02)
assert np.isclose(typstmath.width, mathtex.width, rtol=0.02)
# -- data-typst-label → id mapping tests ------------------------------------
MANIMGRP_PREAMBLE = "#let manimgrp(lbl, body) = [#box(body) #label(lbl)]"
def test_typst_labels_mapped_to_vgroups(config):
"""data-typst-label attributes are promoted to id and appear in id_to_vgroup_dict."""
m = Typst(
'$ #manimgrp("numerator", $a + b$) / #manimgrp("denom", $c - d$) $',
typst_preamble=MANIMGRP_PREAMBLE,
use_svg_cache=False,
)
assert "numerator" in m.id_to_vgroup_dict
assert "denom" in m.id_to_vgroup_dict
# a, +, b → 3 submobjects; c, -, d → 3 submobjects
assert len(m.id_to_vgroup_dict["numerator"]) == 3
assert len(m.id_to_vgroup_dict["denom"]) == 3
def test_typst_nested_labels(config):
"""Nested labeled boxes produce nested VGroups without cross-contamination."""
m = Typst(
'$ #manimgrp("outer", $#manimgrp("inner", $a$) + b$) $',
typst_preamble=MANIMGRP_PREAMBLE,
use_svg_cache=False,
)
assert "outer" in m.id_to_vgroup_dict
assert "inner" in m.id_to_vgroup_dict
# "inner" contains only "a" (1 submobject)
assert len(m.id_to_vgroup_dict["inner"]) == 1
# "outer" contains everything: a, +, b (3 submobjects)
assert len(m.id_to_vgroup_dict["outer"]) == 3
# The inner submobject is a subset of the outer one
inner_mob = m.id_to_vgroup_dict["inner"][0]
assert inner_mob in m.id_to_vgroup_dict["outer"]
def test_typst_no_labels_no_extra_keys(config):
"""Without labeled boxes, no extra label keys appear."""
m = Typst(r"$ a + b $", use_svg_cache=False)
label_keys = [
k
for k in m.id_to_vgroup_dict
if not k.startswith(("numbered_group", "root", "g"))
]
assert label_keys == []
def test_typst_select(config):
"""select() returns the correct VGroup for a given label."""
m = Typst(
'$ #manimgrp("lhs", $a + b$) = #manimgrp("rhs", $c$) $',
typst_preamble=MANIMGRP_PREAMBLE,
use_svg_cache=False,
)
lhs = m.select("lhs")
rhs = m.select("rhs")
assert len(lhs) == 3 # a, +, b
assert len(rhs) == 1 # c
def test_typst_select_collects_duplicate_labels(config):
"""Repeated Typst labels are combined into one selectable group."""
m = Typst(
'$ #manimgrp("picked", $a$) + #manimgrp("picked", $b$) $',
typst_preamble=MANIMGRP_PREAMBLE,
use_svg_cache=False,
)
picked = m.select("picked")
assert len(picked) == 2
def test_typst_get_baseline_frame_for_selected_submobjects(config):
"""Tracked frames can be queried for submobjects returned by select()."""
m = Typst(
'$ #manimgrp("lhs", $a + b$) = c $',
typst_preamble=MANIMGRP_PREAMBLE,
track_baselines=True,
use_svg_cache=False,
)
lhs = m.select("lhs")
frames = [m.get_baseline_frame(submobject) for submobject in lhs]
assert len(frames) == len(lhs)
for orig, right, up in frames:
assert orig.shape == (3,)
assert right.shape == (3,)
assert up.shape == (3,)
def test_typst_select_keyerror(config):
"""select() raises KeyError for a nonexistent label."""
m = Typst(r"$ a + b $", use_svg_cache=False)
with pytest.raises(KeyError, match="No group with label 'missing'"):
m.select("missing")
def test_typst_select_keyerror_lists_labels_starting_with_g(config):
"""Error messages keep user labels even when they start with ``g``."""
m = Typst(
'$ #manimgrp("gamma", $a$) $',
typst_preamble=MANIMGRP_PREAMBLE,
use_svg_cache=False,
)
with pytest.raises(KeyError, match="gamma"):
m.select("missing")
# -- {{ }} double-brace preprocessor tests ----------------------------------
def test_typstmath_double_brace_auto_numbered(config):
"""{{ }} groups are auto-numbered and selectable by index."""
eq = TypstMath("{{ a + b }} / {{ c - d }} = {{ x }}", use_svg_cache=False)
assert eq._group_labels == ["_grp-0", "_grp-1", "_grp-2"]
assert len(eq.select(0)) == 3 # a, +, b
assert len(eq.select(1)) == 3 # c, -, d
assert len(eq.select(2)) == 1 # x
def test_typstmath_double_brace_named(config):
"""{{ content : label }} assigns an explicit label."""
eq = TypstMath("{{ a + b : numerator }} / {{ c - d : denom }}", use_svg_cache=False)
assert "numerator" in eq._group_labels
assert "denom" in eq._group_labels
assert len(eq.select("numerator")) == 3
assert len(eq.select("denom")) == 3
def test_typstmath_double_brace_mixed_named_auto(config):
"""Named and auto-numbered groups can coexist."""
eq = TypstMath("{{ a : lhs }} = {{ b }}", use_svg_cache=False)
assert eq._group_labels == ["lhs", "_grp-0"]
assert len(eq.select("lhs")) == 1
assert len(eq.select(0)) == 1
def test_typstmath_no_braces_no_preamble(config):
"""Without {{ }}, the manimgrp preamble is not injected."""
eq = TypstMath("a + b", use_svg_cache=False)
assert eq._group_labels == []
assert "manimgrp" not in eq.typst_preamble
def test_typstmath_select_index_error(config):
"""select(int) raises IndexError for out-of-range index."""
eq = TypstMath("{{ a }}", use_svg_cache=False)
with pytest.raises(IndexError, match="out of range"):
eq.select(1)
def test_typstmath_preprocessor_skips_strings():
"""{{ }} inside string literals are not processed."""
processed, labels = TypstMath._preprocess_groups('x =_("{{ not a group }}") z')
assert labels == []
assert "manimgrp" not in processed
def test_typstmath_preprocessor_skips_content_blocks():
"""{{ }} inside [...] content blocks are not processed."""
processed, labels = TypstMath._preprocess_groups("[text {{ here }}] {{ real }}")
assert labels == ["_grp-0"]
assert processed.count("manimgrp") == 1
# -- integration tests for existing APIs ------------------------------------
def test_label_accepts_typst(config):
"""Label accepts a prebuilt Typst mobject."""
rendered = Typst("hello", use_svg_cache=False)
label = Label(rendered)
assert label.rendered_label is rendered
def test_numberline_add_labels_with_typstmath_constructor_uses_typst(config):
"""String labels use Typst text mode when label_constructor is TypstMath."""
number_line = NumberLine(x_range=[-1, 1])
number_line.add_labels({0: "origin"}, label_constructor=TypstMath)
assert len(number_line.labels) == 1
assert isinstance(number_line.labels[0], Typst)
assert not isinstance(number_line.labels[0], TypstMath)
def test_vector_scene_get_vector_label_accepts_typst(config):
"""VectorScene accepts a prebuilt Typst label mobject."""
scene = VectorScene()
vector = Vector(RIGHT)
label = Typst("v", use_svg_cache=False)
returned = scene.get_vector_label(vector, label)
assert returned is label

View file

@ -1,4 +1,4 @@
from manim import ORIGIN, UR, Arrow, DashedVMobject, VGroup
from manim import ORIGIN, UR, Arrow, DashedLine, DashedVMobject, VGroup
from manim.mobject.geometry.tips import ArrowTip, StealthTip
@ -41,3 +41,19 @@ def test_dashed_arrow_with_start_tip_has_two_tips():
tips = _collect_tips(dashed)
assert len(tips) == 2
def test_zero_length_dashed_line_submobjects_have_2d_points():
"""Submobjects of a zero-length DashedLine must have 2-D point arrays."""
line = DashedLine(ORIGIN, ORIGIN)
for sub in line.submobjects:
assert sub.points.ndim == 2, (
f"Expected 2-D points array, got shape {sub.points.shape}"
)
def test_become_nonzero_to_zero_dashed_line_does_not_crash():
"""become() from a normal DashedLine to a zero-length one should not crash."""
normal = DashedLine(ORIGIN, 2 * UR)
zero = DashedLine(ORIGIN, ORIGIN)
normal.become(zero)

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

View file

@ -174,6 +174,27 @@ def test_ZIndex(scene):
scene.play(ApplyMethod(triangle.shift, 2 * UP))
@frames_comparison(last_frame=False)
def test_negative_z_index_AnimationGroup(scene):
# https://github.com/ManimCommunity/manim/issues/3334
s = Square().set_z_index(-1)
scene.play(AnimationGroup(GrowFromCenter(s)))
@frames_comparison(last_frame=False)
def test_negative_z_index_LaggedStart(scene):
# https://github.com/ManimCommunity/manim/issues/3914
line_1 = Line(LEFT, RIGHT, color=BLUE)
line_2 = Line(UP + LEFT, UP + RIGHT, color=RED).set_z_index(-1)
scene.play(LaggedStart(FadeIn(line_1), FadeIn(line_2), lag_ratio=0.5))
@frames_comparison(last_frame=False)
def test_nested_animation_groups_with_negative_z_index(scene):
line = Line(LEFT, RIGHT, color=BLUE).set_z_index(-1)
scene.play(AnimationGroup(AnimationGroup(AnimationGroup(FadeIn(line)))))
@frames_comparison
def test_Angle(scene):
l1 = Line(ORIGIN, RIGHT)

284
uv.lock generated
View file

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