Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 150 additions & 4 deletions manim/mobject/text/tex_mobject.py
Original file line number Diff line number Diff line change
Expand Up @@ -342,7 +342,10 @@ def _break_up_by_substrings(self) -> Self:
"""
new_submobjects: list[VMobject] = []
curr_index = 0
for tex_string in self.tex_strings:
i = 0

while i < len(self.tex_strings):
tex_string = self.tex_strings[i]
sub_tex_mob = SingleStringMathTex(
tex_string,
tex_environment=self.tex_environment,
Expand All @@ -352,16 +355,159 @@ def _break_up_by_substrings(self) -> Self:
new_index = (
curr_index + num_submobs + len("".join(self.arg_separator.split()))
)

if num_submobs == 0:
last_submob_index = min(curr_index, len(self.submobjects) - 1)
sub_tex_mob.move_to(self.submobjects[last_submob_index], RIGHT)
new_submobjects.append(sub_tex_mob)
curr_index = new_index
i += 1
elif self._is_pure_script(tex_string):
# Handle consecutive scripts as a group, matching by Y-position
script_group, j = self._group_consecutive_scripts(i)
total_script_submobs = self._total_submobs_for_scripts(script_group)
script_pool = self.submobjects[
curr_index : curr_index + total_script_submobs
]

self._assign_script_group(script_group, script_pool, new_submobjects)

curr_index += total_script_submobs
i = j
else:
sub_tex_mob.submobjects = self.submobjects[curr_index:new_index]
new_submobjects.append(sub_tex_mob)
curr_index = new_index
# Base element processing: check if followed by scripts
# But skip if this element already has scripts attached (e.g., \int^b)
has_scripts_already = "^" in tex_string or "_" in tex_string
next_is_script = i + 1 < len(self.tex_strings) and self._is_pure_script(
self.tex_strings[i + 1]
)

if next_is_script and num_submobs > 0 and not has_scripts_already:
script_group, j = self._group_consecutive_scripts(i + 1)
total_script_submobs = self._total_submobs_for_scripts(script_group)
total_needed = num_submobs + total_script_submobs

all_submobs = self.submobjects[
curr_index : curr_index + total_needed
]

# Only use special handling if scripts have content (non-empty)
if total_script_submobs > 0 and len(all_submobs) == total_needed:
# LaTeX may render base+scripts in unexpected order
# Find base by Y-position: closest to baseline (Y=0)
base_submob = min(
all_submobs, key=lambda m: abs(m.get_center()[1])
)
sub_tex_mob.submobjects = [base_submob]

script_pool = [m for m in all_submobs if m != base_submob]
new_submobjects.append(sub_tex_mob)

self._assign_script_group(
script_group, script_pool, new_submobjects
)

curr_index += total_needed
i = j
else:
# Fallback if counts don't match
sub_tex_mob.submobjects = self.submobjects[curr_index:new_index]
new_submobjects.append(sub_tex_mob)
curr_index = new_index
i += 1
else:
sub_tex_mob.submobjects = self.submobjects[curr_index:new_index]
new_submobjects.append(sub_tex_mob)
curr_index = new_index
i += 1

self.submobjects = new_submobjects
return self

def _is_pure_script(self, tex_string: str) -> bool:
"""Check if a tex_string is a pure script (only ^ or _ with its content).

A pure script should not contain spaces or other content beyond the script itself.
For example: '^n', '_1', '^{abc}' are pure scripts.
But '^b dx' is not a pure script (has additional content).
"""
stripped = tex_string.strip()
if not stripped.startswith(("^", "_")):
return False
# Pure scripts shouldn't have spaces (which indicate additional content)
# They should be compact like '^n', '_1', '^{...}', etc.
return " " not in stripped

def _group_consecutive_scripts(self, start_index: int) -> tuple[list[str], int]:
"""Collect consecutive script tex_strings starting at ``start_index``.

Returns the list of scripts and the index just after the group.
Scripts are tex strings starting with '^' or '_'.
"""
script_group = [self.tex_strings[start_index]]
j = start_index + 1
while j < len(self.tex_strings) and self._is_pure_script(self.tex_strings[j]):
script_group.append(self.tex_strings[j])
j += 1
return script_group, j

def _total_submobs_for_scripts(self, script_group: list[str]) -> int:
"""Calculate total submobject count for a group of script strings.

Creates temporary SingleStringMathTex instances to inspect counts.
"""
total = 0
for s in script_group:
total += len(
SingleStringMathTex(
s,
tex_environment=self.tex_environment,
tex_template=self.tex_template,
).submobjects
)
return total

def _assign_script_group(
self,
script_group: list[str],
script_pool: list[VMobject],
new_submobjects: list[VMobject],
) -> None:
"""Assign submobjects from ``script_pool`` to scripts in ``script_group``.

Selection strategy:
- Superscripts ('^'): Pick highest Y-position items (above baseline)
- Subscripts ('_'): Pick lowest Y-position items (below baseline)

Selected submobjects are removed from pool to prevent reuse.
"""
for script_tex in script_group:
script_mob = SingleStringMathTex(
script_tex,
tex_environment=self.tex_environment,
tex_template=self.tex_template,
)
script_num_submobs = len(script_mob.submobjects)

if script_num_submobs > 0 and len(script_pool) > 0:
is_superscript = script_tex.strip().startswith("^")
# Sort by Y-position: reverse=True for superscripts (highest first)
sorted_pool = sorted(
script_pool,
key=lambda mob: mob.get_center()[1],
reverse=is_superscript,
)

selected = sorted_pool[:script_num_submobs]
script_mob.submobjects = selected

# Remove selected items from pool
for sel in selected:
if sel in script_pool:
script_pool.remove(sel)

new_submobjects.append(script_mob)

def get_parts_by_tex(
self, tex: str, substring: bool = True, case_sensitive: bool = True
) -> VGroup:
Expand Down
28 changes: 28 additions & 0 deletions tests/module/mobject/text/test_texmobject.py
Original file line number Diff line number Diff line change
Expand Up @@ -225,3 +225,31 @@ def test_tex_garbage_collection(tmpdir, monkeypatch, config):

tex_with_log = Tex("Hello World, again!") # da27670a37b08799.tex
assert Path("media", "Tex", "da27670a37b08799.log").exists()


def test_tex_strings_with_subscripts_and_superscripts():
"""Test that submobjects match tex_strings and positions when LaTeX reorders scripts.

Regression test for issue #3548.
"""
eq1 = MathTex("A", "^n", "_1")
assert eq1.submobjects[0].get_tex_string() == "A"
assert eq1.submobjects[1].get_tex_string() == "^n"
assert eq1.submobjects[2].get_tex_string() == "_1"
assert eq1.submobjects[1].get_center()[1] > 0
assert eq1.submobjects[2].get_center()[1] < 0

eq2 = MathTex("A", "_1", "^n")
assert eq2.submobjects[0].get_tex_string() == "A"
assert eq2.submobjects[1].get_tex_string() == "_1"
assert eq2.submobjects[2].get_tex_string() == "^n"
assert eq2.submobjects[1].get_center()[1] < 0
assert eq2.submobjects[2].get_center()[1] > 0

eq3 = MathTex("\\sum", "^n", "_1", "x")
assert eq3.submobjects[0].get_tex_string() == "\\sum"
assert eq3.submobjects[1].get_tex_string() == "^n"
assert eq3.submobjects[2].get_tex_string() == "_1"
assert eq3.submobjects[3].get_tex_string() == "x"
assert eq3.submobjects[1].get_center()[1] > 0
assert eq3.submobjects[2].get_center()[1] < 0
Loading