diff --git a/manim/mobject/text/tex_mobject.py b/manim/mobject/text/tex_mobject.py index 03bc285e79..2cc548c037 100644 --- a/manim/mobject/text/tex_mobject.py +++ b/manim/mobject/text/tex_mobject.py @@ -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, @@ -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: diff --git a/tests/module/mobject/text/test_texmobject.py b/tests/module/mobject/text/test_texmobject.py index ca8e635ea6..b6addf8aa4 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(): + """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