From 47c6720c34cdbe136325e5aae4caeb06c17d86f8 Mon Sep 17 00:00:00 2001 From: Nikhil172913832 Date: Sat, 25 Oct 2025 16:18:06 +0530 Subject: [PATCH 1/4] Fix #3548: Match MathTex subscripts/superscripts by position Use geometric matching for script elements to handle LaTeX reordering while preserving sequential matching for non-script elements. --- manim/mobject/text/tex_mobject.py | 21 ++++++++++++++- tests/module/mobject/text/test_texmobject.py | 28 ++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/manim/mobject/text/tex_mobject.py b/manim/mobject/text/tex_mobject.py index 03bc285e79..5f22a05eb8 100644 --- a/manim/mobject/text/tex_mobject.py +++ b/manim/mobject/text/tex_mobject.py @@ -31,6 +31,7 @@ from textwrap import dedent from typing import Any +import numpy as np from typing_extensions import Self from manim import config, logger @@ -356,7 +357,25 @@ def _break_up_by_substrings(self) -> Self: last_submob_index = min(curr_index, len(self.submobjects) - 1) sub_tex_mob.move_to(self.submobjects[last_submob_index], RIGHT) else: - sub_tex_mob.submobjects = self.submobjects[curr_index:new_index] + is_script = tex_string.strip().startswith(("^", "_")) + remaining = self.submobjects[curr_index:new_index] + + if is_script and len(remaining) >= num_submobs: + matched_submobs = [] + for target_submob in sub_tex_mob.submobjects: + if not remaining: + break + target_center = target_submob.get_center() + best_match_idx = min( + range(len(remaining)), + key=lambda i: np.linalg.norm( + remaining[i].get_center() - target_center + ), + ) + matched_submobs.append(remaining.pop(best_match_idx)) + sub_tex_mob.submobjects = matched_submobs + else: + sub_tex_mob.submobjects = self.submobjects[curr_index:new_index] new_submobjects.append(sub_tex_mob) curr_index = new_index self.submobjects = new_submobjects diff --git a/tests/module/mobject/text/test_texmobject.py b/tests/module/mobject/text/test_texmobject.py index ca8e635ea6..c8ed0d489a 100644 --- a/tests/module/mobject/text/test_texmobject.py +++ b/tests/module/mobject/text/test_texmobject.py @@ -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(): + """Check that MathTex submobjects match their tex_strings when using + subscripts and superscripts in different orders. + + This is a regression test for issue #3548. LaTeX may reorder subscripts + and superscripts in the compiled output, but the submobjects should still + correspond to their original tex_strings. + """ + # Test with superscript before subscript + 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" + + # Test with subscript before superscript (reversed order) + 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" + + # Test with summation and multiple terms + 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" From 0c0e01325590e4f4e696437b2cd9d0b836bacfcb Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 25 Oct 2025 10:51:55 +0000 Subject: [PATCH 2/4] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- manim/mobject/text/tex_mobject.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manim/mobject/text/tex_mobject.py b/manim/mobject/text/tex_mobject.py index 5f22a05eb8..c4e4c78dfd 100644 --- a/manim/mobject/text/tex_mobject.py +++ b/manim/mobject/text/tex_mobject.py @@ -359,7 +359,7 @@ def _break_up_by_substrings(self) -> Self: else: is_script = tex_string.strip().startswith(("^", "_")) remaining = self.submobjects[curr_index:new_index] - + if is_script and len(remaining) >= num_submobs: matched_submobs = [] for target_submob in sub_tex_mob.submobjects: From c135c0277acb4d9d4f7cfce194ee9cccb84be17c Mon Sep 17 00:00:00 2001 From: Nikhil172913832 Date: Sat, 1 Nov 2025 10:08:38 +0530 Subject: [PATCH 3/4] Improve fix for #3548: Group consecutive scripts and match by Y position --- manim/mobject/text/tex_mobject.py | 90 +++++++++++++++----- tests/module/mobject/text/test_texmobject.py | 16 ++-- 2 files changed, 75 insertions(+), 31 deletions(-) diff --git a/manim/mobject/text/tex_mobject.py b/manim/mobject/text/tex_mobject.py index c4e4c78dfd..c552eb1a89 100644 --- a/manim/mobject/text/tex_mobject.py +++ b/manim/mobject/text/tex_mobject.py @@ -31,7 +31,6 @@ from textwrap import dedent from typing import Any -import numpy as np from typing_extensions import Self from manim import config, logger @@ -343,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, @@ -353,31 +355,73 @@ 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) - else: - is_script = tex_string.strip().startswith(("^", "_")) - remaining = self.submobjects[curr_index:new_index] - - if is_script and len(remaining) >= num_submobs: - matched_submobs = [] - for target_submob in sub_tex_mob.submobjects: - if not remaining: - break - target_center = target_submob.get_center() - best_match_idx = min( - range(len(remaining)), - key=lambda i: np.linalg.norm( - remaining[i].get_center() - target_center - ), + new_submobjects.append(sub_tex_mob) + curr_index = new_index + i += 1 + elif tex_string.strip().startswith(("^", "_")): + # Handle consecutive scripts as a group + script_group = [tex_string] + j = i + 1 + while j < len(self.tex_strings) and self.tex_strings[j].strip().startswith(("^", "_")): + script_group.append(self.tex_strings[j]) + j += 1 + + # Calculate total submobjects needed for all scripts + total_script_submobs = sum( + len(SingleStringMathTex( + s, + tex_environment=self.tex_environment, + tex_template=self.tex_template, + ).submobjects) + for s in script_group + ) + + # Get the pool of available submobjects for all scripts + script_pool = self.submobjects[curr_index:curr_index + total_script_submobs] + + # Process each script in the group + 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: + # Select submobjects by Y position + is_superscript = script_tex.strip().startswith("^") + sorted_pool = sorted( + script_pool, + key=lambda mob: mob.get_center()[1], + reverse=is_superscript, # highest first for ^, lowest first for _ ) - matched_submobs.append(remaining.pop(best_match_idx)) - sub_tex_mob.submobjects = matched_submobs - else: - sub_tex_mob.submobjects = self.submobjects[curr_index:new_index] - new_submobjects.append(sub_tex_mob) - curr_index = new_index + + # Take the first script_num_submobs from sorted pool + selected = sorted_pool[:script_num_submobs] + script_mob.submobjects = selected + + # Remove selected submobjects from pool + for sel in selected: + if sel in script_pool: + script_pool.remove(sel) + + new_submobjects.append(script_mob) + + # Update indices + curr_index += total_script_submobs + i = j # Skip past all processed scripts + else: + # Normal (non-script) processing + 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 diff --git a/tests/module/mobject/text/test_texmobject.py b/tests/module/mobject/text/test_texmobject.py index c8ed0d489a..b6addf8aa4 100644 --- a/tests/module/mobject/text/test_texmobject.py +++ b/tests/module/mobject/text/test_texmobject.py @@ -228,28 +228,28 @@ def test_tex_garbage_collection(tmpdir, monkeypatch, config): def test_tex_strings_with_subscripts_and_superscripts(): - """Check that MathTex submobjects match their tex_strings when using - subscripts and superscripts in different orders. + """Test that submobjects match tex_strings and positions when LaTeX reorders scripts. - This is a regression test for issue #3548. LaTeX may reorder subscripts - and superscripts in the compiled output, but the submobjects should still - correspond to their original tex_strings. + Regression test for issue #3548. """ - # Test with superscript before subscript 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 - # Test with subscript before superscript (reversed order) 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 - # Test with summation and multiple terms 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 From 084b82f9843e3371d641f26d0ec205e855268217 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 1 Nov 2025 04:51:28 +0000 Subject: [PATCH 4/4] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- manim/mobject/text/tex_mobject.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/manim/mobject/text/tex_mobject.py b/manim/mobject/text/tex_mobject.py index c552eb1a89..345ab6fc4a 100644 --- a/manim/mobject/text/tex_mobject.py +++ b/manim/mobject/text/tex_mobject.py @@ -366,22 +366,28 @@ def _break_up_by_substrings(self) -> Self: # Handle consecutive scripts as a group script_group = [tex_string] j = i + 1 - while j < len(self.tex_strings) and self.tex_strings[j].strip().startswith(("^", "_")): + while j < len(self.tex_strings) and self.tex_strings[ + j + ].strip().startswith(("^", "_")): script_group.append(self.tex_strings[j]) j += 1 # Calculate total submobjects needed for all scripts total_script_submobs = sum( - len(SingleStringMathTex( - s, - tex_environment=self.tex_environment, - tex_template=self.tex_template, - ).submobjects) + len( + SingleStringMathTex( + s, + tex_environment=self.tex_environment, + tex_template=self.tex_template, + ).submobjects + ) for s in script_group ) # Get the pool of available submobjects for all scripts - script_pool = self.submobjects[curr_index:curr_index + total_script_submobs] + script_pool = self.submobjects[ + curr_index : curr_index + total_script_submobs + ] # Process each script in the group for script_tex in script_group: