manim-simplex 0.2.1__tar.gz → 0.2.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. {manim_simplex-0.2.1 → manim_simplex-0.2.2}/PKG-INFO +2 -1
  2. {manim_simplex-0.2.1 → manim_simplex-0.2.2}/pyproject.toml +2 -1
  3. {manim_simplex-0.2.1 → manim_simplex-0.2.2}/src/simplex/engine/animations.py +6 -0
  4. {manim_simplex-0.2.1 → manim_simplex-0.2.2}/src/simplex/mobjects/__init__.py +13 -1
  5. manim_simplex-0.2.2/src/simplex/mobjects/paper.py +456 -0
  6. manim_simplex-0.2.2/tests/mobjects/test_paper.py +136 -0
  7. {manim_simplex-0.2.1 → manim_simplex-0.2.2}/uv.lock +19 -1
  8. {manim_simplex-0.2.1 → manim_simplex-0.2.2}/.gitignore +0 -0
  9. {manim_simplex-0.2.1 → manim_simplex-0.2.2}/.pre-commit-config.yaml +0 -0
  10. {manim_simplex-0.2.1 → manim_simplex-0.2.2}/.python-version +0 -0
  11. {manim_simplex-0.2.1 → manim_simplex-0.2.2}/CHANGELOG.md +0 -0
  12. {manim_simplex-0.2.1 → manim_simplex-0.2.2}/LICENSE +0 -0
  13. {manim_simplex-0.2.1 → manim_simplex-0.2.2}/README.md +0 -0
  14. {manim_simplex-0.2.1 → manim_simplex-0.2.2}/examples/README.md +0 -0
  15. {manim_simplex-0.2.1 → manim_simplex-0.2.2}/examples/glyph_map_demo.py +0 -0
  16. {manim_simplex-0.2.1 → manim_simplex-0.2.2}/examples/hello_slide.py +0 -0
  17. {manim_simplex-0.2.1 → manim_simplex-0.2.2}/examples/manim.cfg +0 -0
  18. {manim_simplex-0.2.1 → manim_simplex-0.2.2}/examples/outline_slide.py +0 -0
  19. {manim_simplex-0.2.1 → manim_simplex-0.2.2}/examples/theme_demo.py +0 -0
  20. {manim_simplex-0.2.1 → manim_simplex-0.2.2}/ruff.toml +0 -0
  21. {manim_simplex-0.2.1 → manim_simplex-0.2.2}/src/simplex/engine/README.md +0 -0
  22. {manim_simplex-0.2.1 → manim_simplex-0.2.2}/src/simplex/engine/__init__.py +0 -0
  23. {manim_simplex-0.2.1 → manim_simplex-0.2.2}/src/simplex/engine/code.py +0 -0
  24. {manim_simplex-0.2.1 → manim_simplex-0.2.2}/src/simplex/engine/debug.py +0 -0
  25. {manim_simplex-0.2.1 → manim_simplex-0.2.2}/src/simplex/engine/defaults.py +0 -0
  26. {manim_simplex-0.2.1 → manim_simplex-0.2.2}/src/simplex/engine/dynamics.py +0 -0
  27. {manim_simplex-0.2.1 → manim_simplex-0.2.2}/src/simplex/engine/geometry.py +0 -0
  28. {manim_simplex-0.2.1 → manim_simplex-0.2.2}/src/simplex/engine/ghost_fade.py +0 -0
  29. {manim_simplex-0.2.1 → manim_simplex-0.2.2}/src/simplex/engine/glyph_map.py +0 -0
  30. {manim_simplex-0.2.1 → manim_simplex-0.2.2}/src/simplex/engine/region.py +0 -0
  31. {manim_simplex-0.2.1 → manim_simplex-0.2.2}/src/simplex/engine/scaling.py +0 -0
  32. {manim_simplex-0.2.1 → manim_simplex-0.2.2}/src/simplex/engine/text.py +0 -0
  33. {manim_simplex-0.2.1 → manim_simplex-0.2.2}/src/simplex/manifest.py +0 -0
  34. {manim_simplex-0.2.1 → manim_simplex-0.2.2}/src/simplex/mobjects/README.md +0 -0
  35. {manim_simplex-0.2.1 → manim_simplex-0.2.2}/src/simplex/mobjects/array.py +0 -0
  36. {manim_simplex-0.2.1 → manim_simplex-0.2.2}/src/simplex/mobjects/graph.py +0 -0
  37. {manim_simplex-0.2.1 → manim_simplex-0.2.2}/src/simplex/mobjects/outline.py +0 -0
  38. {manim_simplex-0.2.1 → manim_simplex-0.2.2}/src/simplex/plugin.py +0 -0
  39. {manim_simplex-0.2.1 → manim_simplex-0.2.2}/src/simplex/py.typed +0 -0
  40. {manim_simplex-0.2.1 → manim_simplex-0.2.2}/src/simplex/section.py +0 -0
  41. {manim_simplex-0.2.1 → manim_simplex-0.2.2}/src/simplex/slides/README.md +0 -0
  42. {manim_simplex-0.2.1 → manim_simplex-0.2.2}/src/simplex/slides/__init__.py +0 -0
  43. {manim_simplex-0.2.1 → manim_simplex-0.2.2}/src/simplex/slides/base.py +0 -0
  44. {manim_simplex-0.2.1 → manim_simplex-0.2.2}/src/simplex/slides/chrome.py +0 -0
  45. {manim_simplex-0.2.1 → manim_simplex-0.2.2}/src/simplex/slides/outline.py +0 -0
  46. {manim_simplex-0.2.1 → manim_simplex-0.2.2}/src/simplex/theme/README.md +0 -0
  47. {manim_simplex-0.2.1 → manim_simplex-0.2.2}/src/simplex/theme/__init__.py +0 -0
  48. {manim_simplex-0.2.1 → manim_simplex-0.2.2}/src/simplex/theme/context.py +0 -0
  49. {manim_simplex-0.2.1 → manim_simplex-0.2.2}/src/simplex/theme/presets.py +0 -0
  50. {manim_simplex-0.2.1 → manim_simplex-0.2.2}/src/simplex/theme/pygments_style.py +0 -0
  51. {manim_simplex-0.2.1 → manim_simplex-0.2.2}/src/simplex/theme/tokens.py +0 -0
  52. {manim_simplex-0.2.1 → manim_simplex-0.2.2}/src/simplex/theme/web_css.py +0 -0
  53. {manim_simplex-0.2.1 → manim_simplex-0.2.2}/tests/README.md +0 -0
  54. {manim_simplex-0.2.1 → manim_simplex-0.2.2}/tests/__init__.py +0 -0
  55. {manim_simplex-0.2.1 → manim_simplex-0.2.2}/tests/engine/README.md +0 -0
  56. {manim_simplex-0.2.1 → manim_simplex-0.2.2}/tests/engine/__init__.py +0 -0
  57. {manim_simplex-0.2.1 → manim_simplex-0.2.2}/tests/engine/test_animations.py +0 -0
  58. {manim_simplex-0.2.1 → manim_simplex-0.2.2}/tests/engine/test_code.py +0 -0
  59. {manim_simplex-0.2.1 → manim_simplex-0.2.2}/tests/engine/test_debug.py +0 -0
  60. {manim_simplex-0.2.1 → manim_simplex-0.2.2}/tests/engine/test_dynamics.py +0 -0
  61. {manim_simplex-0.2.1 → manim_simplex-0.2.2}/tests/engine/test_geometry.py +0 -0
  62. {manim_simplex-0.2.1 → manim_simplex-0.2.2}/tests/engine/test_ghost_fade.py +0 -0
  63. {manim_simplex-0.2.1 → manim_simplex-0.2.2}/tests/engine/test_glyph_map.py +0 -0
  64. {manim_simplex-0.2.1 → manim_simplex-0.2.2}/tests/engine/test_region.py +0 -0
  65. {manim_simplex-0.2.1 → manim_simplex-0.2.2}/tests/engine/test_scaling.py +0 -0
  66. {manim_simplex-0.2.1 → manim_simplex-0.2.2}/tests/engine/test_text.py +0 -0
  67. {manim_simplex-0.2.1 → manim_simplex-0.2.2}/tests/mobjects/README.md +0 -0
  68. {manim_simplex-0.2.1 → manim_simplex-0.2.2}/tests/mobjects/__init__.py +0 -0
  69. {manim_simplex-0.2.1 → manim_simplex-0.2.2}/tests/mobjects/test_graph.py +0 -0
  70. {manim_simplex-0.2.1 → manim_simplex-0.2.2}/tests/mobjects/test_outline.py +0 -0
  71. {manim_simplex-0.2.1 → manim_simplex-0.2.2}/tests/slides/README.md +0 -0
  72. {manim_simplex-0.2.1 → manim_simplex-0.2.2}/tests/slides/__init__.py +0 -0
  73. {manim_simplex-0.2.1 → manim_simplex-0.2.2}/tests/slides/test_base.py +0 -0
  74. {manim_simplex-0.2.1 → manim_simplex-0.2.2}/tests/slides/test_chrome.py +0 -0
  75. {manim_simplex-0.2.1 → manim_simplex-0.2.2}/tests/slides/test_outline.py +0 -0
  76. {manim_simplex-0.2.1 → manim_simplex-0.2.2}/tests/test_manifest.py +0 -0
  77. {manim_simplex-0.2.1 → manim_simplex-0.2.2}/tests/test_section.py +0 -0
  78. {manim_simplex-0.2.1 → manim_simplex-0.2.2}/tests/theme/README.md +0 -0
  79. {manim_simplex-0.2.1 → manim_simplex-0.2.2}/tests/theme/__init__.py +0 -0
  80. {manim_simplex-0.2.1 → manim_simplex-0.2.2}/tests/theme/test_tokens.py +0 -0
  81. {manim_simplex-0.2.1 → manim_simplex-0.2.2}/tests/theme/test_web_css.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: manim-simplex
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: Manim plugin: theme tokens, mobjects, slide hierarchy, deck manifest schema.
5
5
  Project-URL: Changelog, https://github.com/shlomi-perles/manim-simplex/blob/main/CHANGELOG.md
