mirror of
https://github.com/ManimCommunity/manim.git
synced 2026-06-22 10:01:47 +00:00
Compare commits
55 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e83f4b09a |
||
|
|
f7fd708276 |
||
|
|
4c4622df54 |
||
|
|
66d5a4937a |
||
|
|
c07137dbed |
||
|
|
71ab85f960 |
||
|
|
16f0a3de3e |
||
|
|
537a134360 |
||
|
|
b7bf2ea90f |
||
|
|
bdaf4497b7 |
||
|
|
037b376ec2 |
||
|
|
e9639c2697 |
||
|
|
1b2d5ce72b |
||
|
|
c94a7ea9fc |
||
|
|
bb1be6ef8a |
||
|
|
516c8c8ba7 |
||
|
|
ccee37a614 |
||
|
|
31db147222 |
||
|
|
34124c3f60 |
||
|
|
d999d422c9 |
||
|
|
561de9d72a |
||
|
|
05b3042ab0 |
||
|
|
2ece488b2c |
||
|
|
852ebd1c60 |
||
|
|
82522795f1 |
||
|
|
12c5640a32 |
||
|
|
33424fe43d |
||
|
|
1b3390073c |
||
|
|
56f7eb2a1f |
||
|
|
cfb5c684b7 |
||
|
|
429f25328d |
||
|
|
c45724989d |
||
|
|
af70b6fef2 |
||
|
|
4b32312dd1 |
||
|
|
90141df105 |
||
|
|
82f93b6c3c |
||
|
|
752b46a003 |
||
|
|
21cf9998cc |
||
|
|
ebb230f6f1 |
||
|
|
46177d247e |
||
|
|
468929889b |
||
|
|
98c458b6b2 |
||
|
|
d4af5b2baa |
||
|
|
1157b746c3 |
||
|
|
6f825e8513 |
||
|
|
a0414dccec |
||
|
|
33a0e56d73 |
||
|
|
80fd11efbc |
||
|
|
498f0b9c89 |
||
|
|
87cd63549c |
||
|
|
cd370610c5 |
||
|
|
a6af7f3d76 |
||
|
|
e34e707858 |
||
|
|
7c1c9258d0 |
||
|
|
000e7792bd |
76 changed files with 3580 additions and 694 deletions
|
|
@ -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/
|
||||
|
|
|
|||
10
.github/workflows/ci.yml
vendored
10
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
16
.github/workflows/publish-docker.yml
vendored
16
.github/workflows/publish-docker.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
38
.github/workflows/python-publish.yml
vendored
38
.github/workflows/python-publish.yml
vendored
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
...
|
||||
|
|
|
|||
|
|
@ -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
382
agents/typst_selector.md
Normal file
|
|
@ -0,0 +1,382 @@
|
|||
# Design: Sub-Expression Selection for `Typst` / `TypstMath`
|
||||
|
||||
## Problem Statement
|
||||
|
||||
Users need to interact with individual parts of a Typst-rendered expression:
|
||||
color a variable, animate the numerator of a fraction, morph one sub-expression
|
||||
into another, etc. The `MathTex` class solves this with:
|
||||
|
||||
1. **`{{ ... }}` double-brace notation** — splits the TeX string into named
|
||||
submobject groups at compile time.
|
||||
2. **`substrings_to_isolate` / `get_part_by_tex`** — identifies submobjects
|
||||
whose TeX source matches a given string.
|
||||
|
||||
Both mechanisms ultimately rely on injecting `\special{dvisvgm:raw <g id='...'>}`
|
||||
markers into the LaTeX source so that the resulting SVG contains `<g>` elements
|
||||
with known `id` attributes, which SVGMobject's parser maps to `VGroup`
|
||||
sub-trees via `id_to_vgroup_dict`.
|
||||
|
||||
We need an analogous mechanism for Typst.
|
||||
|
||||
## Key Discovery: `data-typst-label` in SVG Output
|
||||
|
||||
Typst's SVG renderer (`typst-svg` crate) already emits a `data-typst-label`
|
||||
attribute on `<g>` elements whenever a `GroupItem` (hard frame) carries a
|
||||
label. The relevant code path:
|
||||
|
||||
```rust
|
||||
// typst-svg/src/lib.rs — render_group()
|
||||
if let Some(label) = group.label {
|
||||
svg.init().attr("data-typst-label", label.resolve());
|
||||
}
|
||||
```
|
||||
|
||||
A **hard frame** is created by the `box` element (and `block`, etc.). Crucially,
|
||||
`box` can be used inline inside math mode, and labels can be attached to it.
|
||||
|
||||
### Proof of Concept
|
||||
|
||||
The following Typst helper wraps content in a labeled `box`:
|
||||
|
||||
```typst
|
||||
#let grp(lbl, body) = [#box(body) #label(lbl)]
|
||||
```
|
||||
|
||||
When used in math:
|
||||
|
||||
```typst
|
||||
$ #grp("numerator", $a + b$) / #grp("denom", $c - d$) = #grp("result", $x$) $
|
||||
```
|
||||
|
||||
The compiled SVG contains:
|
||||
|
||||
```xml
|
||||
<g class="typst-group" ... data-typst-label="numerator">
|
||||
<!-- glyphs for a + b -->
|
||||
</g>
|
||||
<g class="typst-group" ... data-typst-label="denom">
|
||||
<!-- glyphs for c - d -->
|
||||
</g>
|
||||
<g class="typst-group" ... data-typst-label="result">
|
||||
<!-- glyph for x -->
|
||||
</g>
|
||||
```
|
||||
|
||||
**Nesting works.** A `grp` wrapping a fraction that itself contains `grp`-ed
|
||||
sub-parts produces nested `data-typst-label` groups:
|
||||
|
||||
```typst
|
||||
$ #grp("whole-frac", $frac(#grp("numerator", $a + b$), #grp("denom", $c - d$))$) $
|
||||
```
|
||||
|
||||
SVG output:
|
||||
|
||||
```xml
|
||||
<g ... data-typst-label="whole-frac">
|
||||
<g ... data-typst-label="numerator"> ... </g>
|
||||
<g ... data-typst-label="denom"> ... </g>
|
||||
<path class="typst-shape" ... /> <!-- fraction bar -->
|
||||
</g>
|
||||
```
|
||||
|
||||
### SVG Parser Compatibility
|
||||
|
||||
Manim uses `svgelements` to parse SVGs. The library preserves
|
||||
`data-typst-label` in the `values` dictionary of `Group` objects, and it
|
||||
propagates to child elements. Manim's `SVGMobject.get_mobjects_from()` already
|
||||
iterates over groups and builds `id_to_vgroup_dict` keyed by the `id` attribute.
|
||||
Extending this to also key by `data-typst-label` is straightforward.
|
||||
|
||||
## Proposed Interface
|
||||
|
||||
### 1. Explicit Groups via `{{ ... }}` Notation (Compile-Time)
|
||||
|
||||
Mirror the `MathTex` double-brace convention. Users write:
|
||||
|
||||
```python
|
||||
eq = TypstMath("{{ a + b }} / {{ c - d }} = {{ x }}")
|
||||
```
|
||||
|
||||
The pre-processor splits on `{{ ... }}` (reusing the same whitespace-guard
|
||||
rules as `MathTex._split_double_braces`) and wraps each group in a labeled
|
||||
`box` call:
|
||||
|
||||
```typst
|
||||
$ #box[$a + b$] <_grp-0> / #box[$c - d$] <_grp-1> = #box[$x$] <_grp-2> $
|
||||
```
|
||||
|
||||
Each group gets an auto-generated label (`_grp-0`, `_grp-1`, ...).
|
||||
The `data-typst-label` attributes then appear in the SVG, and
|
||||
`SVGMobject.get_mobjects_from()` can map them to `VGroup` entries in
|
||||
`label_to_vgroup_dict` (or reuse `id_to_vgroup_dict`).
|
||||
|
||||
These groups become sub-mobjects of the `TypstMath` instance, accessible by
|
||||
index:
|
||||
|
||||
```python
|
||||
eq[0] # VGroup for "a + b"
|
||||
eq[1] # VGroup for "c - d"
|
||||
eq[2] # VGroup for "x"
|
||||
```
|
||||
|
||||
(Non-group content between groups — like `/` and `=` — also becomes
|
||||
its own submobject, mirroring `MathTex` behavior.)
|
||||
|
||||
**For `Typst` (text mode):** the same `{{ ... }}` notation applies, but the
|
||||
wrapper is `#box[...]` without math delimiters.
|
||||
|
||||
### 2. Named Groups via Labels
|
||||
|
||||
Users can also assign explicit label names for retrieval by name:
|
||||
|
||||
```python
|
||||
eq = Typst(
|
||||
r"$ #box[$a + b$] <numerator> / #box[$c - d$] <denom> $"
|
||||
)
|
||||
eq.select("numerator").set_color(RED)
|
||||
eq.select("denom").set_color(BLUE)
|
||||
```
|
||||
|
||||
Alternatively, an even more ergonomic approach that hides the `box` boilerplate
|
||||
and uses the `{{ ... : label }}` notation:
|
||||
|
||||
```python
|
||||
eq = TypstMath("{{ a + b : numerator }} / {{ c - d : denom }}")
|
||||
eq.select("numerator").set_color(RED)
|
||||
```
|
||||
|
||||
Here the pre-processor recognizes `{{ content : label }}` and emits
|
||||
`#box[$content$] <label>` in the Typst source.
|
||||
|
||||
### 3. The `.select()` Method
|
||||
|
||||
```python
|
||||
def select(self, key: str | int) -> VGroup:
|
||||
"""Select a labeled sub-expression.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
key
|
||||
Either a label name (string) matching a ``data-typst-label``
|
||||
in the SVG, or an integer index into the auto-numbered
|
||||
``{{ ... }}`` groups.
|
||||
|
||||
Returns
|
||||
-------
|
||||
VGroup
|
||||
The sub-mobjects corresponding to the selected group.
|
||||
|
||||
Raises
|
||||
------
|
||||
KeyError
|
||||
If no group with the given label/index exists.
|
||||
"""
|
||||
```
|
||||
|
||||
This returns a `VGroup` containing exactly the submobjects (paths) that
|
||||
were rendered inside the corresponding `<g data-typst-label="...">` in the SVG.
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Step 1: Extend `SVGMobject.get_mobjects_from()` to Track Labels
|
||||
|
||||
In `manim/mobject/svg/svg_mobject.py`, the group-walking loop already checks
|
||||
for `id` attributes. Add a parallel check for `data-typst-label`:
|
||||
|
||||
```python
|
||||
try:
|
||||
group_name = str(element.values["id"])
|
||||
except Exception:
|
||||
# Fall back to data-typst-label if available
|
||||
label = element.values.get("data-typst-label")
|
||||
if label:
|
||||
group_name = f"typst-label:{label}"
|
||||
else:
|
||||
group_name = f"numbered_group_{group_id_number}"
|
||||
group_id_number += 1
|
||||
```
|
||||
|
||||
This automatically populates `id_to_vgroup_dict` with label-keyed entries.
|
||||
|
||||
### Step 2: Pre-Processing `{{ ... }}` in Typst Source
|
||||
|
||||
Add a `_split_and_label_groups()` method that:
|
||||
|
||||
1. Scans the input for `{{ ... }}` or `{{ ... : label }}` patterns
|
||||
(using the same whitespace-guard rules as `MathTex._split_double_braces`).
|
||||
2. Replaces each group with `#box[$content$] <label>` (math mode) or
|
||||
`#box[content] <label>` (text mode).
|
||||
3. Records the mapping from label → original source string for later lookup.
|
||||
|
||||
### Step 3: `Typst.select()` / Index Access
|
||||
|
||||
- Store the ordered list of group labels and their source strings.
|
||||
- `select(label_or_index)` looks up the corresponding `VGroup` from
|
||||
`id_to_vgroup_dict` (using the `typst-label:...` key).
|
||||
- `__getitem__(int)` returns the *n*-th group's `VGroup`.
|
||||
|
||||
### Step 4: Compatibility with `TransformMatchingTex` (future)
|
||||
|
||||
`TransformMatchingTex` (and its successor `TransformMatchingShapes`) works by
|
||||
matching submobjects between two `MathTex` instances by their TeX string keys.
|
||||
The same approach extends to `Typst` if each `{{ ... }}` group carries its
|
||||
original source string as metadata. A `TransformMatchingTypst` animation could
|
||||
match groups by label name or by source string equality.
|
||||
|
||||
## Open Design Questions
|
||||
|
||||
### Q1: Context-Aware Wrapping — Math vs. Text Mode
|
||||
|
||||
The `box` + `label` mechanism works identically in math and text mode, but the
|
||||
**wrapping** of group content must match the surrounding context:
|
||||
|
||||
- **In text mode:** `{{ Hello : greeting }}` → `#box[Hello] <greeting>`
|
||||
- **In math mode:** `{{ y^2 : second }}` → `#box[$y^2$] <second>`
|
||||
|
||||
Getting this wrong is not a silent error — it produces visually broken output.
|
||||
Wrapping math content with `#box[y^2]` (no `$...$`) renders `y^2` as literal
|
||||
text in the body font instead of as a math superscript.
|
||||
|
||||
This is a real problem for `Typst()`, where a single source string can mix text
|
||||
and math freely:
|
||||
|
||||
```python
|
||||
Typst("hello world, here is a formula: $x^2 + {{ y^2 : second }} = z^2$")
|
||||
```
|
||||
|
||||
Here `{{ y^2 : second }}` is inside a `$ ... $` block, so it needs the
|
||||
math-mode wrapper, but the pre-processor has no way to know this unless it
|
||||
tracks `$` delimiters.
|
||||
|
||||
### The `#` prefix problem and math calls
|
||||
|
||||
A natural idea is to translate `{{ content }}` into a Typst function call like
|
||||
`grp("lbl", content)`. However, this has a subtle but critical context
|
||||
sensitivity: Typst has two different call conventions depending on context:
|
||||
|
||||
- **Math call** (no `#` prefix): `$ grp("lbl", a^2 + b) $` — arguments are
|
||||
parsed **in math mode**. The content `a^2 + b` is math. ✓
|
||||
- **Code call** (`#` prefix): `$ #grp("lbl", a^2 + b) $` — arguments are
|
||||
parsed **in code mode**. `a^2` is a syntax error in code! ✗
|
||||
|
||||
So in math mode, the function MUST be called without `#` for args to stay in
|
||||
math mode. In text/markup mode, the function MUST be called WITH `#` (that's
|
||||
how you invoke code from markup), and content arguments need `[...]` wrapping:
|
||||
|
||||
```typst
|
||||
// Text context: #grp("lbl", [Hello world])
|
||||
// Math context: grp("lbl", a^2 + b)
|
||||
```
|
||||
|
||||
The function definition is the same either way:
|
||||
```typst
|
||||
#let grp(lbl, body) = [#box(body) #label(lbl)]
|
||||
```
|
||||
|
||||
This means the function call approach has **exactly the same context problem**
|
||||
as the raw `#box` approach: the pre-processor must know whether it's in math or
|
||||
text to emit the right calling convention.
|
||||
|
||||
### Further complication: string literals and content blocks
|
||||
|
||||
Even inside `TypstMath` (where everything is math), the scanner must avoid
|
||||
`{{ }}` matches inside string literals or content blocks:
|
||||
|
||||
```python
|
||||
TypstMath('x^2 + y^2 =_("Hello {{ world }}") z^2')
|
||||
```
|
||||
|
||||
Here `{{ world }}` is inside a `"..."` string literal — it should NOT be
|
||||
processed. Similarly, content blocks `[...]` inside math switch back to text
|
||||
mode.
|
||||
|
||||
### Options
|
||||
|
||||
**A. `TypstMath`: math calls with simple string-aware scanning.**
|
||||
For `TypstMath`, the entire body is math, so `{{ content }}` always becomes
|
||||
`grp("_grp-N", content)` (no `#`, no `$...$`). The scanner just needs to
|
||||
skip `"..."` string literals and `[...]` content blocks — no `$` tracking
|
||||
needed. This is clean and robust.
|
||||
|
||||
**B. `Typst`: context-aware scanning (full parser).**
|
||||
For the general `Typst` class, the scanner must additionally track `$...$`
|
||||
math blocks (toggling a mode flag on unescaped `$`) to choose between
|
||||
`grp(...)` (in math) and `#grp("lbl", [...])` (in text). It must also handle
|
||||
string literals and content blocks inside math that switch context back. This
|
||||
is doable but non-trivial — essentially a mini Typst lexer.
|
||||
|
||||
**C. `Typst`: no `{{ }}`, manual groups only.**
|
||||
For the general `Typst` class, don't support `{{ }}` at all. Users write
|
||||
`grp(...)` / `#grp(...)` themselves (with the helper injected into the
|
||||
preamble). `{{ }}` is only available on `TypstMath`. This is simpler and
|
||||
avoids the parsing complexity, at the cost of ergonomics for mixed-mode
|
||||
documents.
|
||||
|
||||
**Recommendation:** Start with A (TypstMath only) and C (manual for Typst).
|
||||
Upgrade to B later if demand warrants it — the function call infrastructure
|
||||
is already in place, it's only the scanner that needs upgrading.
|
||||
|
||||
### Q2: What about "unlabeled" content between groups?
|
||||
|
||||
Like `MathTex`, the pieces of content *between* `{{ ... }}` groups should also
|
||||
become their own submobjects (auto-labeled with sequential indices). For
|
||||
example:
|
||||
|
||||
```python
|
||||
TypstMath("{{ a }} + {{ b }} = {{ c }}")
|
||||
# group-0: "a"
|
||||
# group-1: "+" (auto-group for inter-group content)
|
||||
# group-2: "b"
|
||||
# group-3: "=" (auto-group for inter-group content)
|
||||
# group-4: "c"
|
||||
```
|
||||
|
||||
Each segment (group or inter-group) gets wrapped in its own labeled `box`.
|
||||
|
||||
### Q3: What happens with `box` and baseline alignment?
|
||||
|
||||
`box` is an inline element in Typst, and when used inside math mode it
|
||||
participates in math layout. Testing confirms that fractions, superscripts, and
|
||||
other constructs render correctly when their children are `box`-wrapped.
|
||||
However, `box` creates a "hard frame" boundary which may subtly affect spacing
|
||||
in edge cases (e.g., math operator spacing around a boxed expression). This
|
||||
needs further testing; if issues arise, we could explore `block(breakable: false)`
|
||||
or invisible `rect` wrappers as alternatives.
|
||||
|
||||
### Q4: Can we avoid the `#grp(...)` / `#box[...] <label>` verbosity?
|
||||
|
||||
Yes — the `{{ ... }}` double-brace notation is purely syntactic sugar that gets
|
||||
pre-processed by Manim before the source reaches the Typst compiler. Users never
|
||||
need to write raw `#box` or `#label()` calls unless they want finer control.
|
||||
|
||||
### Q5: String-based selection without explicit groups?
|
||||
|
||||
A future enhancement could support:
|
||||
|
||||
```python
|
||||
eq = TypstMath(r"a + b = c")
|
||||
eq.select("a") # finds submobjects corresponding to the glyph "a"
|
||||
```
|
||||
|
||||
This is hard to do reliably because:
|
||||
- Typst SVGs embed glyphs as `<use xlink:href="#gXXX">` references; there's no
|
||||
text content in the SVG itself.
|
||||
- A single variable in Typst may span multiple glyphs (e.g., `"alpha"` → one
|
||||
glyph) or identical glyphs may appear multiple times.
|
||||
|
||||
A possible approach: at pre-processing time, wrap every "token" in the Typst
|
||||
math source in its own labeled `box`. This would require a Typst math tokenizer
|
||||
and is better suited for a v2 implementation.
|
||||
|
||||
## Summary: What Typst Gives Us
|
||||
|
||||
| Mechanism | How it works | SVG output |
|
||||
|---|---|---|
|
||||
| `#box(body) <label>` | Creates a hard-frame `GroupItem` with a `Label` | `<g data-typst-label="label">...</g>` |
|
||||
| `#metadata(val) <label>` | Invisible; queryable via `typst query` CLI | No visual output (useful for CLI queries, not SVG) |
|
||||
| Show rules on labels | `#show <label>: ...` | Transforms visual output but no automatic SVG grouping |
|
||||
| `context query(<label>)` | Document introspection (positions, counters) | In-document only; not available from Python |
|
||||
|
||||
The `box` + `label` mechanism is the **only** one that produces identifiable
|
||||
groups in the SVG output, making it the correct tool for sub-expression
|
||||
selection in Manim.
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -5,3 +5,4 @@ sphinx-copybutton
|
|||
sphinxext-opengraph
|
||||
sphinx-design
|
||||
sphinx-reredirects
|
||||
typst>=0.14
|
||||
|
|
|
|||
|
|
@ -1,2 +1,3 @@
|
|||
jupyterlab
|
||||
sphinxcontrib-programoutput
|
||||
typst>=0.14
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
41
docs/source/changelog/0.20.1-changelog.md
Normal file
41
docs/source/changelog/0.20.1-changelog.md
Normal 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)
|
||||
|
|
@ -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
|
||||
=======================================================
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
-----------------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
:::
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 *
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
[
|
||||
|
|
|
|||
818
manim/mobject/text/typst_mobject.py
Normal file
818
manim/mobject/text/typst_mobject.py
Normal file
|
|
@ -0,0 +1,818 @@
|
|||
"""Mobjects representing text rendered using Typst.
|
||||
|
||||
.. _typst-mobjects:
|
||||
|
||||
.. important::
|
||||
|
||||
The ``typst`` Python package must be installed to use these classes.
|
||||
Install it via ``pip install typst>=0.14`` or add the ``typst`` optional
|
||||
dependency group (``pip install manim[typst]``).
|
||||
|
||||
Typst mobjects compile Typst markup directly to SVG using the ``typst``
|
||||
Python package and then import the result through :class:`~.SVGMobject`.
|
||||
Use :class:`~.Typst` for general Typst markup and :class:`~.TypstMath`
|
||||
for display-style math.
|
||||
|
||||
Examples
|
||||
--------
|
||||
|
||||
Basic text and math
|
||||
^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
.. manim:: TypstTextReferenceExample
|
||||
:save_last_frame:
|
||||
:ref_classes: Typst
|
||||
|
||||
class TypstTextReferenceExample(Scene):
|
||||
def construct(self):
|
||||
text = Typst(
|
||||
r"*Hello* from _Typst!_",
|
||||
color=YELLOW,
|
||||
font_size=72,
|
||||
)
|
||||
self.add(text)
|
||||
|
||||
.. manim:: TypstMathReferenceExample
|
||||
:save_last_frame:
|
||||
:ref_classes: TypstMath
|
||||
|
||||
class TypstMathReferenceExample(Scene):
|
||||
def construct(self):
|
||||
equation = TypstMath(
|
||||
r"sum_(k=1)^n k = frac(n(n + 1), 2)",
|
||||
font_size=72,
|
||||
)
|
||||
self.add(equation)
|
||||
|
||||
Selecting subexpressions
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
Typst mobjects expose label-based selection via :meth:`~.Typst.select`.
|
||||
There are two common ways to create selectable groups:
|
||||
|
||||
- use ordinary Typst labels in :class:`~.Typst`
|
||||
- use Manim's ``{{ ... }}`` shorthand in :class:`~.TypstMath`
|
||||
|
||||
.. note::
|
||||
|
||||
The ``{{ ... }}`` shorthand is currently only supported by
|
||||
:class:`~.TypstMath`. For :class:`~.Typst`, create labels directly in the
|
||||
Typst source, for example with ``#box[body] <label>``.
|
||||
|
||||
.. manim:: TypstLabelSelectionExample
|
||||
:save_last_frame:
|
||||
:ref_classes: Typst
|
||||
:ref_methods: Typst.select
|
||||
|
||||
class TypstLabelSelectionExample(Scene):
|
||||
def construct(self):
|
||||
text = Typst(
|
||||
r'''
|
||||
#box[
|
||||
*Typst* labels also work in regular markup.
|
||||
] <headline>
|
||||
|
||||
#let pick(body) = [#box(body) <picked>]
|
||||
We can highlight #pick[multiple] #pick[fragments] at once.
|
||||
''',
|
||||
font_size=42,
|
||||
)
|
||||
text.select("headline").set_color(BLUE)
|
||||
text.select("picked").set_color(YELLOW)
|
||||
self.add(text)
|
||||
|
||||
.. manim:: TypstMathSelectionExample
|
||||
:save_last_frame:
|
||||
:ref_classes: TypstMath
|
||||
:ref_methods: Typst.select
|
||||
|
||||
class TypstMathSelectionExample(Scene):
|
||||
def construct(self):
|
||||
equation = TypstMath(
|
||||
"{{ a^2 + b^2 : lhs }} = {{ c^2 }}",
|
||||
font_size=72,
|
||||
)
|
||||
equation.select("lhs").set_color(BLUE)
|
||||
equation.select(0).set_color(YELLOW)
|
||||
self.add(equation)
|
||||
|
||||
Inspecting baseline frames
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
For debugging or alignment tasks, Typst mobjects can optionally track a
|
||||
per-element baseline frame. Enable this with ``track_baselines=True`` and
|
||||
query either :attr:`~.Typst.baseline_frames` for all tracked leaf elements or
|
||||
:meth:`~.Typst.get_baseline_frame` for a specific selected submobject.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
text = Typst("Ggf", track_baselines=True)
|
||||
orig, right, up = text.baseline_frames[0]
|
||||
|
||||
eq = TypstMath("{{ a^2 + b^2 : lhs }} = c^2", track_baselines=True)
|
||||
for part in eq.select("lhs"):
|
||||
orig, right, up = eq.get_baseline_frame(part)
|
||||
print(orig, right, up)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
__all__ = [
|
||||
"Typst",
|
||||
"TypstMath",
|
||||
]
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
import numpy as np
|
||||
import svgelements as se
|
||||
|
||||
from manim import config
|
||||
from manim.constants import DEFAULT_FONT_SIZE, SCALE_FACTOR_PER_FONT_POINT, RendererType
|
||||
from manim.mobject.svg.svg_mobject import SVGMobject
|
||||
from manim.mobject.types.vectorized_mobject import VGroup, VMobject
|
||||
from manim.utils.color import BLACK, ParsableManimColor
|
||||
from manim.utils.typst_file_writing import typst_to_svg_file
|
||||
|
||||
_MANIMGRP_PREAMBLE = "#let manimgrp(lbl, body) = [#box(body) #label(lbl)]"
|
||||
|
||||
# Pattern for the label part of {{ content : label }}.
|
||||
# The label must be a valid Typst label identifier.
|
||||
_LABEL_RE = re.compile(r"^(.*)\s*:\s*([a-zA-Z_][a-zA-Z0-9_-]*)\s*$", re.DOTALL)
|
||||
_INTERNAL_TYPST_ID_RE = re.compile(r"g[0-9A-Fa-f]+")
|
||||
_DUPLICATE_LABEL_SUFFIX = "__manim_typst_dup_"
|
||||
# Empirical correction so Typst-authored SVG strokes (fraction bars,
|
||||
# underlines, etc.) visually match the weight of TeX-derived geometry more
|
||||
# closely after import into Manim's pixel-based stroke model.
|
||||
_TYPST_SVG_STROKE_WIDTH_SCALE = 0.5
|
||||
|
||||
|
||||
class Typst(SVGMobject):
|
||||
"""A mobject rendered from a Typst markup string.
|
||||
|
||||
The Typst source is compiled to SVG via the ``typst`` Python package
|
||||
(a self-contained Rust binary extension — no system-level install
|
||||
required) and then imported through :class:`~.SVGMobject`.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
typst_code
|
||||
Raw Typst markup to be compiled. This string is placed verbatim
|
||||
into the body of a minimal Typst document.
|
||||
font_size
|
||||
Font size in Manim font-size units (default: ``DEFAULT_FONT_SIZE``,
|
||||
i.e. 48). The actual scaling is applied *after* SVG import, matching
|
||||
the approach used by :class:`~.SingleStringMathTex`.
|
||||
typst_preamble
|
||||
Extra Typst code inserted before the body. Useful for ``#import``,
|
||||
``#set``, or ``#show`` rules. Default: ``""``.
|
||||
color
|
||||
The color of the mobject. By default the standard VMobject color
|
||||
(white in dark mode). Overrides the Typst text fill color.
|
||||
stroke_width
|
||||
SVG stroke width override. If ``None`` (default), the stroke widths
|
||||
from Typst's SVG output are preserved.
|
||||
font_paths
|
||||
Optional list of additional font directories passed to the Typst
|
||||
compiler (e.g. for custom fonts not installed system-wide).
|
||||
track_baselines
|
||||
Whether to keep enough per-element reference data to recover the
|
||||
current Typst baseline frame for each imported submobject.
|
||||
When enabled, :attr:`baseline_frames` and
|
||||
:meth:`get_baseline_frame` can be used to retrieve the current
|
||||
``(orig, right, up)`` positions for the imported SVG elements.
|
||||
should_center
|
||||
Whether to center the mobject after import (default ``True``).
|
||||
height
|
||||
Target height of the mobject. If ``None`` (default), the height is
|
||||
determined by ``font_size``.
|
||||
**kwargs
|
||||
Forwarded to :class:`~.SVGMobject`.
|
||||
|
||||
Examples
|
||||
--------
|
||||
.. manim:: TypstExample
|
||||
:ref_classes: Typst
|
||||
|
||||
class TypstExample(Scene):
|
||||
def construct(self):
|
||||
formula = Typst(r"$ integral_a^b f(x) dif x $")
|
||||
self.play(Write(formula))
|
||||
|
||||
.. manim:: TypstTextExample
|
||||
:save_last_frame:
|
||||
:ref_classes: Typst
|
||||
|
||||
class TypstTextExample(Scene):
|
||||
def construct(self):
|
||||
text = Typst(
|
||||
r"*Hello* from _Typst!_",
|
||||
font_size=72,
|
||||
)
|
||||
self.add(text)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
typst_code: str,
|
||||
*,
|
||||
font_size: float = DEFAULT_FONT_SIZE,
|
||||
typst_preamble: str = "",
|
||||
color: ParsableManimColor | None = None,
|
||||
stroke_width: float | None = None,
|
||||
font_paths: list[str | Path] | None = None,
|
||||
track_baselines: bool = False,
|
||||
should_center: bool = True,
|
||||
height: float | None = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
if color is None:
|
||||
color = VMobject().color
|
||||
|
||||
self._font_size = font_size
|
||||
self.typst_code = typst_code
|
||||
self.typst_preamble = typst_preamble
|
||||
self.track_baselines = track_baselines
|
||||
self._preserve_svg_stroke_widths = stroke_width is None
|
||||
self._baseline_tracked_submobjects: list[VMobject] = []
|
||||
self._stroke_width_tracked_submobjects: list[VMobject] = []
|
||||
self._label_aliases: dict[str, list[str]] = {}
|
||||
|
||||
file_name = typst_to_svg_file(
|
||||
typst_code,
|
||||
preamble=typst_preamble,
|
||||
font_paths=font_paths,
|
||||
)
|
||||
super().__init__(
|
||||
file_name=file_name,
|
||||
should_center=should_center,
|
||||
stroke_width=stroke_width,
|
||||
height=height,
|
||||
color=color,
|
||||
path_string_config={
|
||||
"should_subdivide_sharp_curves": True,
|
||||
"should_remove_null_curves": True,
|
||||
},
|
||||
**kwargs,
|
||||
)
|
||||
self._rebuild_label_aliases()
|
||||
self._refresh_svg_stroke_widths()
|
||||
self.init_colors()
|
||||
|
||||
# Used for scaling via font_size property (mirrors SingleStringMathTex).
|
||||
self.initial_height = self.height
|
||||
|
||||
if height is None:
|
||||
self.font_size = self._font_size
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"{type(self).__name__}({self.typst_code!r})"
|
||||
|
||||
@property
|
||||
def hash_seed(self) -> tuple:
|
||||
"""Include baseline tracking in the SVG cache key."""
|
||||
return (*super().hash_seed, self.track_baselines)
|
||||
|
||||
# -- font_size property (same approach as SingleStringMathTex) -----------
|
||||
|
||||
@property
|
||||
def font_size(self) -> float:
|
||||
"""The font size of the Typst mobject."""
|
||||
return self.height / self.initial_height / SCALE_FACTOR_PER_FONT_POINT
|
||||
|
||||
@font_size.setter
|
||||
def font_size(self, val: float) -> None:
|
||||
if val <= 0:
|
||||
raise ValueError("font_size must be greater than 0.")
|
||||
if self.height > 0:
|
||||
self.scale(val / self.font_size)
|
||||
|
||||
def scale(
|
||||
self,
|
||||
scale_factor: float,
|
||||
scale_stroke: bool = False,
|
||||
*,
|
||||
about_point: np.ndarray | None = None,
|
||||
about_edge: np.ndarray | None = None,
|
||||
) -> Typst:
|
||||
result = super().scale(
|
||||
scale_factor,
|
||||
scale_stroke=scale_stroke,
|
||||
about_point=about_point,
|
||||
about_edge=about_edge,
|
||||
)
|
||||
self._refresh_svg_stroke_widths()
|
||||
return result
|
||||
|
||||
def _refresh_svg_stroke_widths(self) -> None:
|
||||
"""Refresh pixel stroke widths for Typst-authored SVG strokes.
|
||||
|
||||
SVG stroke widths are specified in the SVG's local coordinate system,
|
||||
while Manim stroke widths are pixel-based. For Typst-authored strokes
|
||||
such as fraction bars or underlines, rescale them according to the
|
||||
current geometric scale of the imported element so their visual weight
|
||||
stays proportional to the rest of the expression.
|
||||
"""
|
||||
if not self._preserve_svg_stroke_widths:
|
||||
return
|
||||
|
||||
pixels_per_unit = config.pixel_width / config.frame_width
|
||||
for submobject in self._stroke_width_tracked_submobjects:
|
||||
submobject_any = cast(Any, submobject)
|
||||
reference_size = cast(float, submobject_any._typst_reference_size)
|
||||
source_stroke_width = cast(
|
||||
float,
|
||||
submobject_any._typst_source_stroke_width,
|
||||
)
|
||||
current_size = max(submobject.width, submobject.height)
|
||||
if reference_size <= 0:
|
||||
continue
|
||||
current_stroke_width = source_stroke_width * current_size / reference_size
|
||||
submobject.set_stroke(
|
||||
width=current_stroke_width
|
||||
* pixels_per_unit
|
||||
* _TYPST_SVG_STROKE_WIDTH_SCALE,
|
||||
family=False,
|
||||
)
|
||||
|
||||
# -- baseline frame tracking ---------------------------------------------
|
||||
|
||||
def get_mob_from_shape_element(self, shape: se.SVGElement) -> VMobject | None:
|
||||
"""Attach Typst-specific metadata to imported shape mobjects."""
|
||||
mob = super().get_mob_from_shape_element(shape)
|
||||
if mob is None or not mob.has_points():
|
||||
return mob
|
||||
|
||||
if self._preserve_svg_stroke_widths and shape.stroke_width not in (None, 0):
|
||||
reference_size = max(mob.width, mob.height)
|
||||
if reference_size > 0:
|
||||
mob_any = cast(Any, mob)
|
||||
mob_any._typst_reference_size = reference_size
|
||||
mob_any._typst_source_stroke_width = shape.stroke_width
|
||||
self._stroke_width_tracked_submobjects.append(mob)
|
||||
|
||||
if not self.track_baselines:
|
||||
return mob
|
||||
|
||||
baseline_marks = self._get_reference_baseline_frame(shape)
|
||||
if baseline_marks is None:
|
||||
return mob
|
||||
|
||||
reference_points = mob.points.copy()
|
||||
reference_xy = np.column_stack(
|
||||
[
|
||||
reference_points[:, 0],
|
||||
reference_points[:, 1],
|
||||
np.ones(len(reference_points)),
|
||||
],
|
||||
)
|
||||
if np.linalg.matrix_rank(reference_xy) < 3:
|
||||
return mob
|
||||
|
||||
mob_any = cast(Any, mob)
|
||||
mob_any._typst_reference_points = reference_points
|
||||
mob_any._typst_reference_baseline_frame = baseline_marks
|
||||
self._baseline_tracked_submobjects.append(mob)
|
||||
return mob
|
||||
|
||||
@staticmethod
|
||||
def _get_reference_baseline_frame(
|
||||
shape: se.SVGElement,
|
||||
) -> np.ndarray | None:
|
||||
"""Return the reference ``(orig, right, up)`` frame for a Typst SVG element.
|
||||
|
||||
The frame is expressed in the same pre-centering coordinate system as the
|
||||
imported submobject points after the element's own SVG transform has been
|
||||
applied.
|
||||
"""
|
||||
if not isinstance(shape, se.Transformable):
|
||||
return None
|
||||
|
||||
matrix = shape.transform if shape.apply else se.Matrix()
|
||||
return np.array(
|
||||
[
|
||||
[matrix.e, matrix.f, 0.0],
|
||||
[matrix.a + matrix.e, matrix.b + matrix.f, 0.0],
|
||||
[matrix.c + matrix.e, matrix.d + matrix.f, 0.0],
|
||||
],
|
||||
)
|
||||
|
||||
def get_baseline_frame(
|
||||
self, submobject: VMobject
|
||||
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
||||
"""Return the current Typst baseline frame for a tracked submobject.
|
||||
|
||||
The returned tuple contains the current positions of ``(orig, right, up)``.
|
||||
These are recovered from the stored reference frame and the submobject's
|
||||
current affine position in the scene.
|
||||
"""
|
||||
try:
|
||||
submobject_any = cast(Any, submobject)
|
||||
reference_points = cast(
|
||||
np.ndarray,
|
||||
submobject_any._typst_reference_points,
|
||||
)
|
||||
reference_frame = cast(
|
||||
np.ndarray,
|
||||
submobject_any._typst_reference_baseline_frame,
|
||||
)
|
||||
except AttributeError as err:
|
||||
raise ValueError(
|
||||
"No tracked Typst baseline frame is available for this submobject. "
|
||||
"Construct the Typst mobject with track_baselines=True.",
|
||||
) from err
|
||||
|
||||
reference_xy = np.column_stack(
|
||||
[
|
||||
reference_points[:, 0],
|
||||
reference_points[:, 1],
|
||||
np.ones(len(reference_points)),
|
||||
],
|
||||
)
|
||||
if np.linalg.matrix_rank(reference_xy) < 3:
|
||||
raise ValueError(
|
||||
"The stored Typst reference geometry is degenerate, so its baseline "
|
||||
"frame cannot be recovered.",
|
||||
)
|
||||
|
||||
transform, _, _, _ = np.linalg.lstsq(
|
||||
reference_xy, submobject.points, rcond=None
|
||||
)
|
||||
frame_xy = np.column_stack(
|
||||
[
|
||||
reference_frame[:, 0],
|
||||
reference_frame[:, 1],
|
||||
np.ones(len(reference_frame)),
|
||||
],
|
||||
)
|
||||
current_frame = frame_xy @ transform
|
||||
return tuple(
|
||||
cast(tuple[np.ndarray, np.ndarray, np.ndarray], tuple(current_frame))
|
||||
)
|
||||
|
||||
@property
|
||||
def baseline_frames(self) -> list[tuple[np.ndarray, np.ndarray, np.ndarray]]:
|
||||
"""Current Typst baseline frames for all tracked leaf submobjects."""
|
||||
if not self.track_baselines:
|
||||
return []
|
||||
return [
|
||||
self.get_baseline_frame(submobject)
|
||||
for submobject in self._baseline_tracked_submobjects
|
||||
]
|
||||
|
||||
def _rebuild_label_aliases(self) -> None:
|
||||
"""Rebuild user-facing label aliases from imported SVG ids."""
|
||||
aliases: dict[str, list[str]] = {}
|
||||
for key in self.id_to_vgroup_dict:
|
||||
if key == "root" or key.startswith("numbered_group_"):
|
||||
continue
|
||||
if _INTERNAL_TYPST_ID_RE.fullmatch(key) is not None:
|
||||
continue
|
||||
|
||||
base_label = key
|
||||
if _DUPLICATE_LABEL_SUFFIX in key:
|
||||
base_label, _, _ = key.partition(_DUPLICATE_LABEL_SUFFIX)
|
||||
aliases.setdefault(base_label, []).append(key)
|
||||
self._label_aliases = aliases
|
||||
|
||||
def _select_label(self, label: str) -> VGroup:
|
||||
if label not in self._label_aliases:
|
||||
raise KeyError(
|
||||
f"No group with label {label!r} found. "
|
||||
f"Available labels: {self._user_label_keys()}"
|
||||
)
|
||||
|
||||
result = VGroup()
|
||||
seen_ids: set[int] = set()
|
||||
for group_id in self._label_aliases[label]:
|
||||
for submobject in self.id_to_vgroup_dict[group_id]:
|
||||
submobject_id = id(submobject)
|
||||
if submobject_id in seen_ids:
|
||||
continue
|
||||
seen_ids.add(submobject_id)
|
||||
result.add(submobject)
|
||||
return result
|
||||
|
||||
# -- SVG post-processing -------------------------------------------------
|
||||
|
||||
def modify_xml_tree(self, element_tree: ET.ElementTree) -> ET.ElementTree:
|
||||
"""Convert ``data-typst-label`` attributes to ``id`` before parsing.
|
||||
|
||||
Typst's SVG renderer emits ``data-typst-label`` on ``<g>`` elements
|
||||
that carry a label (created via ``#box(body) <label>``). The
|
||||
``svgelements`` library propagates custom ``data-*`` attributes from
|
||||
parent groups to all children, making them unusable as unique group
|
||||
keys. ``id`` attributes, on the other hand, are *not* inherited.
|
||||
|
||||
This method walks the XML tree and promotes every
|
||||
``data-typst-label`` to ``id`` (on ``<g>`` elements only), so that
|
||||
:meth:`~.SVGMobject.get_mobjects_from` can pick them up via its
|
||||
existing ``id``-based grouping logic.
|
||||
"""
|
||||
# Let the base class inject default style wrappers first.
|
||||
element_tree = super().modify_xml_tree(element_tree)
|
||||
|
||||
# Walk all elements regardless of namespace — ElementTree
|
||||
# qualifies tags with the namespace URI, so a bare ``"g"``
|
||||
# won't match ``{http://www.w3.org/2000/svg}g``.
|
||||
label_counts: dict[str, int] = {}
|
||||
for element in element_tree.iter():
|
||||
label = element.get("data-typst-label")
|
||||
if label is not None:
|
||||
count = label_counts.get(label, 0)
|
||||
label_counts[label] = count + 1
|
||||
svg_id = label
|
||||
if count > 0:
|
||||
svg_id = f"{label}{_DUPLICATE_LABEL_SUFFIX}{count}"
|
||||
element.set("id", svg_id)
|
||||
del element.attrib["data-typst-label"]
|
||||
|
||||
return element_tree
|
||||
|
||||
# -- sub-expression selection --------------------------------------------
|
||||
|
||||
def select(self, key: str | int) -> VGroup:
|
||||
"""Select a labeled sub-expression.
|
||||
|
||||
Labels are created in the Typst source either manually via the
|
||||
``manimgrp`` helper or automatically through the ``{{ }}``
|
||||
double-brace notation in :class:`TypstMath`.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
key
|
||||
A label name (``str``) matching a ``data-typst-label`` in the
|
||||
SVG, or an integer index into the auto-numbered ``{{ }}``
|
||||
groups (``_grp-0``, ``_grp-1``, …).
|
||||
|
||||
Returns
|
||||
-------
|
||||
VGroup
|
||||
The submobjects corresponding to the selected group.
|
||||
|
||||
Raises
|
||||
------
|
||||
KeyError
|
||||
If no group with the given label exists.
|
||||
IndexError
|
||||
If an integer index is out of range.
|
||||
|
||||
Examples
|
||||
--------
|
||||
.. manim:: TypstSelectExample
|
||||
:save_last_frame:
|
||||
:ref_classes: TypstMath
|
||||
:ref_methods: Typst.select
|
||||
|
||||
class TypstSelectExample(Scene):
|
||||
def construct(self):
|
||||
eq = TypstMath(
|
||||
"{{ a + b : num }} / {{ c : den }} = {{ lambda }} {{ x }}"
|
||||
)
|
||||
eq.select("num").set_color(RED) # "a + b"
|
||||
eq.select("den").set_color(BLUE) # "c"
|
||||
eq.select(0).set_color(YELLOW) # "lambda" (auto-numbered: "grp-0")
|
||||
eq.select(1).set_color(GREEN) # "x" (auto-numbered: "grp-1")
|
||||
|
||||
self.add(eq)
|
||||
"""
|
||||
if isinstance(key, int):
|
||||
label = f"_grp-{key}"
|
||||
if label not in self._label_aliases:
|
||||
raise IndexError(
|
||||
f"Group index {key} out of range. "
|
||||
f"Available labels: {self._user_label_keys()}"
|
||||
)
|
||||
return self._select_label(label)
|
||||
|
||||
return self._select_label(key)
|
||||
|
||||
def _user_label_keys(self) -> list[str]:
|
||||
"""Return the label keys that were created from ``data-typst-label``
|
||||
attributes (filtering out internal Typst group IDs and auto-numbered
|
||||
groups).
|
||||
"""
|
||||
return list(self._label_aliases)
|
||||
|
||||
# -- color handling ------------------------------------------------------
|
||||
|
||||
def init_colors(self, propagate_colors: bool = True) -> Typst:
|
||||
"""Recolor black submobjects to ``self.color``.
|
||||
|
||||
Typst renders text in black (``fill="#000000"``) by default.
|
||||
This mirrors the approach of :meth:`SingleStringMathTex.init_colors`:
|
||||
any submobject whose color is black is recolored to ``self.color``,
|
||||
while explicitly colored submobjects (non-black) are preserved.
|
||||
"""
|
||||
for submobject in self.submobjects:
|
||||
if submobject.color != BLACK:
|
||||
continue
|
||||
submobject.color = self.color
|
||||
if config.renderer == RendererType.OPENGL:
|
||||
submobject.init_colors()
|
||||
elif config.renderer == RendererType.CAIRO:
|
||||
submobject.init_colors(propagate_colors=propagate_colors)
|
||||
return self
|
||||
|
||||
|
||||
class TypstMath(Typst):
|
||||
r"""Convenience wrapper: wraps the input in Typst math delimiters.
|
||||
|
||||
The expression is rendered as a display-level equation
|
||||
(``$ ... $`` with surrounding spaces).
|
||||
|
||||
Supports the ``{{ ... }}`` double-brace notation for grouping
|
||||
sub-expressions. Each ``{{ content }}`` is wrapped in a labeled
|
||||
``manimgrp`` call so that the resulting SVG contains identifiable
|
||||
groups accessible via :meth:`~.Typst.select`.
|
||||
|
||||
Groups can optionally be given explicit labels:
|
||||
``{{ content : label }}``. Without a label, groups are
|
||||
auto-numbered (``_grp-0``, ``_grp-1``, …).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
math_expression
|
||||
Typst math-mode content **without** the ``$ ... $`` delimiters.
|
||||
May contain ``{{ ... }}`` groups.
|
||||
**kwargs
|
||||
Forwarded to :class:`Typst`.
|
||||
|
||||
Examples
|
||||
--------
|
||||
.. manim:: DisplayMath
|
||||
:save_last_frame:
|
||||
:ref_classes: TypstMath
|
||||
|
||||
class DisplayMath(Scene):
|
||||
def construct(self):
|
||||
eq = TypstMath(r"sum_(k=0)^n k = (n(n+1)) / 2")
|
||||
self.add(eq)
|
||||
|
||||
.. manim:: GroupedMath
|
||||
:save_last_frame:
|
||||
:ref_classes: TypstMath
|
||||
:ref_methods: Typst.select
|
||||
|
||||
class GroupedMath(Scene):
|
||||
def construct(self):
|
||||
eq = TypstMath("{{ a^2 + b^2 : lhs }} = {{ c^2 }}")
|
||||
eq.select("lhs").set_color(RED) # "a^2 + b^2"
|
||||
eq.select(0).set_color(BLUE) # "c^2" (auto-numbered: "grp-0")
|
||||
self.add(eq)
|
||||
"""
|
||||
|
||||
def __init__(self, math_expression: str, **kwargs: Any):
|
||||
processed, labels = self._preprocess_groups(math_expression)
|
||||
self._group_labels = labels
|
||||
|
||||
# Inject the manimgrp helper when groups are present.
|
||||
if labels:
|
||||
preamble = kwargs.get("typst_preamble", "")
|
||||
if _MANIMGRP_PREAMBLE not in preamble:
|
||||
preamble = (
|
||||
f"{_MANIMGRP_PREAMBLE}\n{preamble}"
|
||||
if preamble
|
||||
else _MANIMGRP_PREAMBLE
|
||||
)
|
||||
kwargs["typst_preamble"] = preamble
|
||||
|
||||
super().__init__(f"$ {processed} $", **kwargs)
|
||||
|
||||
# -- double-brace preprocessor -------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _preprocess_groups(math_expr: str) -> tuple[str, list[str]]:
|
||||
"""Replace ``{{ ... }}`` groups with ``manimgrp(...)`` calls.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
math_expr
|
||||
The raw math expression (without ``$ ... $`` delimiters).
|
||||
|
||||
Returns
|
||||
-------
|
||||
tuple[str, list[str]]
|
||||
The processed expression and an ordered list of group labels.
|
||||
"""
|
||||
result: list[str] = []
|
||||
labels: list[str] = []
|
||||
auto_index = 0
|
||||
i = 0
|
||||
n = len(math_expr)
|
||||
outer_in_string = False
|
||||
outer_bracket_depth = 0
|
||||
|
||||
while i < n:
|
||||
ch = math_expr[i]
|
||||
|
||||
# Track string literals at the outer level.
|
||||
if outer_in_string:
|
||||
result.append(ch)
|
||||
if ch == "\\" and i + 1 < n:
|
||||
result.append(math_expr[i + 1])
|
||||
i += 2
|
||||
continue
|
||||
if ch == '"':
|
||||
outer_in_string = False
|
||||
i += 1
|
||||
continue
|
||||
if ch == '"':
|
||||
outer_in_string = True
|
||||
result.append(ch)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Track [...] content blocks at the outer level.
|
||||
if ch == "[":
|
||||
outer_bracket_depth += 1
|
||||
result.append(ch)
|
||||
i += 1
|
||||
continue
|
||||
if ch == "]" and outer_bracket_depth > 0:
|
||||
outer_bracket_depth -= 1
|
||||
result.append(ch)
|
||||
i += 1
|
||||
continue
|
||||
if outer_bracket_depth > 0:
|
||||
result.append(ch)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Look for opening {{ (not a single {)
|
||||
if i + 1 < n and ch == "{" and math_expr[i + 1] == "{":
|
||||
i += 2 # skip {{
|
||||
content_start = i
|
||||
depth = 1
|
||||
in_string = False
|
||||
bracket_depth = 0
|
||||
|
||||
while i < n and depth > 0:
|
||||
ch = math_expr[i]
|
||||
|
||||
if in_string:
|
||||
if ch == "\\" and i + 1 < n:
|
||||
i += 2
|
||||
continue
|
||||
if ch == '"':
|
||||
in_string = False
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if ch == '"':
|
||||
in_string = True
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if ch == "[":
|
||||
bracket_depth += 1
|
||||
i += 1
|
||||
continue
|
||||
if ch == "]" and bracket_depth > 0:
|
||||
bracket_depth -= 1
|
||||
i += 1
|
||||
continue
|
||||
if bracket_depth > 0:
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if ch == "{" and i + 1 < n and math_expr[i + 1] == "{":
|
||||
depth += 1
|
||||
i += 2
|
||||
continue
|
||||
if ch == "}" and i + 1 < n and math_expr[i + 1] == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
content = math_expr[content_start:i]
|
||||
i += 2 # skip }}
|
||||
break
|
||||
i += 2
|
||||
continue
|
||||
|
||||
i += 1
|
||||
else:
|
||||
# Unclosed {{ — emit literally and stop.
|
||||
result.append("{{")
|
||||
result.append(math_expr[content_start:])
|
||||
return "".join(result), labels
|
||||
|
||||
# Check for optional `: label` suffix.
|
||||
m = _LABEL_RE.match(content)
|
||||
if m is not None:
|
||||
body = m.group(1).strip()
|
||||
label = m.group(2)
|
||||
else:
|
||||
body = content.strip()
|
||||
label = f"_grp-{auto_index}"
|
||||
auto_index += 1
|
||||
|
||||
labels.append(label)
|
||||
result.append(f'manimgrp("{label}", {body})')
|
||||
else:
|
||||
result.append(math_expr[i])
|
||||
i += 1
|
||||
|
||||
return "".join(result), labels
|
||||
|
|
@ -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__(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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__
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
106
manim/utils/typst_file_writing.py
Normal file
106
manim/utils/typst_file_writing.py
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
"""Interface for writing, compiling, and converting ``.typ`` files via the ``typst`` Python package.
|
||||
|
||||
.. SEEALSO::
|
||||
|
||||
:mod:`.mobject.text.typst_mobject`
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
|
||||
from manim import config, logger
|
||||
|
||||
__all__ = ["typst_to_svg_file"]
|
||||
|
||||
# Use 10pt instead of Typst's 11pt default so that the post-import scaling
|
||||
# based on Manim's font_size property matches TeX / MathTex more closely.
|
||||
TYPST_COMPILATION_FONT_SIZE = 10 # pt
|
||||
|
||||
TYPST_TEMPLATE = """\
|
||||
#set page(width: auto, height: auto, margin: 0pt, fill: none)
|
||||
#set text(size: {text_size}pt)
|
||||
{preamble}
|
||||
{body}
|
||||
"""
|
||||
|
||||
|
||||
def _typst_hash(content: str) -> str:
|
||||
"""Return a truncated SHA-256 hex digest of *content*."""
|
||||
return hashlib.sha256(content.encode()).hexdigest()[:16]
|
||||
|
||||
|
||||
def typst_to_svg_file(
|
||||
typst_code: str,
|
||||
preamble: str = "",
|
||||
text_size: float = TYPST_COMPILATION_FONT_SIZE,
|
||||
font_paths: list[str | Path] | None = None,
|
||||
) -> Path:
|
||||
"""Compile a Typst string to SVG via the ``typst`` Python package.
|
||||
|
||||
The compiled SVG and the intermediate ``.typ`` source are cached
|
||||
under :func:`config.get_dir("tex_dir") <manim.ManimConfig.get_dir>`
|
||||
using a content-hash filename scheme (identical to the LaTeX pipeline).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
typst_code
|
||||
The body of the Typst document (user-supplied markup).
|
||||
preamble
|
||||
Extra Typst code inserted between the ``#set`` rules and the body.
|
||||
Useful for ``#import``, ``#set``, or ``#show`` rules.
|
||||
text_size
|
||||
Font size in Typst points used during compilation.
|
||||
font_paths
|
||||
Optional list of additional font directories passed to the Typst
|
||||
compiler.
|
||||
|
||||
Returns
|
||||
-------
|
||||
:class:`Path`
|
||||
Path to the generated SVG file.
|
||||
|
||||
Raises
|
||||
------
|
||||
ImportError
|
||||
If the ``typst`` Python package is not installed.
|
||||
"""
|
||||
try:
|
||||
import typst as typst_compiler
|
||||
except ImportError as err:
|
||||
raise ImportError(
|
||||
"TypstMobject requires the 'typst' Python package. "
|
||||
"Install it with: pip install typst>=0.14"
|
||||
) from err
|
||||
|
||||
full_source = TYPST_TEMPLATE.format(
|
||||
text_size=text_size,
|
||||
preamble=preamble,
|
||||
body=typst_code,
|
||||
)
|
||||
content_hash = _typst_hash(full_source)
|
||||
typst_dir = config.get_dir("tex_dir")
|
||||
typst_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
typ_file = typst_dir / f"{content_hash}.typ"
|
||||
svg_file = typst_dir / f"{content_hash}.svg"
|
||||
|
||||
if svg_file.exists():
|
||||
return svg_file
|
||||
|
||||
typ_file.write_text(full_source, encoding="utf-8")
|
||||
|
||||
logger.info(
|
||||
"Compiling Typst source %(path)s ...",
|
||||
{"path": f"{typ_file}"},
|
||||
)
|
||||
|
||||
svg_bytes = typst_compiler.compile(
|
||||
str(typ_file),
|
||||
format="svg",
|
||||
font_paths=font_paths or [],
|
||||
)
|
||||
svg_file.write_bytes(svg_bytes)
|
||||
return svg_file
|
||||
9
mypy.ini
9
mypy.ini
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
339
tests/module/mobject/text/test_typst_mobject.py
Normal file
339
tests/module/mobject/text/test_typst_mobject.py
Normal file
|
|
@ -0,0 +1,339 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from manim import (
|
||||
RIGHT,
|
||||
Label,
|
||||
MathTex,
|
||||
NumberLine,
|
||||
Tex,
|
||||
Typst,
|
||||
TypstMath,
|
||||
Vector,
|
||||
VectorScene,
|
||||
)
|
||||
|
||||
|
||||
def test_Typst(config):
|
||||
"""Basic Typst creation produces an SVG file."""
|
||||
m = Typst(r"$ x^2 $")
|
||||
assert m.height > 0
|
||||
assert m.width > 0
|
||||
assert len(m.submobjects) > 0
|
||||
|
||||
|
||||
def test_TypstMath(config):
|
||||
"""TypstMath wraps the expression in math delimiters."""
|
||||
m = TypstMath(r"alpha + beta")
|
||||
assert m.typst_code == "$ alpha + beta $"
|
||||
assert m.height > 0
|
||||
|
||||
|
||||
def test_typst_default_font_size(config):
|
||||
"""Default font_size is 48 (DEFAULT_FONT_SIZE)."""
|
||||
m = Typst(r"$ a + b $")
|
||||
assert np.isclose(m.font_size, 48)
|
||||
|
||||
|
||||
def test_typst_custom_font_size(config):
|
||||
"""Passing a custom font_size scales the mobject accordingly."""
|
||||
m = Typst(r"$ a + b $", font_size=72)
|
||||
assert np.isclose(m.font_size, 72)
|
||||
|
||||
|
||||
def test_typst_font_size_property_setter(config):
|
||||
"""Setting font_size after creation rescales correctly."""
|
||||
m = Typst(r"$ a + b $")
|
||||
original_height = m.height
|
||||
m.font_size = 96
|
||||
assert np.isclose(m.font_size, 96)
|
||||
assert m.height > original_height
|
||||
|
||||
|
||||
def test_typst_font_size_scaling_also_scales_svg_strokes(config):
|
||||
"""Typst-authored stroke widths scale together with font_size."""
|
||||
m = TypstMath("frac(a,b)", font_size=48, use_svg_cache=False)
|
||||
original_stroke_width = max(submobject.stroke_width for submobject in m.submobjects)
|
||||
|
||||
m.font_size = 96
|
||||
scaled_stroke_width = max(submobject.stroke_width for submobject in m.submobjects)
|
||||
|
||||
assert np.isclose(scaled_stroke_width, 2 * original_stroke_width)
|
||||
|
||||
|
||||
def test_typst_font_size_error(config):
|
||||
"""Setting font_size to a non-positive value raises ValueError."""
|
||||
m = Typst(r"$ a + b $")
|
||||
with pytest.raises(ValueError, match="font_size must be greater than 0"):
|
||||
m.font_size = -1
|
||||
|
||||
|
||||
def test_typst_caching(config):
|
||||
"""Compiling the same source twice uses the cached SVG."""
|
||||
m1 = Typst(r"$ e^{i pi} + 1 = 0 $")
|
||||
m2 = Typst(r"$ e^{i pi} + 1 = 0 $")
|
||||
assert np.isclose(m1.height, m2.height)
|
||||
assert np.isclose(m1.width, m2.width)
|
||||
|
||||
|
||||
def test_typst_preamble(config):
|
||||
"""A custom preamble is accepted without error."""
|
||||
m = Typst(
|
||||
r"$ x^2 $",
|
||||
typst_preamble='#set text(font: "New Computer Modern")',
|
||||
)
|
||||
assert m.height > 0
|
||||
|
||||
|
||||
def test_typst_repr(config):
|
||||
"""__repr__ includes the Typst source."""
|
||||
m = Typst("hello")
|
||||
assert repr(m) == "Typst('hello')"
|
||||
|
||||
m2 = TypstMath("x")
|
||||
assert repr(m2) == "TypstMath('$ x $')"
|
||||
|
||||
|
||||
def test_typst_text_rendering(config):
|
||||
"""Non-math Typst markup renders correctly."""
|
||||
m = Typst(r"*Bold* and _italic_")
|
||||
assert m.height > 0
|
||||
assert len(m.submobjects) > 0
|
||||
|
||||
|
||||
def test_typst_preserves_svg_stroke_widths_by_default(config):
|
||||
"""Default stroke_width=None preserves Typst-authored SVG strokes."""
|
||||
m = Typst("#underline[abc]", use_svg_cache=False)
|
||||
assert any(submobject.stroke_width > 0 for submobject in m.submobjects)
|
||||
|
||||
|
||||
def test_typst_baseline_frames_empty_without_tracking(config):
|
||||
"""Baseline frames are only collected when requested."""
|
||||
m = Typst("Ggf", use_svg_cache=False)
|
||||
assert m.baseline_frames == []
|
||||
|
||||
|
||||
def test_typst_baseline_frames_track_scene_positions(config):
|
||||
"""Tracked baseline frames follow ordinary affine transformations."""
|
||||
m = Typst("Ggf", track_baselines=True, use_svg_cache=False)
|
||||
assert m.baseline_frames
|
||||
|
||||
orig, right, up = m.baseline_frames[0]
|
||||
delta = 2 * RIGHT
|
||||
m.shift(delta)
|
||||
shifted_orig, shifted_right, shifted_up = m.baseline_frames[0]
|
||||
|
||||
assert np.allclose(shifted_orig - orig, delta)
|
||||
assert np.allclose(shifted_right - right, delta)
|
||||
assert np.allclose(shifted_up - up, delta)
|
||||
|
||||
|
||||
def test_typst_text_font_size_matches_tex_closely(config):
|
||||
"""Typst text is calibrated close to Tex for the same font_size."""
|
||||
tex = Tex("Hello", font_size=48)
|
||||
typst = Typst("Hello", font_size=48, use_svg_cache=False)
|
||||
assert np.isclose(typst.height, tex.height, rtol=0.02)
|
||||
assert np.isclose(typst.width, tex.width, rtol=0.02)
|
||||
|
||||
|
||||
def test_typstmath_font_size_matches_mathtex_closely(config):
|
||||
"""Typst math is calibrated close to MathTex for the same font_size."""
|
||||
mathtex = MathTex(r"\frac{a}{b}", font_size=48)
|
||||
typstmath = TypstMath("frac(a,b)", font_size=48, use_svg_cache=False)
|
||||
assert np.isclose(typstmath.height, mathtex.height, rtol=0.02)
|
||||
assert np.isclose(typstmath.width, mathtex.width, rtol=0.02)
|
||||
|
||||
|
||||
# -- data-typst-label → id mapping tests ------------------------------------
|
||||
|
||||
MANIMGRP_PREAMBLE = "#let manimgrp(lbl, body) = [#box(body) #label(lbl)]"
|
||||
|
||||
|
||||
def test_typst_labels_mapped_to_vgroups(config):
|
||||
"""data-typst-label attributes are promoted to id and appear in id_to_vgroup_dict."""
|
||||
m = Typst(
|
||||
'$ #manimgrp("numerator", $a + b$) / #manimgrp("denom", $c - d$) $',
|
||||
typst_preamble=MANIMGRP_PREAMBLE,
|
||||
use_svg_cache=False,
|
||||
)
|
||||
assert "numerator" in m.id_to_vgroup_dict
|
||||
assert "denom" in m.id_to_vgroup_dict
|
||||
# a, +, b → 3 submobjects; c, -, d → 3 submobjects
|
||||
assert len(m.id_to_vgroup_dict["numerator"]) == 3
|
||||
assert len(m.id_to_vgroup_dict["denom"]) == 3
|
||||
|
||||
|
||||
def test_typst_nested_labels(config):
|
||||
"""Nested labeled boxes produce nested VGroups without cross-contamination."""
|
||||
m = Typst(
|
||||
'$ #manimgrp("outer", $#manimgrp("inner", $a$) + b$) $',
|
||||
typst_preamble=MANIMGRP_PREAMBLE,
|
||||
use_svg_cache=False,
|
||||
)
|
||||
assert "outer" in m.id_to_vgroup_dict
|
||||
assert "inner" in m.id_to_vgroup_dict
|
||||
# "inner" contains only "a" (1 submobject)
|
||||
assert len(m.id_to_vgroup_dict["inner"]) == 1
|
||||
# "outer" contains everything: a, +, b (3 submobjects)
|
||||
assert len(m.id_to_vgroup_dict["outer"]) == 3
|
||||
# The inner submobject is a subset of the outer one
|
||||
inner_mob = m.id_to_vgroup_dict["inner"][0]
|
||||
assert inner_mob in m.id_to_vgroup_dict["outer"]
|
||||
|
||||
|
||||
def test_typst_no_labels_no_extra_keys(config):
|
||||
"""Without labeled boxes, no extra label keys appear."""
|
||||
m = Typst(r"$ a + b $", use_svg_cache=False)
|
||||
label_keys = [
|
||||
k
|
||||
for k in m.id_to_vgroup_dict
|
||||
if not k.startswith(("numbered_group", "root", "g"))
|
||||
]
|
||||
assert label_keys == []
|
||||
|
||||
|
||||
def test_typst_select(config):
|
||||
"""select() returns the correct VGroup for a given label."""
|
||||
m = Typst(
|
||||
'$ #manimgrp("lhs", $a + b$) = #manimgrp("rhs", $c$) $',
|
||||
typst_preamble=MANIMGRP_PREAMBLE,
|
||||
use_svg_cache=False,
|
||||
)
|
||||
lhs = m.select("lhs")
|
||||
rhs = m.select("rhs")
|
||||
assert len(lhs) == 3 # a, +, b
|
||||
assert len(rhs) == 1 # c
|
||||
|
||||
|
||||
def test_typst_select_collects_duplicate_labels(config):
|
||||
"""Repeated Typst labels are combined into one selectable group."""
|
||||
m = Typst(
|
||||
'$ #manimgrp("picked", $a$) + #manimgrp("picked", $b$) $',
|
||||
typst_preamble=MANIMGRP_PREAMBLE,
|
||||
use_svg_cache=False,
|
||||
)
|
||||
picked = m.select("picked")
|
||||
assert len(picked) == 2
|
||||
|
||||
|
||||
def test_typst_get_baseline_frame_for_selected_submobjects(config):
|
||||
"""Tracked frames can be queried for submobjects returned by select()."""
|
||||
m = Typst(
|
||||
'$ #manimgrp("lhs", $a + b$) = c $',
|
||||
typst_preamble=MANIMGRP_PREAMBLE,
|
||||
track_baselines=True,
|
||||
use_svg_cache=False,
|
||||
)
|
||||
lhs = m.select("lhs")
|
||||
frames = [m.get_baseline_frame(submobject) for submobject in lhs]
|
||||
|
||||
assert len(frames) == len(lhs)
|
||||
for orig, right, up in frames:
|
||||
assert orig.shape == (3,)
|
||||
assert right.shape == (3,)
|
||||
assert up.shape == (3,)
|
||||
|
||||
|
||||
def test_typst_select_keyerror(config):
|
||||
"""select() raises KeyError for a nonexistent label."""
|
||||
m = Typst(r"$ a + b $", use_svg_cache=False)
|
||||
with pytest.raises(KeyError, match="No group with label 'missing'"):
|
||||
m.select("missing")
|
||||
|
||||
|
||||
def test_typst_select_keyerror_lists_labels_starting_with_g(config):
|
||||
"""Error messages keep user labels even when they start with ``g``."""
|
||||
m = Typst(
|
||||
'$ #manimgrp("gamma", $a$) $',
|
||||
typst_preamble=MANIMGRP_PREAMBLE,
|
||||
use_svg_cache=False,
|
||||
)
|
||||
with pytest.raises(KeyError, match="gamma"):
|
||||
m.select("missing")
|
||||
|
||||
|
||||
# -- {{ }} double-brace preprocessor tests ----------------------------------
|
||||
|
||||
|
||||
def test_typstmath_double_brace_auto_numbered(config):
|
||||
"""{{ }} groups are auto-numbered and selectable by index."""
|
||||
eq = TypstMath("{{ a + b }} / {{ c - d }} = {{ x }}", use_svg_cache=False)
|
||||
assert eq._group_labels == ["_grp-0", "_grp-1", "_grp-2"]
|
||||
assert len(eq.select(0)) == 3 # a, +, b
|
||||
assert len(eq.select(1)) == 3 # c, -, d
|
||||
assert len(eq.select(2)) == 1 # x
|
||||
|
||||
|
||||
def test_typstmath_double_brace_named(config):
|
||||
"""{{ content : label }} assigns an explicit label."""
|
||||
eq = TypstMath("{{ a + b : numerator }} / {{ c - d : denom }}", use_svg_cache=False)
|
||||
assert "numerator" in eq._group_labels
|
||||
assert "denom" in eq._group_labels
|
||||
assert len(eq.select("numerator")) == 3
|
||||
assert len(eq.select("denom")) == 3
|
||||
|
||||
|
||||
def test_typstmath_double_brace_mixed_named_auto(config):
|
||||
"""Named and auto-numbered groups can coexist."""
|
||||
eq = TypstMath("{{ a : lhs }} = {{ b }}", use_svg_cache=False)
|
||||
assert eq._group_labels == ["lhs", "_grp-0"]
|
||||
assert len(eq.select("lhs")) == 1
|
||||
assert len(eq.select(0)) == 1
|
||||
|
||||
|
||||
def test_typstmath_no_braces_no_preamble(config):
|
||||
"""Without {{ }}, the manimgrp preamble is not injected."""
|
||||
eq = TypstMath("a + b", use_svg_cache=False)
|
||||
assert eq._group_labels == []
|
||||
assert "manimgrp" not in eq.typst_preamble
|
||||
|
||||
|
||||
def test_typstmath_select_index_error(config):
|
||||
"""select(int) raises IndexError for out-of-range index."""
|
||||
eq = TypstMath("{{ a }}", use_svg_cache=False)
|
||||
with pytest.raises(IndexError, match="out of range"):
|
||||
eq.select(1)
|
||||
|
||||
|
||||
def test_typstmath_preprocessor_skips_strings():
|
||||
"""{{ }} inside string literals are not processed."""
|
||||
processed, labels = TypstMath._preprocess_groups('x =_("{{ not a group }}") z')
|
||||
assert labels == []
|
||||
assert "manimgrp" not in processed
|
||||
|
||||
|
||||
def test_typstmath_preprocessor_skips_content_blocks():
|
||||
"""{{ }} inside [...] content blocks are not processed."""
|
||||
processed, labels = TypstMath._preprocess_groups("[text {{ here }}] {{ real }}")
|
||||
assert labels == ["_grp-0"]
|
||||
assert processed.count("manimgrp") == 1
|
||||
|
||||
|
||||
# -- integration tests for existing APIs ------------------------------------
|
||||
|
||||
|
||||
def test_label_accepts_typst(config):
|
||||
"""Label accepts a prebuilt Typst mobject."""
|
||||
rendered = Typst("hello", use_svg_cache=False)
|
||||
label = Label(rendered)
|
||||
assert label.rendered_label is rendered
|
||||
|
||||
|
||||
def test_numberline_add_labels_with_typstmath_constructor_uses_typst(config):
|
||||
"""String labels use Typst text mode when label_constructor is TypstMath."""
|
||||
number_line = NumberLine(x_range=[-1, 1])
|
||||
number_line.add_labels({0: "origin"}, label_constructor=TypstMath)
|
||||
assert len(number_line.labels) == 1
|
||||
assert isinstance(number_line.labels[0], Typst)
|
||||
assert not isinstance(number_line.labels[0], TypstMath)
|
||||
|
||||
|
||||
def test_vector_scene_get_vector_label_accepts_typst(config):
|
||||
"""VectorScene accepts a prebuilt Typst label mobject."""
|
||||
scene = VectorScene()
|
||||
vector = Vector(RIGHT)
|
||||
label = Typst("v", use_svg_cache=False)
|
||||
returned = scene.get_vector_label(vector, label)
|
||||
assert returned is label
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
264
tests/module/utils/test_color_helpers.py
Normal file
264
tests/module/utils/test_color_helpers.py
Normal 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
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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
284
uv.lock
generated
|
|
@ -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]]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue