diff --git a/manim/_config/utils.py b/manim/_config/utils.py index be9eb113aa..814db08a3e 100644 --- a/manim/_config/utils.py +++ b/manim/_config/utils.py @@ -28,7 +28,7 @@ from manim import constants from manim.constants import RendererType from manim.utils.color import ManimColor -from manim.utils.tex import TexTemplate, TexTemplateFromFile +from manim.utils.tex import TexTemplate if TYPE_CHECKING: from enum import EnumMeta @@ -833,7 +833,7 @@ def digest_args(self, args: argparse.Namespace) -> Self: # Handle --tex_template if args.tex_template: - self.tex_template = TexTemplateFromFile(tex_filename=args.tex_template) + self.tex_template = TexTemplate.from_file(args.tex_template) if ( self.renderer == RendererType.OPENGL @@ -1756,19 +1756,19 @@ def tex_template(self) -> TexTemplate: if not hasattr(self, "_tex_template") or not self._tex_template: fn = self._d["tex_template_file"] if fn: - self._tex_template = TexTemplateFromFile(tex_filename=fn) + self._tex_template = TexTemplate.from_file(fn) else: self._tex_template = TexTemplate() return self._tex_template @tex_template.setter - def tex_template(self, val: TexTemplateFromFile | TexTemplate) -> None: - if isinstance(val, (TexTemplateFromFile, TexTemplate)): + def tex_template(self, val: TexTemplate) -> None: + if isinstance(val, TexTemplate): self._tex_template = val @property def tex_template_file(self) -> Path: - """File to read Tex template from (no flag). See :class:`.TexTemplateFromFile`.""" + """File to read Tex template from (no flag). See :class:`.TexTemplate`.""" return self._d["tex_template_file"] @tex_template_file.setter diff --git a/manim/utils/tex.py b/manim/utils/tex.py index cd0944dcdc..f642abad72 100644 --- a/manim/utils/tex.py +++ b/manim/utils/tex.py @@ -4,162 +4,132 @@ __all__ = [ "TexTemplate", - "TexTemplateFromFile", ] import copy -import os import re +import warnings +from dataclasses import dataclass, field from pathlib import Path +from typing import TYPE_CHECKING, Any +if TYPE_CHECKING: + from typing_extensions import Self -class TexTemplate: - """TeX templates are used for creating Tex() and MathTex() objects. - - Parameters - ---------- - tex_compiler - The TeX compiler to be used, e.g. ``latex``, ``pdflatex`` or ``lualatex`` - output_format - The output format resulting from compilation, e.g. ``.dvi`` or ``.pdf`` - documentclass - The command defining the documentclass, e.g. ``\\documentclass[preview]{standalone}`` - preamble - The document's preamble, i.e. the part between ``\\documentclass`` and ``\\begin{document}`` - placeholder_text - Text in the document that will be replaced by the expression to be rendered - post_doc_commands - Text (definitions, commands) to be inserted at right after ``\\begin{document}``, e.g. ``\\boldmath`` - - Attributes - ---------- - tex_compiler : :class:`str` - The TeX compiler to be used, e.g. ``latex``, ``pdflatex`` or ``lualatex`` - output_format : :class:`str` - The output format resulting from compilation, e.g. ``.dvi`` or ``.pdf`` - documentclass : :class:`str` - The command defining the documentclass, e.g. ``\\documentclass[preview]{standalone}`` - preamble : :class:`str` - The document's preamble, i.e. the part between ``\\documentclass`` and ``\\begin{document}`` - placeholder_text : :class:`str` - Text in the document that will be replaced by the expression to be rendered - post_doc_commands : :class:`str` - Text (definitions, commands) to be inserted at right after ``\\begin{document}``, e.g. ``\\boldmath`` - """ + from manim.typing import StrPath - default_documentclass = r"\documentclass[preview]{standalone}" - default_preamble = r""" -\usepackage[english]{babel} +_DEFAULT_PREAMBLE = r"""\usepackage[english]{babel} \usepackage{amsmath} -\usepackage{amssymb} -""" - default_placeholder_text = "YourTextHere" - default_tex_compiler = "latex" - default_output_format = ".dvi" - default_post_doc_commands = "" - - def __init__( - self, - tex_compiler: str | None = None, - output_format: str | None = None, - documentclass: str | None = None, - preamble: str | None = None, - placeholder_text: str | None = None, - post_doc_commands: str | None = None, - **kwargs, - ): - self.tex_compiler = ( - tex_compiler - if tex_compiler is not None - else TexTemplate.default_tex_compiler - ) - self.output_format = ( - output_format - if output_format is not None - else TexTemplate.default_output_format - ) - self.documentclass = ( - documentclass - if documentclass is not None - else TexTemplate.default_documentclass - ) - self.preamble = ( - preamble if preamble is not None else TexTemplate.default_preamble - ) - self.placeholder_text = ( - placeholder_text - if placeholder_text is not None - else TexTemplate.default_placeholder_text - ) - self.post_doc_commands = ( - post_doc_commands - if post_doc_commands is not None - else TexTemplate.default_post_doc_commands - ) - self._rebuild() - - def __eq__(self, other: TexTemplate) -> bool: - return ( - self.body == other.body - and self.tex_compiler == other.tex_compiler - and self.output_format == other.output_format - and self.post_doc_commands == other.post_doc_commands - ) +\usepackage{amssymb}""" + +_BEGIN_DOCUMENT = r"\begin{document}" +_END_DOCUMENT = r"\end{document}" + - def _rebuild(self): - """Rebuilds the entire TeX template text from ``\\documentclass`` to ``\\end{document}`` according to all settings and choices.""" - self.body = ( - self.documentclass - + "\n" - + self.preamble - + "\n" - + r"\begin{document}" - + "\n" - + self.post_doc_commands - + "\n" - + self.placeholder_text - + "\n" - + "\n" - + r"\end{document}" - + "\n" +@dataclass(eq=True) +class TexTemplate: + """TeX templates are used to create ``Tex`` and ``MathTex`` objects.""" + + _body: str = field(default="", init=False) + """A custom body, can be set from a file.""" + + tex_compiler: str = "latex" + """The TeX compiler to be used, e.g. ``latex``, ``pdflatex`` or ``lualatex``.""" + + output_format: str = ".dvi" + """The output format resulting from compilation, e.g. ``.dvi`` or ``.pdf``.""" + + documentclass: str = r"\documentclass[preview]{standalone}" + r"""The command defining the documentclass, e.g. ``\documentclass[preview]{standalone}``.""" + + preamble: str = _DEFAULT_PREAMBLE + r"""The document's preamble, i.e. the part between ``\documentclass`` and ``\begin{document}``.""" + + placeholder_text: str = "YourTextHere" + """Text in the document that will be replaced by the expression to be rendered.""" + + post_doc_commands: str = "" + r"""Text (definitions, commands) to be inserted at right after ``\begin{document}``, e.g. ``\boldmath``.""" + + @property + def body(self) -> str: + """The entire TeX template.""" + return self._body or "\n".join( + filter( + None, + [ + self.documentclass, + self.preamble, + _BEGIN_DOCUMENT, + self.post_doc_commands, + self.placeholder_text, + _END_DOCUMENT, + ], + ) ) - def add_to_preamble(self, txt: str, prepend: bool = False): - """Adds stuff to the TeX template's preamble (e.g. definitions, packages). Text can be inserted at the beginning or at the end of the preamble. + @body.setter + def body(self, value: str) -> None: + self._body = value + + @classmethod + def from_file(cls, file: StrPath = "tex_template.tex", **kwargs: Any) -> Self: + """Create an instance by reading the content of a file. + + Using the ``add_to_preamble`` and ``add_to_document`` methods on this instance + will have no effect, as the body is read from the file. + """ + instance = cls(**kwargs) + instance.body = Path(file).read_text(encoding="utf-8") + return instance + + def add_to_preamble(self, txt: str, prepend: bool = False) -> Self: + r"""Adds text to the TeX template's preamble (e.g. definitions, packages). Text can be inserted at the beginning or at the end of the preamble. Parameters ---------- txt - String containing the text to be added, e.g. ``\\usepackage{hyperref}`` + String containing the text to be added, e.g. ``\usepackage{hyperref}``. prepend - Whether the text should be added at the beginning of the preamble, i.e. right after ``\\documentclass``. Default is to add it at the end of the preamble, i.e. right before ``\\begin{document}`` + Whether the text should be added at the beginning of the preamble, i.e. right after ``\documentclass``. + Default is to add it at the end of the preamble, i.e. right before ``\begin{document}``. """ + if self._body: + warnings.warn( + "This TeX template was created with a fixed body, trying to add text the preamble will have no effect.", + UserWarning, + stacklevel=2, + ) if prepend: self.preamble = txt + "\n" + self.preamble else: self.preamble += "\n" + txt - self._rebuild() return self - def add_to_document(self, txt: str): - """Adds txt to the TeX template just after \\begin{document}, e.g. ``\\boldmath`` + def add_to_document(self, txt: str) -> Self: + r"""Adds text to the TeX template just after \begin{document}, e.g. ``\boldmath``. Parameters ---------- txt String containing the text to be added. """ - self.post_doc_commands += "\n" + txt + "\n" - self._rebuild() + if self._body: + warnings.warn( + "This TeX template was created with a fixed body, trying to add text the document will have no effect.", + UserWarning, + stacklevel=2, + ) + self.post_doc_commands += txt return self - def get_texcode_for_expression(self, expression: str): - """Inserts expression verbatim into TeX template. + def get_texcode_for_expression(self, expression: str) -> str: + r"""Inserts expression verbatim into TeX template. Parameters ---------- expression - The string containing the expression to be typeset, e.g. ``$\\sqrt{2}$`` + The string containing the expression to be typeset, e.g. ``$\sqrt{2}$`` Returns ------- @@ -168,102 +138,60 @@ def get_texcode_for_expression(self, expression: str): """ return self.body.replace(self.placeholder_text, expression) - def _texcode_for_environment(self, environment: str): - """Processes the tex_environment string to return the correct ``\\begin{environment}[extra]{extra}`` and - ``\\end{environment}`` strings - - Parameters - ---------- - environment - The tex_environment as a string. Acceptable formats include: - ``{align*}``, ``align*``, ``{tabular}[t]{cccl}``, ``tabular}{cccl``, ``\\begin{tabular}[t]{cccl}``. - - Returns - ------- - Tuple[:class:`str`, :class:`str`] - A pair of strings representing the opening and closing of the tex environment, e.g. - ``\\begin{tabular}{cccl}`` and ``\\end{tabular}`` - """ - - # If the environment starts with \begin, remove it - if environment[0:6] == r"\begin": - environment = environment[6:] - - # If environment begins with { strip it - if environment[0] == r"{": - environment = environment[1:] - - # The \begin command takes everything and closes with a brace - begin = r"\begin{" + environment - if ( - begin[-1] != r"}" and begin[-1] != r"]" - ): # If it doesn't end on } or ], assume missing } - begin += r"}" - - # While the \end command terminates at the first closing brace - split_at_brace = re.split(r"}", environment, 1) - end = r"\end{" + split_at_brace[0] + r"}" - - return begin, end - - def get_texcode_for_expression_in_env(self, expression: str, environment: str): - r"""Inserts expression into TeX template wrapped in \begin{environment} and \end{environment} + def get_texcode_for_expression_in_env( + self, expression: str, environment: str + ) -> str: + r"""Inserts expression into TeX template wrapped in ``\begin{environment}`` and ``\end{environment}``. Parameters ---------- expression - The string containing the expression to be typeset, e.g. ``$\\sqrt{2}$`` + The string containing the expression to be typeset, e.g. ``$\sqrt{2}$``. environment - The string containing the environment in which the expression should be typeset, e.g. ``align*`` + The string containing the environment in which the expression should be typeset, e.g. ``align*``. Returns ------- :class:`str` LaTeX code based on template, containing the given expression inside its environment, ready for typesetting """ - begin, end = self._texcode_for_environment(environment) - return self.body.replace(self.placeholder_text, f"{begin}\n{expression}\n{end}") + begin, end = _texcode_for_environment(environment) + return self.body.replace( + self.placeholder_text, "\n".join([begin, expression, end]) + ) - def copy(self) -> TexTemplate: + def copy(self) -> Self: + """Create a deep copy of the TeX template instance.""" return copy.deepcopy(self) -class TexTemplateFromFile(TexTemplate): - """A TexTemplate object created from a template file (default: tex_template.tex) +def _texcode_for_environment(environment: str) -> tuple[str, str]: + r"""Processes the tex_environment string to return the correct ``\begin{environment}[extra]{extra}`` and + ``\end{environment}`` strings. Parameters ---------- - tex_filename - Path to a valid TeX template file - kwargs - Arguments for :class:`~.TexTemplate`. - - Attributes - ---------- - template_file : :class:`str` - Path to a valid TeX template file - body : :class:`str` - Content of the TeX template file - tex_compiler : :class:`str` - The TeX compiler to be used, e.g. ``latex``, ``pdflatex`` or ``lualatex`` - output_format : :class:`str` - The output format resulting from compilation, e.g. ``.dvi`` or ``.pdf`` + environment + The tex_environment as a string. Acceptable formats include: + ``{align*}``, ``align*``, ``{tabular}[t]{cccl}``, ``tabular}{cccl``, ``\begin{tabular}[t]{cccl}``. + + Returns + ------- + Tuple[:class:`str`, :class:`str`] + A pair of strings representing the opening and closing of the tex environment, e.g. + ``\begin{tabular}{cccl}`` and ``\end{tabular}`` """ - def __init__( - self, *, tex_filename: str | os.PathLike = "tex_template.tex", **kwargs - ): - self.template_file = Path(tex_filename) - super().__init__(**kwargs) - - def _rebuild(self): - self.body = self.template_file.read_text() + environment.removeprefix(r"\begin").removeprefix("{") - def file_not_mutable(self): - raise Exception("Cannot modify TexTemplate when using a template file.") + # The \begin command takes everything and closes with a brace + begin = r"\begin{" + environment + # If it doesn't end on } or ], assume missing } + if not begin.endswith(("}", "]")): + begin += "}" - def add_to_preamble(self, txt, prepend=False): - self.file_not_mutable() + # While the \end command terminates at the first closing brace + split_at_brace = re.split("}", environment, 1) + end = r"\end{" + split_at_brace[0] + "}" - def add_to_document(self, txt): - self.file_not_mutable() + return begin, end diff --git a/tests/control_data/logs_data/bad_tex_scene_BadTex.txt b/tests/control_data/logs_data/bad_tex_scene_BadTex.txt index 06d833f1bf..02c8813969 100644 --- a/tests/control_data/logs_data/bad_tex_scene_BadTex.txt +++ b/tests/control_data/logs_data/bad_tex_scene_BadTex.txt @@ -1,8 +1,8 @@ {"levelname": "INFO", "module": "logger_utils", "message": "Log file will be saved in <>"} {"levelname": "INFO", "module": "tex_file_writing", "message": "Writing <> to <>"} {"levelname": "ERROR", "module": "tex_file_writing", "message": "LaTeX compilation error: LaTeX Error: File `notapackage.sty' not found.\n"} -{"levelname": "ERROR", "module": "tex_file_writing", "message": "Context of error: \n\\documentclass[preview]{standalone}\n-> \\usepackage{notapackage}\n\\begin{document}\n\n\\begin{center}\n"} +{"levelname": "ERROR", "module": "tex_file_writing", "message": "Context of error: \n\\documentclass[preview]{standalone}\n-> \\usepackage{notapackage}\n\\begin{document}\n\\begin{center}\n\\frac{1}{0}\n"} {"levelname": "INFO", "module": "tex_file_writing", "message": "You do not have package notapackage.sty installed."} {"levelname": "INFO", "module": "tex_file_writing", "message": "Install notapackage.sty it using your LaTeX package manager, or check for typos."} {"levelname": "ERROR", "module": "tex_file_writing", "message": "LaTeX compilation error: Emergency stop.\n"} -{"levelname": "ERROR", "module": "tex_file_writing", "message": "Context of error: \n\\documentclass[preview]{standalone}\n-> \\usepackage{notapackage}\n\\begin{document}\n\n\\begin{center}\n"} +{"levelname": "ERROR", "module": "tex_file_writing", "message": "Context of error: \n\\documentclass[preview]{standalone}\n-> \\usepackage{notapackage}\n\\begin{document}\n\\begin{center}\n\\frac{1}{0}\n"} diff --git a/tests/module/mobject/text/test_texmobject.py b/tests/module/mobject/text/test_texmobject.py index a3cf26c355..9f487be4e8 100644 --- a/tests/module/mobject/text/test_texmobject.py +++ b/tests/module/mobject/text/test_texmobject.py @@ -12,12 +12,12 @@ def test_MathTex(): MathTex("a^2 + b^2 = c^2") - assert Path(config.media_dir, "Tex", "eb38bdba08f46c80.svg").exists() + assert Path(config.media_dir, "Tex", "e4be163a00cf424f.svg").exists() def test_SingleStringMathTex(): SingleStringMathTex("test") - assert Path(config.media_dir, "Tex", "5b2faa68ebf42d1e.svg").exists() + assert Path(config.media_dir, "Tex", "8ce17c7f5013209f.svg").exists() @pytest.mark.parametrize( # : PT006 @@ -31,7 +31,7 @@ def test_double_braces_testing(text_input, length_sub): def test_tex(): Tex("The horse does not eat cucumber salad.") - assert Path(config.media_dir, "Tex", "f2e45e6e82d750e6.svg").exists() + assert Path(config.media_dir, "Tex", "c3945e23e546c95a.svg").exists() def test_tex_temp_directory(tmpdir, monkeypatch): @@ -44,12 +44,12 @@ def test_tex_temp_directory(tmpdir, monkeypatch): with tempconfig({"media_dir": "media"}): Tex("The horse does not eat cucumber salad.") assert Path("media", "Tex").exists() - assert Path("media", "Tex", "f2e45e6e82d750e6.svg").exists() + assert Path("media", "Tex", "c3945e23e546c95a.svg").exists() def test_percent_char_rendering(): Tex(r"\%") - assert Path(config.media_dir, "Tex", "3f48edf8ebaf82c8.tex").exists() + assert Path(config.media_dir, "Tex", "4a583af4d19a3adf.tex").exists() def test_tex_whitespace_arg(): @@ -219,10 +219,10 @@ def test_tex_garbage_collection(tmpdir, monkeypatch): Path(tmpdir, "media").mkdir() with tempconfig({"media_dir": "media"}): - tex_without_log = Tex("Hello World!") # f7bc61042256dea9.tex - assert Path("media", "Tex", "f7bc61042256dea9.tex").exists() - assert not Path("media", "Tex", "f7bc61042256dea9.log").exists() + tex_without_log = Tex("Hello World!") # d771330b76d29ffb.tex + assert Path("media", "Tex", "d771330b76d29ffb.tex").exists() + assert not Path("media", "Tex", "d771330b76d29ffb.log").exists() with tempconfig({"media_dir": "media", "no_latex_cleanup": True}): - tex_with_log = Tex("Hello World, again!") # 3ef79eaaa2d0b15b.tex - assert Path("media", "Tex", "3ef79eaaa2d0b15b.log").exists() + tex_with_log = Tex("Hello World, again!") # da27670a37b08799.tex + assert Path("media", "Tex", "da27670a37b08799.log").exists() diff --git a/tests/module/utils/test_tex.py b/tests/module/utils/test_tex.py new file mode 100644 index 0000000000..51666168e0 --- /dev/null +++ b/tests/module/utils/test_tex.py @@ -0,0 +1,118 @@ +import pytest + +from manim.utils.tex import TexTemplate + +DEFAULT_BODY = r"""\documentclass[preview]{standalone} +\usepackage[english]{babel} +\usepackage{amsmath} +\usepackage{amssymb} +\begin{document} +YourTextHere +\end{document}""" + +BODY_WITH_ADDED_PREAMBLE = r"""\documentclass[preview]{standalone} +\usepackage[english]{babel} +\usepackage{amsmath} +\usepackage{amssymb} +\usepackage{testpackage} +\begin{document} +YourTextHere +\end{document}""" + +BODY_WITH_PREPENDED_PREAMBLE = r"""\documentclass[preview]{standalone} +\usepackage{testpackage} +\usepackage[english]{babel} +\usepackage{amsmath} +\usepackage{amssymb} +\begin{document} +YourTextHere +\end{document}""" + +BODY_WITH_ADDED_DOCUMENT = r"""\documentclass[preview]{standalone} +\usepackage[english]{babel} +\usepackage{amsmath} +\usepackage{amssymb} +\begin{document} +\boldmath +YourTextHere +\end{document}""" + +BODY_REPLACE = r"""\documentclass[preview]{standalone} +\usepackage[english]{babel} +\usepackage{amsmath} +\usepackage{amssymb} +\begin{document} +\sqrt{2} +\end{document}""" + +BODY_REPLACE_IN_ENV = r"""\documentclass[preview]{standalone} +\usepackage[english]{babel} +\usepackage{amsmath} +\usepackage{amssymb} +\begin{document} +\begin{align} +\sqrt{2} +\end{align} +\end{document}""" + + +def test_tex_template_default_body(): + template = TexTemplate() + assert template.body == DEFAULT_BODY + + +def test_tex_template_preamble(): + template = TexTemplate() + + template.add_to_preamble(r"\usepackage{testpackage}") + assert template.body == BODY_WITH_ADDED_PREAMBLE + + +def test_tex_template_preprend_preamble(): + template = TexTemplate() + + template.add_to_preamble(r"\usepackage{testpackage}", prepend=True) + assert template.body == BODY_WITH_PREPENDED_PREAMBLE + + +def test_tex_template_document(): + template = TexTemplate() + + template.add_to_document(r"\boldmath") + assert template.body == BODY_WITH_ADDED_DOCUMENT + + +def test_tex_template_texcode_for_expression(): + template = TexTemplate() + + assert template.get_texcode_for_expression(r"\sqrt{2}") == BODY_REPLACE + + +def test_tex_template_texcode_for_expression_in_env(): + template = TexTemplate() + + assert ( + template.get_texcode_for_expression_in_env(r"\sqrt{2}", environment="align") + == BODY_REPLACE_IN_ENV + ) + + +def test_tex_template_fixed_body(): + template = TexTemplate() + + # Usually set when calling `from_file` + template.body = "dummy" + + assert template.body == "dummy" + + with pytest.warns( + UserWarning, + match="This TeX template was created with a fixed body, trying to add text the preamble will have no effect.", + ): + template.add_to_preamble("dummys") + + with pytest.warns( + UserWarning, + match="This TeX template was created with a fixed body, trying to add text the document will have no effect.", + ): + template.add_to_document("dummy") diff --git a/tests/opengl/test_texmobject_opengl.py b/tests/opengl/test_texmobject_opengl.py index 392513ccbe..4fe5a76f81 100644 --- a/tests/opengl/test_texmobject_opengl.py +++ b/tests/opengl/test_texmobject_opengl.py @@ -9,12 +9,12 @@ def test_MathTex(using_opengl_renderer): MathTex("a^2 + b^2 = c^2") - assert Path(config.media_dir, "Tex", "eb38bdba08f46c80.svg").exists() + assert Path(config.media_dir, "Tex", "e4be163a00cf424f.svg").exists() def test_SingleStringMathTex(using_opengl_renderer): SingleStringMathTex("test") - assert Path(config.media_dir, "Tex", "5b2faa68ebf42d1e.svg").exists() + assert Path(config.media_dir, "Tex", "8ce17c7f5013209f.svg").exists() @pytest.mark.parametrize( # : PT006 @@ -28,7 +28,7 @@ def test_double_braces_testing(using_opengl_renderer, text_input, length_sub): def test_tex(using_opengl_renderer): Tex("The horse does not eat cucumber salad.") - assert Path(config.media_dir, "Tex", "f2e45e6e82d750e6.svg").exists() + assert Path(config.media_dir, "Tex", "c3945e23e546c95a.svg").exists() def test_tex_whitespace_arg(using_opengl_renderer):