mirror of
https://github.com/ManimCommunity/manim.git
synced 2026-06-22 10:01:47 +00:00
* fix: replace double-brace splitting regex with state-machine parser
The previous re.split(r'{{|}}', ...) call split on any occurrence of
{{ or }} in the input string, which broke strings whose only }} came
from closing two nested LaTeX brace groups (e.g. ^{\frac{Mq}{M+m}}).
The new _split_double_braces() static method uses a character-level
state machine with three guards:
* Escape priority: \\ is consumed before \{ / \}, so \\}} is
correctly read as an escaped backslash followed by a real }}, not
misinterpreted as \ + \} + lone }.
* Whitespace-gated opener: {{ is only treated as a Manim group opener
at the start of the string or after whitespace. Naturally-occurring
{{ in LaTeX is usually preceded by non-whitespace (e.g. \frac{{{n}}}
or a^{{2}}), so this eliminates the most common false positives.
* Depth-tracking closer: inside a Manim group, }} only closes the
group when the inner brace depth is zero, so {{ a^{b^{c}} }} is
handled correctly and nested LaTeX }} cannot trigger an early close.
Fixes #4601.
* docs: document double-brace whitespace requirement
Add a Notes section to the MathTex docstring explaining:
- how {{ }} splits a string into submobjects
- that {{ must be at start-of-string or preceded by whitespace
- that this leaves natural LaTeX like \frac{{{n}}}{k} untouched
- the { { ... } } escape hatch when a split is not wanted
Apply the same explanation to the double-brace paragraph in
docs/source/guides/using_text.rst.
* fix: use r-string for _split_double_braces docstring, halve backslash escaping
337 lines
11 KiB
Python
337 lines
11 KiB
Python
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
import pytest
|
|
|
|
from manim import MathTex, SingleStringMathTex, Tex, TexTemplate, tempconfig
|
|
|
|
|
|
def test_MathTex(config):
|
|
MathTex("a^2 + b^2 = c^2")
|
|
assert Path(config.media_dir, "Tex", "05bb0a41ed575f00.svg").exists()
|
|
|
|
|
|
def test_SingleStringMathTex(config):
|
|
SingleStringMathTex("test")
|
|
assert Path(config.media_dir, "Tex", "8ce17c7f5013209f.svg").exists()
|
|
|
|
|
|
@pytest.mark.parametrize( # : PT006
|
|
("text_input", "length_sub"),
|
|
[
|
|
("{{ 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()
|
|
|
|
|
|
def test_tex_temp_directory(tmpdir, monkeypatch):
|
|
# Adds a test for #3060
|
|
# It's not possible to reproduce the issue normally, because we use
|
|
# tempconfig to change media directory to temporary directory by default
|
|
# we partially, revert that change here.
|
|
monkeypatch.chdir(tmpdir)
|
|
Path(tmpdir, "media").mkdir(exist_ok=True)
|
|
with tempconfig({"media_dir": "media"}):
|
|
Tex("The horse does not eat cucumber salad.")
|
|
assert Path("media", "Tex").exists()
|
|
assert Path("media", "Tex", "5384b41741a246bd.svg").exists()
|
|
|
|
|
|
def test_percent_char_rendering(config):
|
|
Tex(r"\%")
|
|
assert Path(config.media_dir, "Tex", "32509dd0ea993961.tex").exists()
|
|
|
|
|
|
def test_tex_whitespace_arg():
|
|
"""Check that correct number of submobjects are created per string with whitespace separator"""
|
|
separator = "\t"
|
|
str_part_1 = "Hello"
|
|
str_part_2 = "world"
|
|
str_part_3 = "It is"
|
|
str_part_4 = "me!"
|
|
tex = Tex(str_part_1, str_part_2, str_part_3, str_part_4, arg_separator=separator)
|
|
assert len(tex) == 4
|
|
assert len(tex[0]) == len("".join((str_part_1 + separator).split()))
|
|
assert len(tex[1]) == len("".join((str_part_2 + separator).split()))
|
|
assert len(tex[2]) == len("".join((str_part_3 + separator).split()))
|
|
assert len(tex[3]) == len("".join(str_part_4.split()))
|
|
|
|
|
|
def test_tex_non_whitespace_arg():
|
|
"""Check that correct number of submobjects are created per string with non_whitespace characters"""
|
|
separator = ","
|
|
str_part_1 = "Hello"
|
|
str_part_2 = "world"
|
|
str_part_3 = "It is"
|
|
str_part_4 = "me!"
|
|
tex = Tex(str_part_1, str_part_2, str_part_3, str_part_4, arg_separator=separator)
|
|
assert len(tex) == 4
|
|
assert len(tex[0]) == len("".join((str_part_1 + separator).split()))
|
|
assert len(tex[1]) == len("".join((str_part_2 + separator).split()))
|
|
assert len(tex[2]) == len("".join((str_part_3 + separator).split()))
|
|
assert len(tex[3]) == len("".join(str_part_4.split()))
|
|
|
|
|
|
def test_tex_white_space_and_non_whitespace_args():
|
|
"""Check that correct number of submobjects are created per string when mixing characters with whitespace"""
|
|
separator = ", \n . \t\t"
|
|
str_part_1 = "Hello"
|
|
str_part_2 = "world"
|
|
str_part_3 = "It is"
|
|
str_part_4 = "me!"
|
|
tex = Tex(str_part_1, str_part_2, str_part_3, str_part_4, arg_separator=separator)
|
|
assert len(tex) == 4
|
|
assert len(tex[0]) == len("".join((str_part_1 + separator).split()))
|
|
assert len(tex[1]) == len("".join((str_part_2 + separator).split()))
|
|
assert len(tex[2]) == len("".join((str_part_3 + separator).split()))
|
|
assert len(tex[3]) == len("".join(str_part_4.split()))
|
|
|
|
|
|
def test_multi_part_tex_with_empty_parts():
|
|
"""Check that if a Tex or MathTex Mobject with multiple
|
|
string arguments is created where some of the parts render
|
|
as empty SVGs, then the number of family members with points
|
|
should still be the same as the snipped in one singular part.
|
|
"""
|
|
tex_parts = ["(-1)", "^{", "0}"]
|
|
one_part_fomula = MathTex("".join(tex_parts))
|
|
multi_part_formula = MathTex(*tex_parts)
|
|
|
|
for one_part_glyph, multi_part_glyph in zip(
|
|
one_part_fomula.family_members_with_points(),
|
|
multi_part_formula.family_members_with_points(),
|
|
strict=True,
|
|
):
|
|
np.testing.assert_allclose(one_part_glyph.points, multi_part_glyph.points)
|
|
|
|
|
|
def test_tex_size():
|
|
"""Check that the size of a :class:`Tex` string is not changed."""
|
|
text = Tex("what").center()
|
|
vertical = text.get_top() - text.get_bottom()
|
|
horizontal = text.get_right() - text.get_left()
|
|
assert round(vertical[1], 4) == 0.3512
|
|
assert round(horizontal[0], 4) == 1.0420
|
|
|
|
|
|
def test_font_size():
|
|
"""Test that tex_mobject classes return
|
|
the correct font_size value after being scaled.
|
|
"""
|
|
string = MathTex(0).scale(0.3)
|
|
|
|
assert round(string.font_size, 5) == 14.4
|
|
|
|
|
|
def test_font_size_vs_scale():
|
|
"""Test that scale produces the same results as .scale()"""
|
|
num = MathTex(0, font_size=12)
|
|
num_scale = MathTex(0).scale(1 / 4)
|
|
|
|
assert num.height == num_scale.height
|
|
|
|
|
|
def test_changing_font_size():
|
|
"""Test that the font_size property properly scales tex_mobject.py classes."""
|
|
num = Tex("0", font_size=12)
|
|
num.font_size = 48
|
|
|
|
assert num.height == Tex("0", font_size=48).height
|
|
|
|
|
|
def test_log_error_context(capsys):
|
|
"""Test that the environment context of an error is correctly logged if it exists"""
|
|
invalid_tex = r"""
|
|
some text that is fine
|
|
|
|
\begin{unbalanced_braces}{
|
|
not fine
|
|
\end{not_even_the_right_env}
|
|
"""
|
|
|
|
with pytest.raises(ValueError) as err:
|
|
Tex(invalid_tex)
|
|
|
|
# validate useful TeX error logged to user
|
|
assert "unbalanced_braces" in str(capsys.readouterr().out)
|
|
# validate useful error message raised
|
|
assert "See log output above or the log file" in str(err.value)
|
|
|
|
|
|
def test_log_error_no_relevant_context(capsys):
|
|
"""Test that an error with no environment context contains no environment context"""
|
|
failing_preamble = r"""\usepackage{fontspec}
|
|
\setmainfont[Ligatures=TeX]{not_a_font}"""
|
|
|
|
with pytest.raises(ValueError) as err:
|
|
Tex(
|
|
"The template uses a non-existent font",
|
|
tex_template=TexTemplate(preamble=failing_preamble),
|
|
)
|
|
|
|
# validate useless TeX error not logged for user
|
|
assert "Context" not in str(capsys.readouterr().out)
|
|
# validate useful error message raised
|
|
# this won't happen if an error is raised while formatting the message
|
|
assert "See log output above or the log file" in str(err.value)
|
|
|
|
|
|
def test_error_in_nested_context(capsys):
|
|
"""Test that displayed error context is not excessively large"""
|
|
invalid_tex = r"""
|
|
\begin{align}
|
|
\begin{tabular}{ c }
|
|
no need to display \\
|
|
this correct text \\
|
|
\end{tabular}
|
|
\notacontrolsequence
|
|
\end{align}
|
|
"""
|
|
|
|
with pytest.raises(ValueError):
|
|
Tex(invalid_tex)
|
|
|
|
stdout = str(capsys.readouterr().out)
|
|
# validate useless context is not included
|
|
assert r"\begin{frame}" not in stdout
|
|
|
|
|
|
def test_tempconfig_resetting_tex_template(config):
|
|
my_template = TexTemplate()
|
|
my_template.preamble = "Custom preamble!"
|
|
with tempconfig({"tex_template": my_template}):
|
|
assert config.tex_template.preamble == "Custom preamble!"
|
|
|
|
assert config.tex_template.preamble != "Custom preamble!"
|
|
|
|
|
|
def test_tex_garbage_collection(tmpdir, monkeypatch, config):
|
|
monkeypatch.chdir(tmpdir)
|
|
Path(tmpdir, "media").mkdir(exist_ok=True)
|
|
config.media_dir = "media"
|
|
|
|
tex_without_log = Tex("Hello World!") # 058a4e242c57db6d.tex
|
|
assert Path("media", "Tex", "058a4e242c57db6d.tex").exists()
|
|
assert not Path("media", "Tex", "058a4e242c57db6d.log").exists()
|
|
|
|
config.no_latex_cleanup = True
|
|
|
|
tex_with_log = Tex("Hello World, again!") # 45b4e7819cc20cb1.tex
|
|
assert Path("media", "Tex", "45b4e7819cc20cb1.log").exists()
|