diff --git a/manim/constants.py b/manim/constants.py index ccf99a0293..0dafe89574 100644 --- a/manim/constants.py +++ b/manim/constants.py @@ -2,7 +2,7 @@ from __future__ import annotations -from enum import Enum +from enum import Enum, StrEnum from typing import TypedDict import numpy as np @@ -275,6 +275,27 @@ class RendererType(Enum): OPENGL = "opengl" #: An OpenGL-based renderer. +class TexOutputFormat(StrEnum): + DVI = ".dvi" + XDV = ".xdv" + PDF = ".pdf" + + +class TexCompiler(StrEnum): + LATEX = "latex" + PDFLATEX = "pdflatex" + LUALATEX = "lualatex" + XELATEX = "xelatex" + TECTONIC = "tectonic" + + @property + def output_formats(self) -> list[TexOutputFormat]: + if self in (TexCompiler.XELATEX, TexCompiler.TECTONIC): + return [TexOutputFormat.PDF, TexOutputFormat.XDV] + else: + return [TexOutputFormat.DVI, TexOutputFormat.PDF] + + class LineJointType(Enum): """Collection of available line joint types. diff --git a/manim/utils/tex.py b/manim/utils/tex.py index 4617041bae..a3687f4ac1 100644 --- a/manim/utils/tex.py +++ b/manim/utils/tex.py @@ -13,6 +13,8 @@ from pathlib import Path from typing import TYPE_CHECKING, Any +from manim.constants import TexCompiler, TexOutputFormat + if TYPE_CHECKING: from typing import Self @@ -33,7 +35,7 @@ class TexTemplate: _body: str = field(default="", init=False) """A custom body, can be set from a file.""" - tex_compiler: str | list[str] = "latex" + tex_compiler: TexCompiler | list[TexCompiler] | str | list[str] = TexCompiler.LATEX """The TeX compiler(s) to be used. Can be a single compiler (e.g. ``"latex"``, ``"pdflatex"``, ``"lualatex"``) or a list of compilers to compile in order (e.g. ``["lualatex", "pdflatex"]``).""" @@ -41,7 +43,7 @@ class TexTemplate: description: str = "" """A description of the template""" - output_format: str = ".dvi" + output_format: TexOutputFormat | str = TexOutputFormat.DVI """The output format resulting from compilation, e.g. ``.dvi`` or ``.pdf``.""" documentclass: str = r"\documentclass[preview]{standalone}" diff --git a/manim/utils/tex_file_writing.py b/manim/utils/tex_file_writing.py index 6a3765feb4..b8ad8dd562 100644 --- a/manim/utils/tex_file_writing.py +++ b/manim/utils/tex_file_writing.py @@ -17,6 +17,7 @@ from re import Match from typing import Any +from manim.constants import TexCompiler, TexOutputFormat from manim.utils.tex import TexTemplate from .. import config, logger @@ -62,12 +63,16 @@ def tex_to_svg_file( if svg_file.exists(): return svg_file + # convert to TexCompiler and TexOutputFormat + tex_compiler = TexCompiler(str(tex_template.tex_compiler)) + output_format = TexOutputFormat(str(tex_template.output_format)) + dvi_file = compile_tex( tex_file, - tex_template.tex_compiler, - tex_template.output_format, + tex_compiler, + output_format, ) - svg_file = convert_to_svg(dvi_file, tex_template.output_format) + svg_file = convert_to_svg(dvi_file, output_format) if not config["no_latex_cleanup"]: delete_nonsvg_files() return svg_file @@ -116,7 +121,10 @@ def generate_tex_file( def make_tex_compilation_command( - tex_compiler: str, output_format: str, tex_file: Path, tex_dir: Path + tex_compiler: TexCompiler, + output_format: TexOutputFormat, + tex_file: Path, + tex_dir: Path, ) -> list[str]: """Prepares the TeX compilation command, i.e. the TeX compiler name and all necessary CLI flags. @@ -137,29 +145,37 @@ def make_tex_compilation_command( :class:`list[str]` Compilation command according to given parameters """ - if tex_compiler in {"latex", "pdflatex", "luatex", "lualatex"}: + if output_format not in tex_compiler.output_formats: + raise ValueError( + f"{tex_compiler} does not support output format {output_format}" + ) + if tex_compiler in {TexCompiler.LATEX, TexCompiler.PDFLATEX, TexCompiler.LUALATEX}: command = [ tex_compiler, - "-interaction=batchmode", - f"-output-format={output_format[1:]}", - "-halt-on-error", - f"-output-directory={tex_dir.as_posix()}", - f"{tex_file.as_posix()}", + "--interaction=batchmode", + f"--output-format={output_format.removeprefix('.')}", + "--halt-on-error", + f"--output-directory={tex_dir.as_posix()}", + tex_file.as_posix(), ] - elif tex_compiler == "xelatex": - if output_format == ".xdv": - outflag = ["-no-pdf"] - elif output_format == ".pdf": - outflag = [] - else: - raise ValueError("xelatex output is either pdf or xdv") + elif tex_compiler == TexCompiler.XELATEX: + outflag = ["-no-pdf"] if output_format is TexOutputFormat.XDV else [] command = [ - "xelatex", + tex_compiler, *outflag, - "-interaction=batchmode", - "-halt-on-error", - f"-output-directory={tex_dir.as_posix()}", - f"{tex_file.as_posix()}", + "--interaction=batchmode", + "--halt-on-error", + f"--output-directory={tex_dir.as_posix()}", + tex_file.as_posix(), + ] + elif tex_compiler is TexCompiler.TECTONIC: + command = [ + tex_compiler, + "--outfmt", + output_format.removeprefix("."), + "-o", + tex_dir.as_posix(), + tex_file.as_posix(), ] else: raise ValueError(f"Tex compiler {tex_compiler} unknown.") @@ -179,7 +195,9 @@ def insight_package_not_found_error(matching: Match[str]) -> Generator[str]: def compile_tex( - tex_file: Path, tex_compiler: str | list[str], output_format: str + tex_file: Path, + tex_compiler: TexCompiler | list[TexCompiler], + output_format: TexOutputFormat = TexOutputFormat.DVI, ) -> Path: """Compiles a tex_file into a .dvi or a .xdv or a .pdf @@ -189,16 +207,16 @@ def compile_tex( File name of TeX file to be typeset. tex_compiler The TeX compiler(s) to be used. - Can be a single compiler (e.g. ``"latex"``, ``"pdflatex"`` ``"lualatex"``) or a list of compilers to compile in order (e.g. ``["lualatex", "pdflatex"]``). + Can be a single compiler (e.g. ``"latex"``, ``"pdflatex"``, ``"lualatex"``) or a list of compilers to compile in order (e.g. ``["lualatex", "pdflatex"]``). output_format - String containing the output format generated by the compiler, e.g. ``.dvi`` or ``.pdf`` + String containing the output format generated by the compiler, e.g. ``.dvi``, ``.pdf`` or ``.xdv`` Returns ------- :class:`Path` Path to generated output file in desired format (DVI, XDV or PDF). """ - result = tex_file.with_suffix(output_format) + result = tex_file.with_suffix(str(output_format)) tex_dir = config.get_dir("tex_dir") tex_compilers = [tex_compiler] if isinstance(tex_compiler, str) else tex_compiler if not result.exists(): @@ -223,15 +241,17 @@ def compile_tex( return result -def convert_to_svg(dvi_file: Path, extension: str, page: int = 1) -> Path: +def convert_to_svg( + dvi_file: Path, output_format: TexOutputFormat, page: int = 1 +) -> Path: """Converts a .dvi, .xdv, or .pdf file into an svg using dvisvgm. Parameters ---------- dvi_file File name of the input file to be converted. - extension - String containing the file extension and thus indicating the file type, e.g. ``.dvi`` or ``.pdf`` + output_format + String containing the file output format and thus indicating the file type, e.g. ``.dvi`` or ``.pdf`` page Page to be converted if input file is multi-page. @@ -244,7 +264,7 @@ def convert_to_svg(dvi_file: Path, extension: str, page: int = 1) -> Path: if not result.exists(): command = [ "dvisvgm", - *(["--pdf"] if extension == ".pdf" else []), + *(["--pdf"] if output_format is TexOutputFormat.PDF else []), f"--page={page}", "--no-fonts", "--verbosity=0", diff --git a/manim/utils/tex_templates.py b/manim/utils/tex_templates.py index 7273b67d0f..5f308f6ff8 100644 --- a/manim/utils/tex_templates.py +++ b/manim/utils/tex_templates.py @@ -7,6 +7,8 @@ "TexFontTemplates", ] +from manim.constants import TexCompiler, TexOutputFormat + from .tex import * # This file makes TexTemplateLibrary and TexFontTemplates available for use in manim Tex and MathTex objects. @@ -68,8 +70,8 @@ class TexTemplateLibrary: """ An instance of the default TeX template used by 3b1b """ ctex = TexTemplate( - tex_compiler="xelatex", - output_format=".xdv", + tex_compiler=TexCompiler.XELATEX, + output_format=TexOutputFormat.XDV, preamble=_3b1b_preamble.replace( r"\DisableLigatures{encoding = *, family = * }", r"\usepackage[UTF8]{ctex}", @@ -315,8 +317,8 @@ class TexTemplateLibrary: \usepackage[defaultmathsizes]{mathastext} """, ) -americantypewriter.tex_compiler = "xelatex" -americantypewriter.output_format = ".xdv" +americantypewriter.tex_compiler = TexCompiler.XELATEX +americantypewriter.output_format = TexOutputFormat.XDV # Minion Pro and Myriad Pro (and TX fonts symbols) mpmptx = _new_ams_template() @@ -333,8 +335,8 @@ class TexTemplateLibrary: \renewcommand\familydefault\rmdefault """, ) -mpmptx.tex_compiler = "xelatex" -mpmptx.output_format = ".xdv" +mpmptx.tex_compiler = TexCompiler.XELATEX +mpmptx.output_format = TexOutputFormat.XDV # New Century Schoolbook (Symbol Greek, PX math symbols) @@ -436,8 +438,8 @@ class TexTemplateLibrary: \usepackage[defaultmathsizes]{mathastext} """, ) -applechancery.tex_compiler = "xelatex" -applechancery.output_format = ".xdv" +applechancery.tex_compiler = TexCompiler.XELATEX +applechancery.output_format = TexOutputFormat.XDV # Zapf Chancery @@ -467,8 +469,8 @@ class TexTemplateLibrary: \usepackage[defaultmathsizes,italic]{mathastext} """, ) -italicverdana.tex_compiler = "xelatex" -italicverdana.output_format = ".xdv" +italicverdana.tex_compiler = TexCompiler.XELATEX +italicverdana.output_format = TexOutputFormat.XDV # URW Zapf Chancery (CM Greek) @@ -504,8 +506,8 @@ class TexTemplateLibrary: \usepackage[defaultmathsizes]{mathastext} """, ) -comicsansms.tex_compiler = "xelatex" -comicsansms.output_format = ".xdv" +comicsansms.tex_compiler = TexCompiler.XELATEX +comicsansms.output_format = TexOutputFormat.XDV # GFS Didot (Italic) @@ -531,8 +533,8 @@ class TexTemplateLibrary: \usepackage[defaultmathsizes]{mathastext} """, ) -chalkduster.tex_compiler = "lualatex" -chalkduster.output_format = ".pdf" +chalkduster.tex_compiler = TexCompiler.LUALATEX +chalkduster.output_format = TexOutputFormat.PDF # Minion Pro (and TX fonts symbols) @@ -546,8 +548,8 @@ class TexTemplateLibrary: \usepackage[defaultmathsizes]{mathastext} """, ) -mptx.tex_compiler = "xelatex" -mptx.output_format = ".xdv" +mptx.tex_compiler = TexCompiler.XELATEX +mptx.output_format = TexOutputFormat.XDV # GNU FreeSerif and FreeSans @@ -575,8 +577,8 @@ class TexTemplateLibrary: \renewcommand{\familydefault}{\rmdefault} """, ) -gnufsfs.tex_compiler = "xelatex" -gnufsfs.output_format = ".xdv" +gnufsfs.tex_compiler = TexCompiler.XELATEX +gnufsfs.output_format = TexOutputFormat.XDV # GFS NeoHellenic gfsneohellenic = _new_ams_template() @@ -631,8 +633,8 @@ class TexTemplateLibrary: \usepackage[defaultmathsizes,italic]{mathastext} """, ) -italicbaskerville.tex_compiler = "xelatex" -italicbaskerville.output_format = ".xdv" +italicbaskerville.tex_compiler = TexCompiler.XELATEX +italicbaskerville.output_format = TexOutputFormat.XDV # ECF JD (with TX fonts) @@ -676,8 +678,8 @@ class TexTemplateLibrary: \usepackage[defaultmathsizes]{mathastext} """, ) -papyrus.tex_compiler = "xelatex" -papyrus.output_format = ".xdv" +papyrus.tex_compiler = TexCompiler.XELATEX +papyrus.output_format = TexOutputFormat.XDV # GNU FreeSerif (and TX fonts symbols) @@ -695,8 +697,8 @@ class TexTemplateLibrary: \usepackage[defaultmathsizes]{mathastext} """, ) -gnufstx.tex_compiler = "xelatex" -gnufstx.output_format = ".pdf" +gnufstx.tex_compiler = TexCompiler.XELATEX +gnufstx.output_format = TexOutputFormat.PDF # ECF Skeetch (CM Greek) @@ -790,8 +792,8 @@ class TexTemplateLibrary: \usepackage[defaultmathsizes]{mathastext} """, ) -chalkboardse.tex_compiler = "xelatex" -chalkboardse.output_format = ".xdv" +chalkboardse.tex_compiler = TexCompiler.XELATEX +chalkboardse.output_format = TexOutputFormat.XDV # Noteworthy Light @@ -878,8 +880,8 @@ class TexTemplateLibrary: r"""\boldmath """, ) -brushscriptxpx.tex_compiler = "xelatex" -brushscriptxpx.output_format = ".xdv" +brushscriptxpx.tex_compiler = TexCompiler.XELATEX +brushscriptxpx.output_format = TexOutputFormat.XDV # URW Avant Garde (Symbol Greek) diff --git a/pyproject.toml b/pyproject.toml index 825d92c809..e788c4466b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -77,6 +77,9 @@ jupyterlab = [ typst = [ "typst>=0.14", ] +tectonic = [ + "tecto>=0.16.9", +] [dependency-groups] dev = [ diff --git a/uv.lock b/uv.lock index 854d87c49b..0ef3ba2f73 100644 --- a/uv.lock +++ b/uv.lock @@ -1436,6 +1436,9 @@ jupyterlab = [ { name = "jupyterlab" }, { name = "notebook" }, ] +tectonic = [ + { name = "tecto" }, +] typst = [ { name = "typst" }, ] @@ -1492,12 +1495,13 @@ requires-dist = [ { name = "skia-pathops", specifier = ">=0.9.0" }, { name = "srt", specifier = ">=3.0.0" }, { name = "svgelements", specifier = ">=1.9.0" }, + { name = "tecto", marker = "extra == 'tectonic'", specifier = ">=0.16.9" }, { name = "tqdm", specifier = ">=4.21.0" }, { name = "typing-extensions", specifier = ">=4.12.0" }, { name = "typst", marker = "extra == 'typst'", specifier = ">=0.14" }, { name = "watchdog", specifier = ">=2.0.0" }, ] -provides-extras = ["gui", "jupyterlab", "typst"] +provides-extras = ["gui", "jupyterlab", "typst", "tectonic"] [package.metadata.requires-dev] dev = [ @@ -3216,6 +3220,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/48/2c/6c9bb53db56c8a12a736d2158a8b842a5993b96daabc29d90a098e840280/svgelements-1.9.6-py2.py3-none-any.whl", hash = "sha256:8a5cf2cc066d98e713d5b875b1d6e5eeb9b92e855e835ebd7caab2713ae1dcad", size = 137856, upload-time = "2023-08-17T02:01:48.76Z" }, ] +[[package]] +name = "tecto" +version = "0.16.9" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/96/b8bd09640ae2ab7420db2f45fb78f74994999352a740c0034e03aed1f462/tecto-0.16.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ba51a38c90c7bf718c6a438f198c8ee3d2019251d8a1981606a67842c7a47b7a", size = 39665917, upload-time = "2026-04-18T15:03:42.256Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e8/3b55c499c66bf580d244b42de6c2215072e351742db74d103e0d4d24c26f/tecto-0.16.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7f083a36955e42fb7f0903df23fdd47a3f70e7ce925f86eee2ce45aedaae5c60", size = 39552101, upload-time = "2026-04-18T15:03:45.583Z" }, + { url = "https://files.pythonhosted.org/packages/f7/57/d5e5cd9d270613cf723629c9349c64c6eaf380271924d5b98f9fba822e35/tecto-0.16.9-py3-none-manylinux2014_x86_64.whl", hash = "sha256:bc7696ebf58c86ffeca77b6c4a83b9915e40e0ac04584fe574af5f00393b0731", size = 41607561, upload-time = "2026-04-18T15:03:49.051Z" }, + { url = "https://files.pythonhosted.org/packages/58/cb/47e6127d657c64dba48b77d4b8cf844d642dd5eaca7aada208cbe6338233/tecto-0.16.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:7f5df5d1f392e4e22030036c6e174a8a7e3eca50be32e504286c0f46553724b8", size = 19208539, upload-time = "2026-04-18T15:03:51.937Z" }, + { url = "https://files.pythonhosted.org/packages/41/2d/b03fb1cf50d9abce0c0e55aeeff6c35dca532c55844be948ae421080308a/tecto-0.16.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f2ee0401f0ee1033d27c35e17e46ba35101dd872d469581d8ff1d3e6b72a4857", size = 19725452, upload-time = "2026-04-18T15:03:54.323Z" }, + { url = "https://files.pythonhosted.org/packages/19/49/1d44a16885a6935c2b13c8481dbec0b84c00004e5072f384befc5b65a463/tecto-0.16.9-py3-none-win_amd64.whl", hash = "sha256:a067142f90b02eac2bac78d2e0e04fea75695e895e31b897a00ac6756342e004", size = 38343808, upload-time = "2026-04-18T15:03:56.951Z" }, +] + [[package]] name = "terminado" version = "0.18.1"