From 47c6720c34cdbe136325e5aae4caeb06c17d86f8 Mon Sep 17 00:00:00 2001 From: Nikhil172913832 Date: Sat, 25 Oct 2025 16:18:06 +0530 Subject: [PATCH 1/8] 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/8] [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/8] 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/8] [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: From 5e1070f9ea612c39eb809d2d10ba2586002e34eb Mon Sep 17 00:00:00 2001 From: Nikhil172913832 Date: Thu, 6 Nov 2025 11:51:48 +0530 Subject: [PATCH 5/8] Fix the coloring schems to make sure it is consistent and refactored _break_up_by_substrings function to improve understandability --- manim/mobject/text/tex_mobject.py | 161 +++++++++++++++++++----------- 1 file changed, 100 insertions(+), 61 deletions(-) diff --git a/manim/mobject/text/tex_mobject.py b/manim/mobject/text/tex_mobject.py index 345ab6fc4a..15b59755e8 100644 --- a/manim/mobject/text/tex_mobject.py +++ b/manim/mobject/text/tex_mobject.py @@ -363,74 +363,113 @@ def _break_up_by_substrings(self) -> Self: 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 - ) + # 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) - # 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 _ - ) - - # 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 + i = j 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 + # Base element processing: check if followed by scripts + next_is_script = (i + 1 < len(self.tex_strings) and + self.tex_strings[i + 1].strip().startswith(("^", "_"))) + + if next_is_script and num_submobs > 0: + 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 _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.tex_strings[j].strip().startswith(("^", "_")): + 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: From 5d0c384837412da69124f261be8d31301fbd6b25 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 6 Nov 2025 06:22:08 +0000 Subject: [PATCH 6/8] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- manim/mobject/text/tex_mobject.py | 46 +++++++++++++++++++++++-------- 1 file changed, 35 insertions(+), 11 deletions(-) diff --git a/manim/mobject/text/tex_mobject.py b/manim/mobject/text/tex_mobject.py index 15b59755e8..bb5d526282 100644 --- a/manim/mobject/text/tex_mobject.py +++ b/manim/mobject/text/tex_mobject.py @@ -366,7 +366,9 @@ def _break_up_by_substrings(self) -> Self: # 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] + script_pool = self.submobjects[ + curr_index : curr_index + total_script_submobs + ] self._assign_script_group(script_group, script_pool, new_submobjects) @@ -374,27 +376,34 @@ def _break_up_by_substrings(self) -> Self: i = j else: # Base element processing: check if followed by scripts - next_is_script = (i + 1 < len(self.tex_strings) and - self.tex_strings[i + 1].strip().startswith(("^", "_"))) + next_is_script = i + 1 < len(self.tex_strings) and self.tex_strings[ + i + 1 + ].strip().startswith(("^", "_")) if next_is_script and num_submobs > 0: 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] + 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])) + 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) + self._assign_script_group( + script_group, script_pool, new_submobjects + ) curr_index += total_needed i = j @@ -421,7 +430,9 @@ def _group_consecutive_scripts(self, start_index: int) -> tuple[list[str], int]: """ script_group = [self.tex_strings[start_index]] j = start_index + 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 return script_group, j @@ -435,12 +446,19 @@ def _total_submobs_for_scripts(self, script_group: list[str]) -> int: for s in script_group: total += len( SingleStringMathTex( - s, tex_environment=self.tex_environment, tex_template=self.tex_template + 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: + 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: @@ -451,14 +469,20 @@ def _assign_script_group(self, script_group: list[str], script_pool: list[VMobje """ for script_tex in script_group: script_mob = SingleStringMathTex( - script_tex, tex_environment=self.tex_environment, tex_template=self.tex_template + 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) + 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 From adc71b45b1af699d72e1bb659c43a8c52d28f270 Mon Sep 17 00:00:00 2001 From: Nikhil172913832 Date: Fri, 7 Nov 2025 12:26:31 +0530 Subject: [PATCH 7/8] Fix MathTex substring extraction to correctly handle bases with existing scripts, ensuring both subscript and superscript limits are displayed for integrals --- manim/mobject/text/tex_mobject.py | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/manim/mobject/text/tex_mobject.py b/manim/mobject/text/tex_mobject.py index bb5d526282..8acb1a3480 100644 --- a/manim/mobject/text/tex_mobject.py +++ b/manim/mobject/text/tex_mobject.py @@ -362,7 +362,7 @@ def _break_up_by_substrings(self) -> Self: new_submobjects.append(sub_tex_mob) curr_index = new_index i += 1 - elif tex_string.strip().startswith(("^", "_")): + 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) @@ -376,11 +376,12 @@ def _break_up_by_substrings(self) -> Self: i = j else: # Base element processing: check if followed by scripts - next_is_script = i + 1 < len(self.tex_strings) and self.tex_strings[ - i + 1 - ].strip().startswith(("^", "_")) + # 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: + 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 @@ -422,6 +423,20 @@ def _break_up_by_substrings(self) -> Self: 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``. @@ -430,9 +445,7 @@ def _group_consecutive_scripts(self, start_index: int) -> tuple[list[str], int]: """ script_group = [self.tex_strings[start_index]] j = start_index + 1 - while j < len(self.tex_strings) and self.tex_strings[j].strip().startswith( - ("^", "_") - ): + 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 From d8122d1837ed40eb40525f25a28a00900a3d2470 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 7 Nov 2025 07:02:21 +0000 Subject: [PATCH 8/8] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- manim/mobject/text/tex_mobject.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/manim/mobject/text/tex_mobject.py b/manim/mobject/text/tex_mobject.py index 8acb1a3480..2cc548c037 100644 --- a/manim/mobject/text/tex_mobject.py +++ b/manim/mobject/text/tex_mobject.py @@ -377,9 +377,10 @@ def _break_up_by_substrings(self) -> Self: else: # 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])) + 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) @@ -435,7 +436,7 @@ def _is_pure_script(self, tex_string: str) -> bool: return False # Pure scripts shouldn't have spaces (which indicate additional content) # They should be compact like '^n', '_1', '^{...}', etc. - return ' ' not in stripped + 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``.