6
6
  Project-URL: Homepage, https://github.com/shlomi-perles/manim-simplex
@@ -28,6 +28,7 @@ Requires-Dist: manim-slides>=5.1.7
28
28
  Requires-Dist: manim>=0.20.1
29
29
  Requires-Dist: pydantic>=2.7
30
30
  Requires-Dist: pygments>=2.18
31
+ Requires-Dist: pymupdf>=1.27.2.3
31
32
  Description-Content-Type: text/markdown
32
33
 
33
34
  # manim-simplex
@@ -33,6 +33,7 @@ dependencies = [
33
33
  "manim-slides>=5.1.7",
34
34
  "pydantic>=2.7",
35
35
  "pygments>=2.18",
36
+ "pymupdf>=1.27.2.3",
36
37
  ]
37
38
  description = "Manim plugin: theme tokens, mobjects, slide hierarchy, deck manifest schema."
38
39
  keywords = [
@@ -50,7 +51,7 @@ license-files = ["LICENSE"]
50
51
  name = "manim-simplex"
51
52
  readme = "README.md"
52
53
  requires-python = ">=3.13"
53
- version = "0.2.1"
54
+ version = "0.2.2"
54
55
 
55
56
  [project.entry-points."manim.plugins"]
56
57
  simplex = "simplex.plugin:activate"
@@ -66,10 +66,16 @@ class _DefaultRegistry:
66
66
  VMobject,
67
67
  )
