From 5e60b55e102d51b88bf920e54a9de882da413413 Mon Sep 17 00:00:00 2001 From: Philipp Imhof Date: Thu, 28 May 2020 21:43:58 +0200 Subject: [PATCH] Completely rewritten TeX template management --- example_scenes/customtex.py | 17 ++ manim/__init__.py | 1 + manim/__main__.py | 1 + manim/config.py | 46 ++++- manim/constants.py | 82 +++++++-- manim/mobject/svg/tex_mobject.py | 11 +- manim/utils/tex.py | 288 +++++++++++++++++++++++++++++++ manim/utils/tex_file_writing.py | 47 ++--- 8 files changed, 450 insertions(+), 43 deletions(-) create mode 100644 example_scenes/customtex.py create mode 100644 manim/utils/tex.py diff --git a/example_scenes/customtex.py b/example_scenes/customtex.py new file mode 100644 index 0000000000..3825aea33c --- /dev/null +++ b/example_scenes/customtex.py @@ -0,0 +1,17 @@ +from manim import * + +class ExampleFileScene(Scene): + def construct(self): + text=TexMobject(r"\vv{vb}") + #text=TextMobject(r"$\vv{vb}$") + self.play(Write(text)) + +class ExampleScene(Scene): + def construct(self): + tpl=TexTemplate() + tpl.append_package(["esvect",["f"]]) + config.register_tex_template(tpl) + + #text=TextMobject(r"$\vv{vb}$") + text=TexMobject(r"\vv{vb}") + self.play(Write(text)) diff --git a/manim/__init__.py b/manim/__init__.py index 48403e23ed..eb741d49c6 100644 --- a/manim/__init__.py +++ b/manim/__init__.py @@ -68,3 +68,4 @@ from .utils.sounds import * from .utils.space_ops import * from .utils.strings import * +from .utils.tex import * diff --git a/manim/__main__.py b/manim/__main__.py index 08dc1d325b..1eaeefde5f 100644 --- a/manim/__main__.py +++ b/manim/__main__.py @@ -7,6 +7,7 @@ def main(): args = config.parse_cli() cfg = config.get_configuration(args) config.initialize_directories(cfg) + config.initialize_tex(cfg) extract_scene.main(cfg) diff --git a/manim/config.py b/manim/config.py index a353a42184..02e240bee3 100644 --- a/manim/config.py +++ b/manim/config.py @@ -3,12 +3,14 @@ import os import sys import types +import manim.constants as consts +from .utils.tex import * from . import constants from . import dirs from .logger import logger -__all__ = ["parse_cli", "get_configuration", "initialize_directories"] +__all__ = ["parse_cli", "get_configuration", "initialize_directories","register_tex_template","initialize_tex"] def parse_cli(): @@ -140,7 +142,12 @@ def parse_cli(): "--text_dir", help="Directory to write text", ) + parser.add_argument( + "--tex_template", + help="Specify a custom TeX template file", + ) return parser.parse_args() + except argparse.ArgumentError as err: logger.error(str(err)) sys.exit(2) @@ -176,6 +183,7 @@ def get_configuration(args): "video_dir": args.video_dir, "tex_dir": args.tex_dir, "text_dir": args.text_dir, + "tex_template": args.tex_template, } # Camera configuration @@ -281,3 +289,39 @@ def initialize_directories(config): dirs.VIDEO_DIR = dir_config["video_dir"] dirs.TEX_DIR = dir_config["tex_dir"] dirs.TEXT_DIR = dir_config["text_dir"] + +def register_tex_template(tpl): + """Register the given LaTeX template for later use. + + Parameters + ---------- + tpl : :class:`~.TexTemplate` + The LaTeX template to register. + """ + consts.TEX_TEMPLATE = tpl + +def initialize_tex(config): + """Safely create a LaTeX template object from a file. + If file is not readable, the default template file is used. + + Parameters + ---------- + filename : :class:`str` + The name of the file with the LaTeX template. + """ + filename="" + if config["tex_template"]: + filename = os.path.expanduser(config["tex_template"]) + if filename and not os.access(filename, os.R_OK): + # custom template not available, fallback to default + logger.warning( + f"Custom TeX template {filename} not found or not readable. " + "Falling back to the default template." + ) + filename = "" + if filename: + # still having a filename -> use the file + consts.TEX_TEMPLATE = TexTemplateFromFile(filename=filename) + else: + # use the default template + consts.TEX_TEMPLATE = TexTemplate() diff --git a/manim/constants.py b/manim/constants.py index c1b2e8687b..3486168957 100644 --- a/manim/constants.py +++ b/manim/constants.py @@ -2,6 +2,58 @@ import os from .logger import logger +MEDIA_DIR = "" +VIDEO_DIR = "" +VIDEO_OUTPUT_DIR = "" +TEX_DIR = "" +TEXT_DIR = "" +TEX_TEMPLATE = None + +def initialize_directories(config): + global MEDIA_DIR + global VIDEO_DIR + global VIDEO_OUTPUT_DIR + global TEX_DIR + global TEXT_DIR + + video_path_specified = config["video_dir"] or config["video_output_dir"] + + if not (video_path_specified and config["tex_dir"]): + if config["media_dir"]: + MEDIA_DIR = config["media_dir"] + else: + MEDIA_DIR = os.path.join( + os.path.expanduser('~'), + "Dropbox (3Blue1Brown)/3Blue1Brown Team Folder" + ) + if not os.path.isdir(MEDIA_DIR): + MEDIA_DIR = "./media" + print( + f"Media will be written to {MEDIA_DIR + os.sep}. You can change " + "this behavior with the --media_dir flag." + ) + else: + if config["media_dir"]: + print( + "Ignoring --media_dir, since both --tex_dir and a video " + "directory were both passed" + ) + + TEX_DIR = config["tex_dir"] or os.path.join(MEDIA_DIR, "Tex") + TEXT_DIR = os.path.join(MEDIA_DIR, "texts") + if not video_path_specified: + VIDEO_DIR = os.path.join(MEDIA_DIR, "videos") + VIDEO_OUTPUT_DIR = os.path.join(MEDIA_DIR, "videos") + elif config["video_output_dir"]: + VIDEO_OUTPUT_DIR = config["video_output_dir"] + else: + VIDEO_DIR = config["video_dir"] + + for folder in [VIDEO_DIR, VIDEO_OUTPUT_DIR, TEX_DIR, TEXT_DIR]: + if folder != "" and not os.path.exists(folder): + os.makedirs(folder) + + NOT_SETTING_FONT_MSG=''' You haven't set font. If you are not using English, this may cause text rendering problem. @@ -20,19 +72,23 @@ class MyText(Text): OBLIQUE = 'OBLIQUE' BOLD = 'BOLD' -TEX_USE_CTEX = False -TEX_TEXT_TO_REPLACE = "YourTextHere" -TEMPLATE_TEX_FILE = os.path.join( - os.path.dirname(os.path.realpath(__file__)), - "tex_template.tex" if not TEX_USE_CTEX else "ctex_template.tex" -) -with open(TEMPLATE_TEX_FILE, "r") as infile: - TEMPLATE_TEXT_FILE_BODY = infile.read() - TEMPLATE_TEX_FILE_BODY = TEMPLATE_TEXT_FILE_BODY.replace( - TEX_TEXT_TO_REPLACE, - "\\begin{align*}\n" + TEX_TEXT_TO_REPLACE + "\n\\end{align*}", - ) - +HELP_MESSAGE = """ + Usage: + python extract_scene.py [] + -p preview in low quality + -s show and save picture of last frame + -w write result to file [this is default if nothing else is stated] + -o write to a different file_name + -l use low quality + -m use medium quality + -a run and save every scene in the script, or all args for the given scene + -q don't print progress + -f when writing to a movie file, export the frames in png sequence + -t use transperency when exporting images + -n specify the number of the animation to start from + -r specify a resolution + -c specify a background color +""" SCENE_NOT_FOUND_MESSAGE = """ {} is not in the script """ diff --git a/manim/mobject/svg/tex_mobject.py b/manim/mobject/svg/tex_mobject.py index 4c5d645150..f016cba804 100644 --- a/manim/mobject/svg/tex_mobject.py +++ b/manim/mobject/svg/tex_mobject.py @@ -11,7 +11,6 @@ from ...utils.strings import split_string_list_to_isolate_substrings from ...utils.tex_file_writing import tex_to_svg_file - TEX_MOB_SCALE_FACTOR = 0.05 @@ -21,10 +20,8 @@ class TexSymbol(VMobjectFromSVGPathstring): """ pass - class SingleStringTexMobject(SVGMobject): CONFIG = { - "template_tex_file_body": TEMPLATE_TEX_FILE_BODY, "stroke_width": 0, "fill_opacity": 1.0, "background_stroke_width": 1, @@ -33,6 +30,7 @@ class SingleStringTexMobject(SVGMobject): "height": None, "organize_left_to_right": False, "alignment": "", + "type": "tex", } def __init__(self, tex_string, **kwargs): @@ -41,7 +39,7 @@ def __init__(self, tex_string, **kwargs): self.tex_string = tex_string file_name = tex_to_svg_file( self.get_modified_expression(tex_string), - self.template_tex_file_body + self.type ) SVGMobject.__init__(self, file_name=file_name, **kwargs) if self.height is None: @@ -247,9 +245,9 @@ def sort_alphabetically(self): class TextMobject(TexMobject): CONFIG = { - "template_tex_file_body": TEMPLATE_TEXT_FILE_BODY, "alignment": "\\centering", "arg_separator": "", + "type": "text", } @@ -258,7 +256,6 @@ class BulletedList(TextMobject): "buff": MED_LARGE_BUFF, "dot_scale_factor": 2, # Have to include because of handle_multiple_args implementation - "template_tex_file_body": TEMPLATE_TEXT_FILE_BODY, "alignment": "", } @@ -325,4 +322,4 @@ def __init__(self, *text_parts, **kwargs): else: underline.set_width(self.underline_width) self.add(underline) - self.underline = underline \ No newline at end of file + self.underline = underline diff --git a/manim/utils/tex.py b/manim/utils/tex.py new file mode 100644 index 0000000000..a38161b31b --- /dev/null +++ b/manim/utils/tex.py @@ -0,0 +1,288 @@ +import os +from ..utils.config_ops import digest_config + +class TexTemplateFromFile(): + """ + Class representing a TeX template file + """ # TODO: attributes, dataclasses stuff + CONFIG = { + "use_ctex": False, + "filename" : "tex_template.tex", + "text_to_replace": "YourTextHere", + } + body = "" + + def __init__(self, **kwargs): + digest_config(self, kwargs) + self.rebuild_cache() + + def rebuild_cache(self): + """For faster access, the LaTeX template's code is cached. + If the base file is modified, the cache needs to be rebuilt. + """ + with open(self.filename, "r") as infile: + self.body = infile.read() + + def get_text_for_text_mode(self,expression): + """Inserting expression verbatim into TeX template. + + Parameters + ---------- + expression : :class:`str` + String containing the expression to be typeset, e.g. `"foo"` + + Returns + ------- + :class:`str` + LaTeX code based on the template containing the given expression and ready for typesetting. + """ + return self.body.replace( + self.text_to_replace, expression + ) + + def get_text_for_env(self, environment, expression): + """Inserts an expression into the TeX template, surrounded + by `\\begin{} ... \\end{}` for a certain environment. + + Parameters + ---------- + environment : :class:`str` + The environment in which we should wrap the expression. + expression : :class:`str` + The string containing the expression to be typeset, e.g. $\\sqrt{2}$ + + Returns + ------- + :class:`str` + LaTeX code based on template, containing the given expression and ready for typesetting + """ + begin = r"\begin{" + environment + "}" + end = r"\end{" + environment + "}" + return self.body.replace( + self.text_to_replace, + "{0}\n{1}\n{2}".format(begin, expression, end) + ) + + def get_text_for_tex_mode(self,expression): + """Inserts an expression into the TeX template, surrounded + by `\\begin{align*} ... \\end{align*}` for math mode. + + Parameters + ---------- + expression : :class:`str` + The string containing the (math) expression to be typeset, e.g. $\\sqrt{2}$ + + Returns + ------- + :class:`str` + LaTeX code based on template, containing the given expression and ready for typesetting + """ + return self.get_text_for_env("align*", expression) + + +class TexTemplate(TexTemplateFromFile): + """ + Class for dynamically managing a TeX template + """ # TODO: Add attributes (when dataclasses are implemented) + CONFIG = { + "documentclass": ["standalone",["preview"]], + "common_packages": [ + ["babel",["english"]], + "amsmath", + "amssymb", + "dsfont", + "setspace", + "tipa", + "relsize", + "textcomp", + "mathrsfs", + "calligra", + "wasysym", + "ragged2e", + "physics", + "xcolor", + "microtype" + ], + "tex_packages": [], + "ctex_packages": [["ctex",["UTF8"]]], + "common_preamble_text": r"\linespread{1}" "\n", + "tex_preamble_text": r"\DisableLigatures{encoding = *, family = *}" "\n", + "ctex_preamble_text": "", + "document_prefix": "", + "document_suffix": "", + } + + def __init__(self, **kwargs): + digest_config(self, kwargs) + self.rebuild_cache() + + def rebuild_cache(self): + """For faster access, the LaTeX template's code is cached. + If the base file is modified, the cache needs to be rebuilt.""" + tpl = self.generate_tex_command( + "documentclass",required_params=[self.documentclass[0]], optional_params=self.documentclass[1] + ) + for pkg in self.common_packages: + tpl += self.generate_usepackage(pkg) + + if self.use_ctex: + for pkg in self.ctex_packages: + tpl += self.generate_usepackage(pkg) + else: + for pkg in self.tex_packages: + tpl += self.generate_usepackage(pkg) + + tpl += self.common_preamble_text + if self.use_ctex: + tpl += self.ctex_preamble_text + else: + tpl += self.tex_preamble_text + + tpl += "\n" r"\begin{document}" "\n" + tpl += f"\n{self.text_to_replace}\n" + tpl += "\n" r"\end{document}" + + self.body=tpl + + def prepend_package(self, pkg): + """Adds a new package (or several new packages) + before all other packages. Sometimes, the order of + the `\\usepackage` directives is relevant. + + Parameters + ---------- + pkg : :class:`str` + The package name, e.g. "siunitx" + """ + self.common_packages.insert(0, pkg) + self.rebuild_cache() + + def append_package(self, pkg): + """Adds a new package (or several new packages) + after all other packages. Sometimes, the order of + the `\\usepackage` directives is relevant. + + Parameters + ---------- + pkg : :class:`str` + The package name, e.g. "siunitx" + """ + self.common_packages.append(pkg) + self.rebuild_cache() + + def append_to_preamble(self,text): + """Adds commands (e.g. macro definitions) at the end of the preamble. + + Parameters + ---------- + text : :class:`str` + The text to be included, e.g. "\\newcommand{\\R}{\\mathbb{Q}}" + """ + if self.use_ctex: + self.ctex_preamble_text += text + else: + self.tex_preamble_text += text + self.rebuild_cache() + pass + + def clear_preamble(self): + """Removes custom definitions from the LaTeX preamble. + This does not affect the imported packages or documentclass.""" + self.common_preamble_text = "" + self.ctex_preamble_text = "" + self.tex_preamble_text = "" + self.rebuild_cache() + pass + + def generate_tex_command(self,command, *, required_params, optional_params = []): + """ + Function for creating LaTeX command strings with or without options. + Internally used to generate `\\usepackage{...}` + + Parameters + ---------- + command : :class:`str` + The command, e.g. `"usepackage"` + required_params : Iterable[:class:`str`] + The required parameters of this command, each wrapped in `{}`s. + optional_params : Iterable[:class:`str`] + The optional parameters of this command, each separated by a comma inside one `[]`. + + Examples + -------- + :: + generate_tex_command("usepackage", required_params=["packagename"], optional_params=["option1", "option2"]) + + Returns + ------- + :class:`str` + The generated command. + """ + optional_params = list(optional_params) # so we can measure its length + return r"\{0}{1}{2}".format( + command, + f"[{','.join(optional_params)}]" if optional_params else "", + "".join("{" + param + "}" for param in required_params) + ) + + def generate_usepackage(self,pkg): + if isinstance(pkg,list): + return self.generate_tex_command("usepackage",required_params=[pkg[0]],optional_params=pkg[1]) + else: + return self.generate_tex_command("usepackage",required_params=[pkg]) + + def get_text_for_text_mode(self,expression): + """Inserts an expression verbatim into the TeX template. + + Parameters + ----–----- + expression : :class:`str` + The expression to be typeset, e.g. `"foo"` + + Returns + ------- + :class:`str` + LaTeX code based on the template, containing the given expression and ready for typesetting + """ + return self.body.replace( + self.text_to_replace, expression + ) + + def get_text_for_env(self, environment, expression): + """Inserts an expression into the TeX template, surrounded + by `\\begin{} ... \\end{}` for a certain environment. + + Parameters + ---------- + environment : :class:`str` + The environment in which we should wrap the expression. + expression : :class:`str` + The string containing the expression to be typeset, e.g. $\\sqrt{2}$ + + Returns + ------- + :class:`str` + LaTeX code based on template, containing the given expression and ready for typesetting + """ + begin = r"\begin{" + environment + "}" + end = r"\end{" + environment + "}" + return self.body.replace( + self.text_to_replace, + "{0}\n{1}\n{2}".format(begin, expression, end) + ) + + def get_text_for_tex_mode(self,expression): + """Inserts an expression into the TeX template, surrounded + by `\\begin{align*} ... \\end{align*}` for math mode. + + Parameters + ---------- + expression : :class:`str` + The string containing the (math) expression to be typeset, e.g. $\\sqrt{2}$ + + Returns + ------- + :class:`str` + LaTeX code based on template, containing the given expression and ready for typesetting + """ + return self.get_text_for_env("align*", expression) diff --git a/manim/utils/tex_file_writing.py b/manim/utils/tex_file_writing.py index e3951187d6..8a28642dc2 100644 --- a/manim/utils/tex_file_writing.py +++ b/manim/utils/tex_file_writing.py @@ -3,44 +3,47 @@ from pathlib import Path -from ..constants import TEX_TEXT_TO_REPLACE -from ..constants import TEX_USE_CTEX +#from ..constants import TEX_TEXT_TO_REPLACE +#from ..constants import TEX_USE_CTEX +import manim.constants as consts + from .. import dirs from ..logger import logger -def tex_hash(expression, template_tex_file_body): - id_str = str(expression + template_tex_file_body) +def tex_hash(expression): + id_str = str(expression) hasher = hashlib.sha256() hasher.update(id_str.encode()) # Truncating at 16 bytes for cleanliness return hasher.hexdigest()[:16] +def tex_to_svg_file(expression, source_type): + tex_template = consts.TEX_TEMPLATE + tex_file = generate_tex_file(expression, tex_template, source_type) + dvi_file = tex_to_dvi(tex_file, tex_template.use_ctex) + return dvi_to_svg(dvi_file, use_ctex=tex_template.use_ctex) -def tex_to_svg_file(expression, template_tex_file_body): - tex_file = generate_tex_file(expression, template_tex_file_body) - dvi_file = tex_to_dvi(tex_file) - return dvi_to_svg(dvi_file) - +def generate_tex_file(expression, tex_template, source_type): + if source_type == "text": + output = tex_template.get_text_for_text_mode(expression) + elif source_type == "tex": + output = tex_template.get_text_for_tex_mode(expression) -def generate_tex_file(expression, template_tex_file_body): result = os.path.join( dirs.TEX_DIR, - tex_hash(expression, template_tex_file_body) + tex_hash(output) ) + ".tex" if not os.path.exists(result): logger.info("Writing \"%s\" to %s" % ( "".join(expression), result )) - new_body = template_tex_file_body.replace( - TEX_TEXT_TO_REPLACE, expression - ) with open(result, "w", encoding="utf-8") as outfile: - outfile.write(new_body) + outfile.write(output) return result -def tex_to_dvi(tex_file): - result = tex_file.replace(".tex", ".dvi" if not TEX_USE_CTEX else ".xdv") +def tex_to_dvi(tex_file, use_ctex = False): + result = tex_file.replace(".tex", ".dvi" if not use_ctex else ".xdv") result = Path(result).as_posix() tex_file = Path(tex_file).as_posix() tex_dir = Path(dirs.TEX_DIR).as_posix() @@ -53,7 +56,7 @@ def tex_to_dvi(tex_file): "\"{}\"".format(tex_file), ">", os.devnull - ] if not TEX_USE_CTEX else [ + ] if not use_ctex else [ "xelatex", "-no-pdf", "-interaction=batchmode", @@ -67,20 +70,20 @@ def tex_to_dvi(tex_file): if exit_code != 0: log_file = tex_file.replace(".tex", ".log") raise Exception( - ("Latex error converting to dvi. " if not TEX_USE_CTEX - else "Xelatex error converting to xdv. ") + + ("LaTeX error converting to dvi. " if not use_ctex + else "XeLaTeX error converting to xdv. ") + "See log output above or the log file: %s" % log_file) return result -def dvi_to_svg(dvi_file, regen_if_exists=False): +def dvi_to_svg(dvi_file, use_ctex=False, regen_if_exists=False): """ Converts a dvi, which potentially has multiple slides, into a directory full of enumerated pngs corresponding with these slides. Returns a list of PIL Image objects for these images sorted as they where in the dvi """ - result = dvi_file.replace(".dvi" if not TEX_USE_CTEX else ".xdv", ".svg") + result = dvi_file.replace(".dvi" if not use_ctex else ".xdv", ".svg") result = Path(result).as_posix() dvi_file = Path(dvi_file).as_posix() if not os.path.exists(result):