68
68
 
69
+ from simplex.mobjects.paper import DismissPaper, Paper
70
+
69
71
  def fade_with_drift(m: Any, **kw: Any) -> Any:
70
72
  return FadeOut(m, shift=0.1 * DOWN, **kw)
71
73
 
74
+ def paper_exit(m: Any, **kw: Any) -> Any:
75
+ return DismissPaper(m, **kw)
76
+
72
77
  return {
78
+ Paper: paper_exit,
73
79
  Tex: Unwrite,
74
80
  MathTex: Unwrite,
75
81
  Text: Unwrite,
@@ -9,5 +9,17 @@ slide system.
9
9
  from simplex.mobjects.array import ArrayEntry, ArrayMob, ArrayPointer
10
10
  from simplex.mobjects.graph import Edge, Node
11
11
  from simplex.mobjects.outline import OutlineProgressBar
12
+ from simplex.mobjects.paper import DismissPaper, Paper, PickPage, ShowPaper
12
13
 
13
- __all__ = ["ArrayEntry", "ArrayMob", "ArrayPointer", "Edge", "Node", "OutlineProgressBar"]
14
+ __all__ = [
15
+ "ArrayEntry",
16
+ "ArrayMob",
17
+ "ArrayPointer",
18
+ "DismissPaper",
19
+ "Edge",
20
+ "Node",
21
+ "OutlineProgressBar",
22
+ "Paper",
23
+ "PickPage",
24
+ "ShowPaper",
25
+ ]
@@ -0,0 +1,456 @@
1
+ """Paper mobject -- render academic papers (ArXiv / local PDF / BibTeX) as stacked page images.
2
+
3
+ Provides:
4
+ - ``Paper``: a ``Group`` of ``ImageMobject`` pages with configurable shadow and stacking.
5
+ - ``ShowPaper``: intro animation that builds the stacked view.
6
+ - ``DismissPaper``: exit animation — delegates to ``ShowPaper`` with reversed fade direction.
7
+ - ``PickPage``: pull-from-stack animation for a given page index.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ import re
14
+ import urllib.request
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ import numpy as np
19
+ import pymupdf
20
+ from manim import (
21
+ DOWN,
22
+ LEFT,
23
+ RIGHT,
24
+ UP,
25
+ WHITE,
26
+ Animation,
27
+ AnimationGroup,
28
+ FadeIn,
29
+ FadeOut,
30
+ Group,
31
+ ImageMobject,
32
+ Rectangle,
33
+ RoundedRectangle,
34
+ config,
35
+ smooth,
36
+ )
37
+ from manim.utils.tex_file_writing import tex_hash
38
+
39
+ logger = logging.getLogger("simplex.paper")
40
+
41
+ _DEFAULT_DPI = 150
42
+ _DEFAULT_PAGES = 3
43
+ _DEFAULT_TIMEOUT = 30
44
+ _SHADOW_OPACITY = 0.35
45
+ _SHADOW_COLOR = "#000000"
46
+ _SHADOW_OFFSET_FACTOR = 0.06
47
+ _STACK_OFFSET_FACTOR = 0.08
48
+ _PAGE_HEIGHT = 5.5
49
+ _BORDER_COLOR = WHITE
50
+ _BORDER_STROKE_WIDTH = 1.5
51
+
52
+ _ARXIV_ABS_RE = re.compile(r"arxiv\.org/abs/(.+?)(?:\?|$)")
53
+ _ARXIV_PDF_RE = re.compile(r"arxiv\.org/pdf/(.+?)(?:\.pdf)?(?:\?|$)")
54
+
55
+
56
+ def _paper_dir() -> Path:
57
+ """Return (and create) the paper cache directory inside Manim's media tree."""
58
+ d = Path(config.media_dir) / "papers"
59
+ d.mkdir(parents=True, exist_ok=True)
60
+ return d
61
+
62
+
63
+ def _url_to_pdf_url(url: str) -> str:
64
+ """Normalize an ArXiv URL to a direct PDF download link."""
65
+ if m := _ARXIV_ABS_RE.search(url):
66
+ return f"https://arxiv.org/pdf/{m.group(1)}.pdf"
67
+ if _ARXIV_PDF_RE.search(url):
68
+ return url if url.endswith(".pdf") else url + ".pdf"
69
+ return url
70
+
71
+
72
+ def _download_pdf(url: str, *, timeout: int = _DEFAULT_TIMEOUT) -> Path:
73
+ """Download a PDF from *url*, caching on disk. Returns local path."""
74
+ key = tex_hash(url)
75
+ cached = _paper_dir() / f"{key}.pdf"
76
+ if cached.exists():
77
+ return cached
78
+ pdf_url = _url_to_pdf_url(url)
79
+ if not pdf_url.startswith(("https://", "http://")):
80
+ raise ValueError(f"Refusing to open non-HTTP URL: {pdf_url}")
81
+ logger.info("Downloading %s → %s", pdf_url, cached)
82
+ req = urllib.request.Request(pdf_url, headers={"User-Agent": "manim-simplex/0.2"}) # noqa: S310
83
+ with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310
84
+ cached.write_bytes(resp.read())
85
+ return cached
86
+
87
+
88
+ def _resolve_bibtex_source(bib_path: Path, cite_key: str) -> str:
89
+ """Extract an ArXiv URL or ``eprint`` field from a BibTeX entry."""
90
+ text = bib_path.read_text()
91
+ pattern = re.compile(
92
+ rf"@\w+\{{\s*{re.escape(cite_key)}\s*,(.*?)\n\s*\}}",
93
+ re.DOTALL,
94
+ )
95
+ match = pattern.search(text)
96
+ if not match:
97
+ raise ValueError(f"Cite key '{cite_key}' not found in {bib_path}")
98
+ body = match.group(1)
99
+ if ep := re.search(r"eprint\s*=\s*\{?([0-9.]+)\}?", body):
100
+ return f"https://arxiv.org/abs/{ep.group(1)}"
101
+ if url_match := re.search(r"url\s*=\s*\{(.+?)\}", body):
102
+ return url_match.group(1)
103
+ raise ValueError(f"No ArXiv eprint or URL found for '{cite_key}' in {bib_path}")
104
+
105
+
106
+ def _render_pages(
107
+ pdf_path: Path,
108
+ *,
109
+ pages: int = _DEFAULT_PAGES,
110
+ dpi: int = _DEFAULT_DPI,
111
+ ) -> list[Path]:
112
+ """Render the first *pages* pages of a PDF to cached PNGs."""
113
+ key = tex_hash(str(pdf_path))
114
+ cache = _paper_dir() / key
115
+ cache.mkdir(parents=True, exist_ok=True)
116
+
117
+ rendered: list[Path] = []
118
+ doc = pymupdf.open(pdf_path)
119
+ n = min(pages, len(doc))
120
+ for i in range(n):
121
+ out = cache / f"page_{i}_dpi{dpi}.png"
122
+ if not out.exists():
123
+ page = doc[i]
124
+ zoom = dpi / 72.0
125
+ mat = pymupdf.Matrix(zoom, zoom)
126
+ pix = page.get_pixmap(matrix=mat, alpha=False)
127
+ pix.save(out)
128
+ logger.info("Rendered %s page %d → %s", pdf_path.name, i, out)
129
+ rendered.append(out)
130
+ doc.close()
131
+ return rendered
132
+
133
+
134
+ # ---------------------------------------------------------------------------
135
+ # Direction parsing (shared by Paper and animation classes)
136
+ # ---------------------------------------------------------------------------
137
+
138
+ _DIRECTION_MAP: dict[str, np.ndarray] = {
139
+ "dl": DOWN + LEFT,
140
+ "dr": DOWN + RIGHT,
141
+ "ul": UP + LEFT,
142
+ "ur": UP + RIGHT,
143
+ "left": LEFT,
144
+ "right": RIGHT,
145
+ "up": UP,
146
+ "down": DOWN,
147
+ }
148
+
149
+
150
+ def _parse_direction(direction: str | np.ndarray) -> np.ndarray:
151
+ if isinstance(direction, str):
152
+ key = direction.lower().strip()
153
+ if key in _DIRECTION_MAP:
154
+ return _DIRECTION_MAP[key]
155
+ raise ValueError(f"Unknown direction '{direction}'.")
156
+ return np.asarray(direction, dtype=float)
157
+
158
+
159
+ # ---------------------------------------------------------------------------
160
+ # Paper mobject
161
+ # ---------------------------------------------------------------------------
162
+
163
+
164
+ class Paper(Group):
165
+ """A stack of rendered PDF pages displayed as ``ImageMobject`` instances.
166
+
167
+ Parameters
168
+ ----------
169
+ source
170
+ One of: ArXiv URL, local PDF path, or ``(bib_path, cite_key)`` tuple.
171
+ pages
172
+ Number of pages to render (from the start of the document).
173
+ dpi
174
+ Resolution for PDF-to-image conversion.
175
+ page_height
176
+ Target height of each page in Manim units.
177
+ shadow
178
+ Whether to render a drop shadow behind pages.
179
+ shadow_direction
180
+ Direction the shadow falls (offset direction from page center).
181
+ shadow_opacity
182
+ Fill opacity of the shadow rectangles.
183
+ border
184
+ Whether to draw a thin border around each page.
185
+ border_color
186
+ Stroke color for the page border.
187
+ border_stroke_width
188
+ Stroke width for the page border.
189
+ stack_direction
190
+ Direction pages stack towards (the offset axis for peeking edges).
191
+ stack_offset
192
+ Distance between consecutive pages in the stack (Manim units).
193
+ timeout
194
+ Network timeout in seconds for downloading.
195
+ """
196
+
197
+ def __init__(
198
+ self,
199
+ source: str | Path | tuple[Path | str, str],
200
+ *,
201
+ pages: int = _DEFAULT_PAGES,
202
+ dpi: int = _DEFAULT_DPI,
203
+ page_height: float = _PAGE_HEIGHT,
204
+ shadow: bool = True,
205
+ shadow_direction: str | np.ndarray = "DL",
206
+ shadow_opacity: float = _SHADOW_OPACITY,
207
+ border: bool = True,
208
+ border_color: str = _BORDER_COLOR,
209
+ border_stroke_width: float = _BORDER_STROKE_WIDTH,
210
+ stack_direction: str | np.ndarray = "DL",
211
+ stack_offset: float | None = None,
212
+ timeout: int = _DEFAULT_TIMEOUT,
213
+ **kwargs: Any,
214
+ ) -> None:
215
+ self._shadow_enabled = shadow
216
+
217
+ pdf_path = self._resolve_source(source, timeout=timeout)
218
+ image_paths = _render_pages(pdf_path, pages=pages, dpi=dpi)
219
+
220
+ shadow_dir = _parse_direction(shadow_direction)
221
+ self._stack_dir = _parse_direction(stack_direction)
222
+ self._stack_offset = (
223
+ stack_offset if stack_offset is not None else page_height * _STACK_OFFSET_FACTOR
224
+ )
225
+
226
+ page_groups: list[Group] = []
227
+ for img_path in image_paths:
228
+ img = ImageMobject(str(img_path))
229
+ img.height = page_height
230
+
231
+ parts: list[Any] = []
232
+
233
+ if shadow:
234
+ shadow_rect = RoundedRectangle(
235
+ width=img.width,
236
+ height=img.height,
237
+ corner_radius=0.04,
238
+ fill_color=_SHADOW_COLOR,
239
+ fill_opacity=shadow_opacity,
240
+ stroke_width=0,
241
+ )
242
+ shadow_offset = shadow_dir * page_height * _SHADOW_OFFSET_FACTOR
243
+ shadow_rect.move_to(img.get_center() + shadow_offset)
244
+ parts.append(shadow_rect)
245
+
246
+ parts.append(img)
247
+
248
+ if border:
249
+ border_rect = Rectangle(
250
+ width=img.width,
251
+ height=img.height,
252
+ stroke_color=border_color,
253
+ stroke_width=border_stroke_width,
254
+ stroke_opacity=0.6,
255
+ fill_opacity=0,
256
+ )
257
+ border_rect.move_to(img.get_center())
258
+ parts.append(border_rect)
259
+
260
+ page_groups.append(Group(*parts))
261
+
262
+ # _page_groups[0] = top/front page (first PDF page), drawn last.
263
+ # Submobjects stored back-to-front for correct z-order.
264
+ self._page_groups = page_groups
265
+ super().__init__(*reversed(page_groups), **kwargs)
266
+ self._arrange_stack()
267
+
268
+ # -- source resolution ---------------------------------------------------
269
+
270
+ def _resolve_source(self, source: str | Path | tuple[Path | str, str], *, timeout: int) -> Path:
271
+ if isinstance(source, tuple):
272
+ bib_path, cite_key = source
273
+ url = _resolve_bibtex_source(Path(bib_path), cite_key)
274
+ return _download_pdf(url, timeout=timeout)
275
+ source_str = str(source)
276
+ if source_str.startswith(("http://", "https://")):
277
+ return _download_pdf(source_str, timeout=timeout)
278
+ path = Path(source_str)
279
+ if not path.exists():
280
+ raise FileNotFoundError(f"PDF not found: {path}")
281
+ return path
282
+
283
+ # -- layout --------------------------------------------------------------
284
+
285
+ def _arrange_stack(self) -> None:
286
+ """Position pages: page 0 at origin (top), others offset behind."""
287
+ for i, pg in enumerate(self._page_groups):
288
+ pg.move_to(self._stack_dir * self._stack_offset * i)
289
+
290
+ # -- public API ----------------------------------------------------------
291
+
292
+ @property
293
+ def page_groups(self) -> list[Group]:
294
+ return list(self._page_groups)
295
+
296
+ @property
297
+ def page_count(self) -> int:
298
+ return len(self._page_groups)
299
+
300
+ def get_page(self, index: int) -> Group:
301
+ return self._page_groups[index]
302
+
303
+ def get_top_page(self) -> Group:
304
+ return self._page_groups[0]
305
+
306
+ def reorder_page_to_top(self, index: int) -> None:
307
+ """Move page at *index* to position 0 (front of stack, drawn last)."""
308
+ page = self._page_groups.pop(index)
309
+ self._page_groups.insert(0, page)
310
+ self.submobjects = list(reversed(self._page_groups))
311
+ self._arrange_stack()
312
+
313
+
314
+ # ---------------------------------------------------------------------------
315
+ # Animations
316
+ # ---------------------------------------------------------------------------
317
+
318
+
319
+ class ShowPaper(AnimationGroup):
320
+ """Intro animation: pages cascade in with a lagged stagger.
321
+
322
+ Back pages appear first, then the front page lands on top — giving a
323
+ natural "dealing cards" effect.
324
+
325
+ When *dismiss* is ``True`` the animation flips to ``FadeOut`` and the
326
+ cascade order reverses (front page exits first), so ``DismissPaper``
327
+ can delegate here without duplicating the logic.
328
+
329
+ Parameters
330
+ ----------
331
+ paper
332
+ The Paper mobject to animate.
333
+ direction
334
+ Direction from which pages slide in (intro) or out (dismiss).
335
+ lag_ratio
336
+ Stagger between successive page animations.
337
+ dismiss
338
+ If ``True``, use ``FadeOut`` (exit) instead of ``FadeIn`` (intro).
339
+ """
340
+
341
+ def __init__(
342
+ self,
343
+ paper: Paper,
344
+ *,
345
+ direction: str | np.ndarray = "DOWN",
346
+ lag_ratio: float = 0.3,
347
+ dismiss: bool = False,
348
+ **kwargs: Any,
349
+ ) -> None:
350
+ shift_dir = _parse_direction(direction)
351
+ shift_vec = shift_dir * 2.0
352
+ anim_cls = FadeOut if dismiss else FadeIn
353
+
354
+ # Intro: back-to-front (last page first, top page last).
355
+ # Dismiss: front-to-back (top page first, last page last).
356
+ ordering = paper.page_groups if dismiss else list(reversed(paper.page_groups))
357
+
358
+ anims = [anim_cls(pg, shift=shift_vec) for pg in ordering]
359
+ kwargs.setdefault("run_time", 1.5)
360
+ super().__init__(*anims, lag_ratio=lag_ratio, **kwargs)
361
+
362
+
363
+ class DismissPaper(ShowPaper):
364
+ """Exit animation — syntactic sugar for ``ShowPaper(..., dismiss=True)``."""
365
+
366
+ def __init__(
367
+ self,
368
+ paper: Paper,
369
+ *,
370
+ direction: str | np.ndarray = "DOWN",
371
+ lag_ratio: float = 0.3,
372
+ **kwargs: Any,
373
+ ) -> None:
374
+ super().__init__(paper, direction=direction, lag_ratio=lag_ratio, dismiss=True, **kwargs)
375
+
376
+
377
+ class PickPage(Animation):
378
+ """Animate a page sliding out of the stack, then moving to the top/front.
379
+
380
+ The target page slides out in *slide_direction*, pauses visibly beside
381
+ the stack, then slides back to position 0 (the front). The remaining
382
+ pages re-settle to fill the gap.
383
+
384
+ Parameters
385
+ ----------
386
+ paper
387
+ The Paper mobject containing the stack.
388
+ page_index
389
+ Which page to pick (0 = current top; 1+ = pages behind it).
390
+ slide_direction
391
+ Direction the page slides out to before returning to top.
392
+ overshoot
393
+ How far (Manim units) the page travels out before settling.
394
+ """
395
+
396
+ def __init__(
397
+ self,
398
+ paper: Paper,
399
+ page_index: int = 1,
400
+ *,
401
+ slide_direction: str | np.ndarray = "RIGHT",
402
+ overshoot: float = 3.0,
403
+ **kwargs: Any,
404
+ ) -> None:
405
+ if page_index < 0 or page_index >= paper.page_count:
406
+ raise IndexError(f"page_index {page_index} out of range [0, {paper.page_count})")
407
+ self._paper = paper
408
+ self._page_index = page_index
409
+ self._slide_vec = _parse_direction(slide_direction) * overshoot
410
+ kwargs.setdefault("run_time", 2.0)
411
+ super().__init__(paper, **kwargs)
412
+
413
+ def begin(self) -> None:
414
+ self._page = self._paper.get_page(self._page_index)
415
+ self._start_pos = self._page.get_center().copy()
416
+
417
+ self._other_pages_start: list[np.ndarray] = []
418
+ for i, pg in enumerate(self._paper.page_groups):
419
+ if i != self._page_index:
420
+ self._other_pages_start.append(pg.get_center().copy())
421
+
422
+ self._paper.reorder_page_to_top(self._page_index)
423
+
424
+ self._end_pos = self._paper._stack_dir * self._paper._stack_offset * 0
425
+ self._midpoint = self._start_pos + self._slide_vec
426
+
427
+ self._other_pages_end: list[np.ndarray] = []
428
+ for i, _pg in enumerate(self._paper.page_groups[1:], start=1):
429
+ self._other_pages_end.append(self._paper._stack_dir * self._paper._stack_offset * i)
430
+
431
+ self._other_pages = self._paper.page_groups[1:]
432
+ super().begin()
433
+
434
+ def interpolate_mobject(self, alpha: float) -> None:
435
+ t = smooth(alpha)
436
+
437
+ if t < 0.5:
438
+ sub_t = t * 2.0
439
+ pos = self._start_pos + (self._midpoint - self._start_pos) * sub_t
440
+ else:
441
+ sub_t = (t - 0.5) * 2.0
442
+ pos = self._midpoint + (self._end_pos - self._midpoint) * sub_t
443
+
444
+ self._page.move_to(pos)
445
+
446
+ settle_t = min(t * 2.0, 1.0)
447
+ for i, pg in enumerate(self._other_pages):
448
+ start = self._other_pages_start[i]
449
+ end = self._other_pages_end[i]
450
+ pg.move_to(start + (end - start) * settle_t)
451
+
452
+ def finish(self) -> None:
453
+ self._page.move_to(self._end_pos)
454
+ for i, pg in enumerate(self._other_pages):
455
+ pg.move_to(self._other_pages_end[i])
456
+ super().finish()
@@ -0,0 +1,136 @@
1
+ """Tests for the Paper mobject and its animations."""
2
+
3
+ from pathlib import Path
4
+
5
+ import pytest
6
+
7
+ pytest.importorskip("manim")
8
+
9
+ import numpy as np
10
+
11
+ from simplex.mobjects.paper import (
12
+ DismissPaper,
13
+ Paper,
14
+ PickPage,
15
+ ShowPaper,
16
+ _render_pages,
17
+ _url_to_pdf_url,
18
+ )
19
+
20
+
21
+ @pytest.fixture
22
+ def sample_pdf(tmp_path: Path) -> Path:
23
+ """Create a minimal multi-page PDF for testing."""
24
+ import pymupdf
25
+
26
+ pdf_path = tmp_path / "test.pdf"
27
+ doc = pymupdf.open()
28
+ for i in range(5):
29
+ page = doc.new_page(width=612, height=792)
30
+ tw = pymupdf.TextWriter(page.rect)
31
+ tw.append((72, 72), f"Page {i + 1}", fontsize=24)
32
+ tw.write_text(page)
33
+ doc.save(pdf_path)
34
+ doc.close()
35
+ return pdf_path
36
+
37
+
38
+ def test_arxiv_url_normalization() -> None:
39
+ assert _url_to_pdf_url("https://arxiv.org/abs/1706.03762") == (
40
+ "https://arxiv.org/pdf/1706.03762.pdf"
41
+ )
42
+ assert _url_to_pdf_url("https://arxiv.org/pdf/1706.03762") == (
43
+ "https://arxiv.org/pdf/1706.03762.pdf"
44
+ )
45
+ assert _url_to_pdf_url("https://arxiv.org/pdf/1706.03762.pdf") == (
46
+ "https://arxiv.org/pdf/1706.03762.pdf"
47
+ )
48
+
49
+
50
+ def test_render_pages_creates_images(sample_pdf: Path) -> None:
51
+ pages = _render_pages(sample_pdf, pages=3, dpi=72)
52
+ assert len(pages) == 3
53
+ for p in pages:
54
+ assert p.exists()
55
+ assert p.suffix == ".png"
56
+
57
+
58
+ def test_render_pages_clamps_to_document_length(sample_pdf: Path) -> None:
59
+ pages = _render_pages(sample_pdf, pages=100, dpi=72)
60
+ assert len(pages) == 5
61
+
62
+
63
+ def test_paper_constructs_with_local_pdf(sample_pdf: Path) -> None:
64
+ paper = Paper(sample_pdf, pages=3, dpi=72, page_height=4.0)
65
+ assert paper.page_count == 3
66
+ assert len(paper.submobjects) == 3
67
+
68
+
69
+ def test_paper_top_page_at_origin(sample_pdf: Path) -> None:
70
+ paper = Paper(sample_pdf, pages=3, dpi=72, page_height=4.0)
71
+ assert np.allclose(paper.get_top_page().get_center(), [0, 0, 0], atol=0.01)
72
+
73
+
74
+ def test_paper_reorder_to_top(sample_pdf: Path) -> None:
75
+ paper = Paper(sample_pdf, pages=3, dpi=72, page_height=4.0)
76
+ original_back = paper.get_page(2)
77
+ paper.reorder_page_to_top(2)
78
+ assert paper.get_top_page() is original_back
79
+
80
+
81
+ def test_show_paper_constructs(sample_pdf: Path) -> None:
82
+ paper = Paper(sample_pdf, pages=2, dpi=72, page_height=3.0)
83
+ anim = ShowPaper(paper, direction="DOWN")
84
+ assert anim.run_time == 1.5
85
+
86
+
87
+ def test_dismiss_paper_constructs(sample_pdf: Path) -> None:
88
+ paper = Paper(sample_pdf, pages=2, dpi=72, page_height=3.0)
89
+ anim = DismissPaper(paper, direction="UP")
90
+ assert anim.run_time == 1.5
91
+
92
+
93
+ def test_pick_page_constructs(sample_pdf: Path) -> None:
94
+ paper = Paper(sample_pdf, pages=3, dpi=72, page_height=3.0)
95
+ anim = PickPage(paper, page_index=2, slide_direction="RIGHT")
96
+ assert anim.run_time == 2.0
97
+
98
+
99
+ def test_pick_page_out_of_range_raises(sample_pdf: Path) -> None:
100
+ paper = Paper(sample_pdf, pages=3, dpi=72, page_height=3.0)
101
+ with pytest.raises(IndexError):
102
+ PickPage(paper, page_index=5)
103
+
104
+
105
+ def test_paper_exit_animation_is_dismiss(sample_pdf: Path) -> None:
106
+ from simplex.engine.animations import exit_for
107
+
108
+ paper = Paper(sample_pdf, pages=2, dpi=72, page_height=3.0)
109
+ anim = exit_for(paper)
110
+ assert isinstance(anim, DismissPaper)
111
+
112
+
113
+ def test_paper_without_shadow(sample_pdf: Path) -> None:
114
+ paper = Paper(sample_pdf, pages=2, dpi=72, page_height=3.0, shadow=False, border=False)
115
+ assert paper.page_count == 2
116
+ for pg in paper.page_groups:
117
+ assert len(pg.submobjects) == 1
118
+
119
+
120
+ def test_paper_with_border_no_shadow(sample_pdf: Path) -> None:
121
+ paper = Paper(sample_pdf, pages=2, dpi=72, page_height=3.0, shadow=False, border=True)
122
+ assert paper.page_count == 2
123
+ for pg in paper.page_groups:
124
+ assert len(pg.submobjects) == 2
125
+
126
+
127
+ def test_paper_with_shadow_and_border(sample_pdf: Path) -> None:
128
+ paper = Paper(sample_pdf, pages=2, dpi=72, page_height=3.0, shadow=True, border=True)
129
+ for pg in paper.page_groups:
130
+ assert len(pg.submobjects) == 3
131
+
132
+
133
+ def test_dismiss_is_show_subclass(sample_pdf: Path) -> None:
134
+ paper = Paper(sample_pdf, pages=2, dpi=72, page_height=3.0)
135
+ anim = DismissPaper(paper, direction="UP")
136
+ assert isinstance(anim, ShowPaper)
@@ -443,13 +443,14 @@ wheels = [
443
443
 
444
444
  [[package]]
445
445
  name = "manim-simplex"
446
- version = "0.2.1"
446
+ version = "0.2.2"
447
447
  source = { editable = "." }
448
448
  dependencies = [
449
449
  { name = "manim" },
450
450
  { name = "manim-slides" },
451
451
  { name = "pydantic" },
452
452
  { name = "pygments" },
453
+ { name = "pymupdf" },
453
454
  ]
454
455
 
455
456
  [package.dev-dependencies]
@@ -467,6 +468,7 @@ requires-dist = [
467
468
  { name = "manim-slides", specifier = ">=5.1.7" },
468
469
  { name = "pydantic", specifier = ">=2.7" },
469
470
  { name = "pygments", specifier = ">=2.18" },
471
+ { name = "pymupdf", specifier = ">=1.27.2.3" },
470
472
  ]
471
473
 
472
474
  [package.metadata.requires-dev]
@@ -1016,6 +1018,22 @@ wheels = [
1016
1018
  { 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" },
1017
1019
  ]
1018
1020
 
1021
+ [[package]]
1022
+ name = "pymupdf"
1023
+ version = "1.27.2.3"
1024
+ source = { registry = "https://pypi.org/simple" }
1025
+ sdist = { url = "https://files.pythonhosted.org/packages/22/32/708bedc9dde7b328d45abbc076091769d44f2f24ad151ad92d56a6ec142b/pymupdf-1.27.2.3.tar.gz", hash = "sha256:7a92faa25129e8bbec5e50eeb9214f187665428c31b05c4ef6e36c58c0b1c6d2", size = 85759618, upload-time = "2026-04-24T14:13:14.42Z" }
1026
+ wheels = [
1027
+ { url = "https://files.pythonhosted.org/packages/dc/09/ddbdfa7ee91fbabd6f63d7d744884cbdfe3e7ff9b8604749fb38bddf5c5d/pymupdf-1.27.2.3-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:fc1bc3cae6e9e150b0dbb0a9221bdfd411d65f0db2fe359eaa22467d7cc2a05f", size = 24002636, upload-time = "2026-04-24T14:09:17.459Z" },
1028
+ { url = "https://files.pythonhosted.org/packages/01/89/3f8edd6c4f50ca370e2a2f2a3011face36f3760728ffe76dffec91c0fca0/pymupdf-1.27.2.3-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:660d93cb6da5bbddf11d3982ae27745dd3a9902d9f24cdb69adab83962294b5a", size = 23278238, upload-time = "2026-04-24T14:09:32.882Z" },
1029
+ { url = "https://files.pythonhosted.org/packages/c3/26/b7e5a70eb83bd189f8b5df87ec442746b992f2f632662839b288170d357d/pymupdf-1.27.2.3-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:1dd460a3ae4597a755f00a3bd9771f5ebf1531dc111f6a36bf05dd00a6b84425", size = 24333923, upload-time = "2026-04-24T14:09:47.341Z" },
1030
+ { url = "https://files.pythonhosted.org/packages/e4/a0/aa1ee2240f29481a04a827c313333b4ecd8a14d6ac3e15d3f41a30574781/pymupdf-1.27.2.3-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:857842b4888827bd6155a1131341b2822a7ebe9a8c15a975fd7d490d7a64a30c", size = 24963198, upload-time = "2026-04-24T14:10:07.408Z" },
1031
+ { url = "https://files.pythonhosted.org/packages/69/49/4f742451f980840829fc00ba158bebb25d389c846d8f4f8c65936ee55de8/pymupdf-1.27.2.3-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:580983849c64a08d08344ca3d1580e87c01f046a8392421797bc850efd72a5b6", size = 25184609, upload-time = "2026-04-24T14:10:22.911Z" },
1032
+ { url = "https://files.pythonhosted.org/packages/f6/3f/3853d6608f394faf6eec2bd4e8ea9f6a00beea329b071abdb29f4164cc3d/pymupdf-1.27.2.3-cp310-abi3-win32.whl", hash = "sha256:a5c1088a87189891a4946ab314a14b7934ac4c5b6077f7e74ebee956f8906d0e", size = 18019286, upload-time = "2026-04-24T14:10:34.239Z" },
1033
+ { url = "https://files.pythonhosted.org/packages/44/47/5fb10fe73f96b31253a41647c362ea9e0380920bddf16028414a051247fc/pymupdf-1.27.2.3-cp310-abi3-win_amd64.whl", hash = "sha256:d20f68ef15195e073071dbc4ae7455257c7889af7584e39df490c0a92728526e", size = 19249102, upload-time = "2026-04-24T14:10:46.72Z" },
1034
+ { url = "https://files.pythonhosted.org/packages/53/a4/b9e91aac82293f9c954654c85581ee8212b5b05efadc534b581141241e6f/pymupdf-1.27.2.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:77691604c5d1d0233827139bbcdea61fd57879c84712b8e49b1f45520f7ab9c2", size = 25000393, upload-time = "2026-04-24T14:11:01.669Z" },
1035
+ ]
1036
+
1019
1037
  [[package]]
1020
1038
  name = "pyobjc-core"
1021
1039
  version = "12.1"
File without changes
File without changes
File without changes
File without changes