Cleaner local caching of Tex/Text data, and partially cleaned up configuration (#2259)

* Remove print("Reloading...")

* Change where exception mode is set, to be quieter

* Add default fallback monitor for when no monitors are detected

* Have StringMobject work with svg strings rather than necessarily writing to file

Change SVGMobject to allow taking in a string of svg code as an input

* Add caching functionality, and have Tex and Text both use it for saved svg strings

* Clean up tex_file_writing

* Get rid of get_tex_dir and get_text_dir

* Allow for a configurable cache location

* Make caching on disk a decorator, and update implementations for Tex and Text mobjects

* Remove stray prints

* Clean up how configuration is handled

In principle, all we need here is that manim looks to the default_config.yaml file, and updates it based on any local configuration files, whether in the current working directory or as specified by a CLI argument.

* Make the default size for hash_string an option

* Remove utils/customization.py

* Remove stray prints

* Consolidate camera configuration

This is still not optimal, but at least makes clearer the way that importing from constants.py kicks off some of the configuration code.

* Factor out configuration to be passed into a scene vs. that used to run a scene

* Use newer extract_scene.main interface

* Add clarifying message to note what exactly is being reloaded

* Minor clean up

* Minor clean up

* If it's worth caching to disk, then might as well do so in memory too during development

* No longer any need for custom hash_seeds in Tex and Text

* Remove display_during_execution

* Get rid of (no longer used) mobject_data directory reference

* Remove get_downloads_dir reference from register_font

* Update where downloads go

* Easier use of subdirectories in configuration

* Add new pip requirements
This commit is contained in:
Grant Sanderson 2024-12-05 16:51:14 -06:00 committed by GitHub
parent 5a70d67b98
commit 94f6f0aa96
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 417 additions and 462 deletions

View file

@ -61,9 +61,9 @@ from manimlib.scene.interactive_scene import *
from manimlib.scene.scene import * from manimlib.scene.scene import *
from manimlib.utils.bezier import * from manimlib.utils.bezier import *
from manimlib.utils.cache import *
from manimlib.utils.color import * from manimlib.utils.color import *
from manimlib.utils.dict_ops import * from manimlib.utils.dict_ops import *
from manimlib.utils.customization import *
from manimlib.utils.debug import * from manimlib.utils.debug import *
from manimlib.utils.directories import * from manimlib.utils.directories import *
from manimlib.utils.file_ops import * from manimlib.utils.file_ops import *

View file

@ -10,6 +10,8 @@ import screeninfo
import sys import sys
import yaml import yaml
from functools import lru_cache
from manimlib.logger import log from manimlib.logger import log
from manimlib.utils.dict_ops import merge_dicts_recursively from manimlib.utils.dict_ops import merge_dicts_recursively
from manimlib.utils.init_config import init_customization from manimlib.utils.init_config import init_customization
@ -17,9 +19,12 @@ from manimlib.utils.init_config import init_customization
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
if TYPE_CHECKING: if TYPE_CHECKING:
Module = importlib.util.types.ModuleType Module = importlib.util.types.ModuleType
from typing import Optional
__config_file__ = "custom_config.yml" # This has to be here instead of in constants.py
# due to its use in creating the camera configuration
FRAME_HEIGHT: float = 8.0
def parse_cli(): def parse_cli():
@ -300,69 +305,30 @@ def get_scene_module(args: Namespace) -> Module:
) )
def get_custom_config(): def load_yaml(file_path: str):
global __config_file__ try:
with open(file_path, "r") as file:
return yaml.safe_load(file) or {}
except FileNotFoundError:
return {}
@lru_cache
def get_global_config():
args = parse_cli()
global_defaults_file = os.path.join(get_manim_dir(), "manimlib", "default_config.yml") global_defaults_file = os.path.join(get_manim_dir(), "manimlib", "default_config.yml")
config = merge_dicts_recursively(
load_yaml(global_defaults_file),
load_yaml("custom_config.yml"), # From current working directory
load_yaml(args.config_file) if args.config_file else {},
)
if os.path.exists(global_defaults_file): # Set the subdirectories
with open(global_defaults_file, "r") as file: base = config['directories']['base']
custom_config = yaml.safe_load(file) for key, subdir in config['directories']['subdirs'].items():
config['directories'][key] = os.path.join(base, subdir)
if os.path.exists(__config_file__): return config
with open(__config_file__, "r") as file:
local_defaults = yaml.safe_load(file)
if local_defaults:
custom_config = merge_dicts_recursively(
custom_config,
local_defaults,
)
else:
with open(__config_file__, "r") as file:
custom_config = yaml.safe_load(file)
# Check temporary storage(custom_config)
if custom_config["directories"]["temporary_storage"] == "" and sys.platform == "win32":
log.warning(
"You may be using Windows platform and have not specified the path of" + \
" `temporary_storage`, which may cause OSError. So it is recommended" + \
" to specify the `temporary_storage` in the config file (.yml)"
)
return custom_config
def init_global_config(config_file):
global __config_file__
# ensure __config_file__ always exists
if config_file is not None:
if not os.path.exists(config_file):
log.error(f"Can't find {config_file}.")
if sys.platform == 'win32':
log.info(f"Copying default configuration file to {config_file}...")
os.system(f"copy default_config.yml {config_file}")
elif sys.platform in ["linux2", "darwin"]:
log.info(f"Copying default configuration file to {config_file}...")
os.system(f"cp default_config.yml {config_file}")
else:
log.info("Please create the configuration file manually.")
log.info("Read configuration from default_config.yml.")
else:
__config_file__ = config_file
global_defaults_file = os.path.join(get_manim_dir(), "manimlib", "default_config.yml")
if not (os.path.exists(global_defaults_file) or os.path.exists(__config_file__)):
log.info("There is no configuration file detected. Switch to the config file initializer:")
init_customization()
elif not os.path.exists(__config_file__):
log.info(f"Using the default configuration file, which you can modify in `{global_defaults_file}`")
log.info(
"If you want to create a local configuration file, you can create a file named" + \
f" `{__config_file__}`, or run `manimgl --config`"
)
def get_file_ext(args: Namespace) -> str: def get_file_ext(args: Namespace) -> str:
@ -435,7 +401,8 @@ def get_window_config(args: Namespace, custom_config: dict, camera_config: dict)
try: try:
monitors = screeninfo.get_monitors() monitors = screeninfo.get_monitors()
except screeninfo.ScreenInfoError: except screeninfo.ScreenInfoError:
pass # Default fallback
monitors = [screeninfo.Monitor(width=1920, height=1080)]
mon_index = custom_config["window_monitor"] mon_index = custom_config["window_monitor"]
monitor = monitors[min(mon_index, len(monitors) - 1)] monitor = monitors[min(mon_index, len(monitors) - 1)]
aspect_ratio = camera_config["pixel_width"] / camera_config["pixel_height"] aspect_ratio = camera_config["pixel_width"] / camera_config["pixel_height"]
@ -446,8 +413,13 @@ def get_window_config(args: Namespace, custom_config: dict, camera_config: dict)
return dict(size=(window_width, window_height)) return dict(size=(window_width, window_height))
def get_camera_config(args: Namespace, custom_config: dict) -> dict: def get_camera_config(args: Optional[Namespace] = None, custom_config: Optional[dict] = None) -> dict:
camera_config = {} if args is None:
args = parse_cli()
if custom_config is None:
custom_config = get_global_config()
camera_config = dict()
camera_resolutions = custom_config["camera_resolutions"] camera_resolutions = custom_config["camera_resolutions"]
if args.resolution: if args.resolution:
resolution = args.resolution resolution = args.resolution
@ -475,7 +447,7 @@ def get_camera_config(args: Namespace, custom_config: dict) -> dict:
"pixel_width": width, "pixel_width": width,
"pixel_height": height, "pixel_height": height,
"frame_config": { "frame_config": {
"frame_shape": ((width / height) * get_frame_height(), get_frame_height()), "frame_shape": ((width / height) * FRAME_HEIGHT, FRAME_HEIGHT),
}, },
"fps": fps, "fps": fps,
}) })
@ -496,21 +468,19 @@ def get_camera_config(args: Namespace, custom_config: dict) -> dict:
return camera_config return camera_config
def get_configuration(args: Namespace) -> dict: def get_scene_config(args: Namespace) -> dict:
init_global_config(args.config_file) """
custom_config = get_custom_config() Returns a dictionary to be used as key word arguments for Scene
camera_config = get_camera_config(args, custom_config) """
window_config = get_window_config(args, custom_config, camera_config) global_config = get_global_config()
camera_config = get_camera_config(args, global_config)
window_config = get_window_config(args, global_config, camera_config)
start, end = get_animations_numbers(args) start, end = get_animations_numbers(args)
return { return {
"module": get_scene_module(args), "file_writer_config": get_file_writer_config(args, global_config),
"scene_names": args.scene_names,
"file_writer_config": get_file_writer_config(args, custom_config),
"camera_config": camera_config, "camera_config": camera_config,
"window_config": window_config, "window_config": window_config,
"quiet": args.quiet or args.write_all,
"write_all": args.write_all,
"skip_animations": args.skip_animations, "skip_animations": args.skip_animations,
"start_at_animation_number": start, "start_at_animation_number": start,
"end_at_animation_number": end, "end_at_animation_number": end,
@ -518,26 +488,16 @@ def get_configuration(args: Namespace) -> dict:
"presenter_mode": args.presenter_mode, "presenter_mode": args.presenter_mode,
"leave_progress_bars": args.leave_progress_bars, "leave_progress_bars": args.leave_progress_bars,
"show_animation_progress": args.show_animation_progress, "show_animation_progress": args.show_animation_progress,
"prerun": args.prerun, "embed_exception_mode": global_config["embed_exception_mode"],
"embed_exception_mode": custom_config["embed_exception_mode"], "embed_error_sound": global_config["embed_error_sound"],
"embed_error_sound": custom_config["embed_error_sound"],
} }
def get_frame_height(): def get_run_config(args: Namespace):
return 8.0 return {
"module": get_scene_module(args),
"prerun": args.prerun,
def get_aspect_ratio(): "scene_names": args.scene_names,
cam_config = get_camera_config(parse_cli(), get_custom_config()) "quiet": args.quiet or args.write_all,
return cam_config['pixel_width'] / cam_config['pixel_height'] "write_all": args.write_all,
}
def get_default_pixel_width():
cam_config = get_camera_config(parse_cli(), get_custom_config())
return cam_config['pixel_width']
def get_default_pixel_height():
cam_config = get_camera_config(parse_cli(), get_custom_config())
return cam_config['pixel_height']

View file

@ -1,10 +1,8 @@
from __future__ import annotations from __future__ import annotations
import numpy as np import numpy as np
from manimlib.config import get_aspect_ratio from manimlib.config import get_camera_config
from manimlib.config import get_default_pixel_width from manimlib.config import FRAME_HEIGHT
from manimlib.config import get_default_pixel_height
from manimlib.config import get_frame_height
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
if TYPE_CHECKING: if TYPE_CHECKING:
@ -12,16 +10,20 @@ if TYPE_CHECKING:
from manimlib.typing import ManimColor, Vect3 from manimlib.typing import ManimColor, Vect3
# TODO, it feels a bit unprincipled to have some global constants
# depend on the output of this function, and for all that configuration
# code to be run merely upon importing from this file.
CAMERA_CONFIG = get_camera_config()
# Sizes relevant to default camera frame # Sizes relevant to default camera frame
ASPECT_RATIO: float = get_aspect_ratio() ASPECT_RATIO: float = CAMERA_CONFIG['pixel_width'] / CAMERA_CONFIG['pixel_height']
FRAME_HEIGHT: float = get_frame_height()
FRAME_WIDTH: float = FRAME_HEIGHT * ASPECT_RATIO FRAME_WIDTH: float = FRAME_HEIGHT * ASPECT_RATIO
FRAME_SHAPE: tuple[float, float] = (FRAME_WIDTH, FRAME_HEIGHT) FRAME_SHAPE: tuple[float, float] = (FRAME_WIDTH, FRAME_HEIGHT)
FRAME_Y_RADIUS: float = FRAME_HEIGHT / 2 FRAME_Y_RADIUS: float = FRAME_HEIGHT / 2
FRAME_X_RADIUS: float = FRAME_WIDTH / 2 FRAME_X_RADIUS: float = FRAME_WIDTH / 2
DEFAULT_PIXEL_HEIGHT: int = get_default_pixel_height() DEFAULT_PIXEL_HEIGHT: int = CAMERA_CONFIG['pixel_height']
DEFAULT_PIXEL_WIDTH: int = get_default_pixel_width() DEFAULT_PIXEL_WIDTH: int = CAMERA_CONFIG['pixel_width']
DEFAULT_FPS: int = 30 DEFAULT_FPS: int = 30
SMALL_BUFF: float = 0.1 SMALL_BUFF: float = 0.1

View file

@ -3,19 +3,28 @@ directories:
# to match the directory structure of the path to the # to match the directory structure of the path to the
# sourcecode generating that video # sourcecode generating that video
mirror_module_path: False mirror_module_path: False
# Where should manim output video and image files? # Manim may write to and read from teh file system, e.g.
output: "" # to render videos and to look for svg/png assets. This
# If you want to use images, manim will look to these folders to find them # will specify where those assets live, with a base directory,
raster_images: "" # and various subdirectory names within it
vector_images: "" base: ""
# If you want to use sounds, manim will look here to find it. subdirs:
sounds: "" # Where should manim output video and image files?
# Manim often generates tex_files or other kinds of serialized data output: "videos"
# to keep from having to generate the same thing too many times. By # If you want to use images, manim will look to these folders to find them
# default, these will be stored at tempfile.gettempdir(), e.g. this might raster_images: "raster_images"
# return whatever is at to the TMPDIR environment variable. If you want to vector_images: "vector_images"
# specify them elsewhere, # If you want to use sounds, manim will look here to find it.
temporary_storage: "" sounds: "sounds"
# Place for other forms of data relevant to any projects, like csv's
data: "data"
# When downloading, say an image, where will it go?
downloads: "downloads"
# For certain object types, especially Tex and Text, manim will save information
# to file to prevent the need to re-compute, e.g. recompiling the latex. By default,
# it stores this saved data to whatever directory appdirs.user_cache_dir("manim") returns,
# but here a user can specify a different cache location
cache: ""
universal_import_line: "from manimlib import *" universal_import_line: "from manimlib import *"
style: style:
tex_template: "default" tex_template: "default"

View file

@ -2,7 +2,7 @@ import copy
import inspect import inspect
import sys import sys
from manimlib.config import get_custom_config from manimlib.config import get_global_config
from manimlib.logger import log from manimlib.logger import log
from manimlib.scene.interactive_scene import InteractiveScene from manimlib.scene.interactive_scene import InteractiveScene
from manimlib.scene.scene import Scene from manimlib.scene.scene import Scene
@ -10,7 +10,7 @@ from manimlib.scene.scene import Scene
class BlankScene(InteractiveScene): class BlankScene(InteractiveScene):
def construct(self): def construct(self):
exec(get_custom_config()["universal_import_line"]) exec(get_global_config()["universal_import_line"])
self.embed() self.embed()
@ -53,14 +53,6 @@ def prompt_user_for_choice(scene_classes):
sys.exit(1) sys.exit(1)
def get_scene_config(config):
scene_parameters = inspect.signature(Scene).parameters.keys()
return {
key: config[key]
for key in set(scene_parameters).intersection(config.keys())
}
def compute_total_frames(scene_class, scene_config): def compute_total_frames(scene_class, scene_config):
""" """
When a scene is being written to file, a copy of the scene is run with When a scene is being written to file, a copy of the scene is run with
@ -79,19 +71,19 @@ def compute_total_frames(scene_class, scene_config):
return int(total_time * scene_config["camera_config"]["fps"]) return int(total_time * scene_config["camera_config"]["fps"])
def scene_from_class(scene_class, scene_config, config): def scene_from_class(scene_class, scene_config, run_config):
fw_config = scene_config["file_writer_config"] fw_config = scene_config["file_writer_config"]
if fw_config["write_to_movie"] and config["prerun"]: if fw_config["write_to_movie"] and run_config["prerun"]:
fw_config["total_frames"] = compute_total_frames(scene_class, scene_config) fw_config["total_frames"] = compute_total_frames(scene_class, scene_config)
return scene_class(**scene_config) return scene_class(**scene_config)
def get_scenes_to_render(all_scene_classes, scene_config, config): def get_scenes_to_render(all_scene_classes, scene_config, run_config):
if config["write_all"]: if run_config["write_all"]:
return [sc(**scene_config) for sc in all_scene_classes] return [sc(**scene_config) for sc in all_scene_classes]
names_to_classes = {sc.__name__ : sc for sc in all_scene_classes} names_to_classes = {sc.__name__: sc for sc in all_scene_classes}
scene_names = config["scene_names"] scene_names = run_config["scene_names"]
for name in set.difference(set(scene_names), names_to_classes): for name in set.difference(set(scene_names), names_to_classes):
log.error(f"No scene named {name} found") log.error(f"No scene named {name} found")
@ -105,7 +97,7 @@ def get_scenes_to_render(all_scene_classes, scene_config, config):
classes_to_run = prompt_user_for_choice(all_scene_classes) classes_to_run = prompt_user_for_choice(all_scene_classes)
return [ return [
scene_from_class(scene_class, scene_config, config) scene_from_class(scene_class, scene_config, run_config)
for scene_class in classes_to_run for scene_class in classes_to_run
] ]
@ -123,13 +115,10 @@ def get_scene_classes_from_module(module):
] ]
def main(config): def main(scene_config, run_config):
module = config["module"] if run_config["module"] is None:
scene_config = get_scene_config(config)
if module is None:
# If no module was passed in, just play the blank scene # If no module was passed in, just play the blank scene
return [BlankScene(**scene_config)] return [BlankScene(**scene_config)]
all_scene_classes = get_scene_classes_from_module(module) all_scene_classes = get_scene_classes_from_module(run_config["module"])
scenes = get_scenes_to_render(all_scene_classes, scene_config, config) return get_scenes_to_render(all_scene_classes, scene_config, run_config)
return scenes

View file

@ -2,6 +2,7 @@ from __future__ import annotations
from colour import Color from colour import Color
from manimlib.config import get_global_config
from manimlib.constants import BLACK, RED, YELLOW, WHITE from manimlib.constants import BLACK, RED, YELLOW, WHITE
from manimlib.constants import DL, DOWN, DR, LEFT, RIGHT, UL, UR from manimlib.constants import DL, DOWN, DR, LEFT, RIGHT, UL, UR
from manimlib.constants import SMALL_BUFF from manimlib.constants import SMALL_BUFF
@ -9,7 +10,6 @@ from manimlib.mobject.geometry import Line
from manimlib.mobject.geometry import Rectangle from manimlib.mobject.geometry import Rectangle
from manimlib.mobject.types.vectorized_mobject import VGroup from manimlib.mobject.types.vectorized_mobject import VGroup
from manimlib.mobject.types.vectorized_mobject import VMobject from manimlib.mobject.types.vectorized_mobject import VMobject
from manimlib.utils.customization import get_customization
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@ -57,7 +57,7 @@ class BackgroundRectangle(SurroundingRectangle):
**kwargs **kwargs
): ):
if color is None: if color is None:
color = get_customization()['style']['background_color'] color = get_global_config()['style']['background_color']
super().__init__( super().__init__(
mobject, mobject,
color=color, color=color,

View file

@ -7,7 +7,7 @@ import re
from manimlib.constants import BLACK, WHITE from manimlib.constants import BLACK, WHITE
from manimlib.mobject.svg.svg_mobject import SVGMobject from manimlib.mobject.svg.svg_mobject import SVGMobject
from manimlib.mobject.types.vectorized_mobject import VGroup from manimlib.mobject.types.vectorized_mobject import VGroup
from manimlib.utils.tex_file_writing import tex_content_to_svg_file from manimlib.utils.tex_file_writing import latex_to_svg
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@ -76,12 +76,8 @@ class SingleStringTex(SVGMobject):
self.additional_preamble self.additional_preamble
) )
def get_file_path(self) -> str: def get_svg_string_by_content(self, content: str) -> str:
content = self.get_tex_file_body(self.tex_string) return latex_to_svg(content, self.template, self.additional_preamble)
file_path = tex_content_to_svg_file(
content, self.template, self.additional_preamble, self.tex_string
)
return file_path
def get_tex_file_body(self, tex_string: str) -> str: def get_tex_file_body(self, tex_string: str) -> str:
new_tex = self.get_modified_expression(tex_string) new_tex = self.get_modified_expression(tex_string)

View file

@ -66,17 +66,18 @@ class StringMobject(SVGMobject, ABC):
self.use_labelled_svg = use_labelled_svg self.use_labelled_svg = use_labelled_svg
self.parse() self.parse()
super().__init__(**kwargs) svg_string = self.get_svg_string()
super().__init__(svg_string=svg_string, **kwargs)
self.set_stroke(stroke_color, stroke_width) self.set_stroke(stroke_color, stroke_width)
self.set_fill(fill_color, border_width=fill_border_width) self.set_fill(fill_color, border_width=fill_border_width)
self.labels = [submob.label for submob in self.submobjects] self.labels = [submob.label for submob in self.submobjects]
def get_file_path(self, is_labelled: bool = False) -> str: def get_svg_string(self, is_labelled: bool = False) -> str:
is_labelled = is_labelled or self.use_labelled_svg content = self.get_content(is_labelled or self.use_labelled_svg)
return self.get_file_path_by_content(self.get_content(is_labelled)) return self.get_svg_string_by_content(content)
@abstractmethod @abstractmethod
def get_file_path_by_content(self, content: str) -> str: def get_svg_string_by_content(self, content: str) -> str:
return "" return ""
def assign_labels_by_color(self, mobjects: list[VMobject]) -> None: def assign_labels_by_color(self, mobjects: list[VMobject]) -> None:
@ -109,8 +110,8 @@ class StringMobject(SVGMobject, ABC):
) )
) )
def mobjects_from_file(self, file_path: str) -> list[VMobject]: def mobjects_from_svg_string(self, svg_string: str) -> list[VMobject]:
submobs = super().mobjects_from_file(file_path) submobs = super().mobjects_from_svg_string(svg_string)
if self.use_labelled_svg: if self.use_labelled_svg:
# This means submobjects are colored according to spans # This means submobjects are colored according to spans

View file

@ -5,6 +5,7 @@ from xml.etree import ElementTree as ET
import numpy as np import numpy as np
import svgelements as se import svgelements as se
import io import io
from pathlib import Path
from manimlib.constants import RIGHT from manimlib.constants import RIGHT
from manimlib.logger import log from manimlib.logger import log
@ -39,6 +40,7 @@ class SVGMobject(VMobject):
def __init__( def __init__(
self, self,
file_name: str = "", file_name: str = "",
svg_string: str = "",
should_center: bool = True, should_center: bool = True,
height: float | None = None, height: float | None = None,
width: float | None = None, width: float | None = None,
@ -63,11 +65,19 @@ class SVGMobject(VMobject):
path_string_config: dict = dict(), path_string_config: dict = dict(),
**kwargs **kwargs
): ):
self.file_name = file_name or self.file_name if svg_string != "":
self.svg_string = svg_string
elif file_name != "":
self.svg_string = self.file_name_to_svg_string(file_name)
elif self.file_name != "":
self.file_name_to_svg_string(self.file_name)
else:
raise Exception("Must specify either a file_name or svg_string SVGMobject")
self.svg_default = dict(svg_default) self.svg_default = dict(svg_default)
self.path_string_config = dict(path_string_config) self.path_string_config = dict(path_string_config)
super().__init__(**kwargs ) super().__init__(**kwargs)
self.init_svg_mobject() self.init_svg_mobject()
self.ensure_positive_orientation() self.ensure_positive_orientation()
@ -97,7 +107,7 @@ class SVGMobject(VMobject):
if hash_val in SVG_HASH_TO_MOB_MAP: if hash_val in SVG_HASH_TO_MOB_MAP:
submobs = [sm.copy() for sm in SVG_HASH_TO_MOB_MAP[hash_val]] submobs = [sm.copy() for sm in SVG_HASH_TO_MOB_MAP[hash_val]]
else: else:
submobs = self.mobjects_from_file(self.get_file_path()) submobs = self.mobjects_from_svg_string(self.svg_string)
SVG_HASH_TO_MOB_MAP[hash_val] = [sm.copy() for sm in submobs] SVG_HASH_TO_MOB_MAP[hash_val] = [sm.copy() for sm in submobs]
self.add(*submobs) self.add(*submobs)
@ -111,11 +121,11 @@ class SVGMobject(VMobject):
self.__class__.__name__, self.__class__.__name__,
self.svg_default, self.svg_default,
self.path_string_config, self.path_string_config,
self.file_name self.svg_string
) )
def mobjects_from_file(self, file_path: str) -> list[VMobject]: def mobjects_from_svg_string(self, svg_string: str) -> list[VMobject]:
element_tree = ET.parse(file_path) element_tree = ET.ElementTree(ET.fromstring(svg_string))
new_tree = self.modify_xml_tree(element_tree) new_tree = self.modify_xml_tree(element_tree)
# New svg based on tree contents # New svg based on tree contents
@ -127,10 +137,8 @@ class SVGMobject(VMobject):
return self.mobjects_from_svg(svg) return self.mobjects_from_svg(svg)
def get_file_path(self) -> str: def file_name_to_svg_string(self, file_name: str) -> str:
if self.file_name is None: return Path(get_full_vector_image_path(file_name)).read_text()
raise Exception("Must specify file for SVGMobject")
return get_full_vector_image_path(self.file_name)
def modify_xml_tree(self, element_tree: ET.ElementTree) -> ET.ElementTree: def modify_xml_tree(self, element_tree: ET.ElementTree) -> ET.ElementTree:
config_style_attrs = self.generate_config_style_dict() config_style_attrs = self.generate_config_style_dict()

View file

@ -1,14 +1,16 @@
from __future__ import annotations from __future__ import annotations
import re import re
from pathlib import Path
from manimlib.mobject.svg.string_mobject import StringMobject from manimlib.mobject.svg.string_mobject import StringMobject
from manimlib.mobject.types.vectorized_mobject import VGroup from manimlib.mobject.types.vectorized_mobject import VGroup
from manimlib.mobject.types.vectorized_mobject import VMobject from manimlib.mobject.types.vectorized_mobject import VMobject
from manimlib.utils.color import color_to_hex from manimlib.utils.color import color_to_hex
from manimlib.utils.color import hex_to_int from manimlib.utils.color import hex_to_int
from manimlib.utils.tex_file_writing import tex_content_to_svg_file from manimlib.utils.tex_file_writing import latex_to_svg
from manimlib.utils.tex import num_tex_symbols from manimlib.utils.tex import num_tex_symbols
from manimlib.utils.simple_functions import hash_string
from manimlib.logger import log from manimlib.logger import log
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@ -65,27 +67,8 @@ class Tex(StringMobject):
self.set_color_by_tex_to_color_map(self.tex_to_color_map) self.set_color_by_tex_to_color_map(self.tex_to_color_map)
self.scale(SCALE_FACTOR_PER_FONT_POINT * font_size) self.scale(SCALE_FACTOR_PER_FONT_POINT * font_size)
@property def get_svg_string_by_content(self, content: str) -> str:
def hash_seed(self) -> tuple: return latex_to_svg(content, self.template, self.additional_preamble, short_tex=self.tex_string)
return (
self.__class__.__name__,
self.svg_default,
self.path_string_config,
self.base_color,
self.isolate,
self.protect,
self.tex_string,
self.alignment,
self.tex_environment,
self.tex_to_color_map,
self.template,
self.additional_preamble
)
def get_file_path_by_content(self, content: str) -> str:
return tex_content_to_svg_file(
content, self.template, self.additional_preamble, self.tex_string
)
def _handle_scale_side_effects(self, scale_factor: float) -> Self: def _handle_scale_side_effects(self, scale_factor: float) -> Self:
self.font_size *= scale_factor self.font_size *= scale_factor

View file

@ -4,21 +4,23 @@ from contextlib import contextmanager
import os import os
from pathlib import Path from pathlib import Path
import re import re
import tempfile
import hashlib
from functools import lru_cache
import manimpango import manimpango
import pygments import pygments
import pygments.formatters import pygments.formatters
import pygments.lexers import pygments.lexers
from manimlib.config import get_global_config
from manimlib.constants import DEFAULT_PIXEL_WIDTH, FRAME_WIDTH from manimlib.constants import DEFAULT_PIXEL_WIDTH, FRAME_WIDTH
from manimlib.constants import NORMAL from manimlib.constants import NORMAL
from manimlib.logger import log from manimlib.logger import log
from manimlib.mobject.svg.string_mobject import StringMobject from manimlib.mobject.svg.string_mobject import StringMobject
from manimlib.utils.customization import get_customization from manimlib.utils.cache import cache_on_disk
from manimlib.utils.color import color_to_hex from manimlib.utils.color import color_to_hex
from manimlib.utils.color import int_to_hex from manimlib.utils.color import int_to_hex
from manimlib.utils.directories import get_downloads_dir
from manimlib.utils.directories import get_text_dir
from manimlib.utils.simple_functions import hash_string from manimlib.utils.simple_functions import hash_string
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@ -49,6 +51,58 @@ class _Alignment:
self.value = _Alignment.VAL_DICT[s.upper()] self.value = _Alignment.VAL_DICT[s.upper()]
@lru_cache(maxsize=128)
@cache_on_disk
def markup_to_svg(
markup_str: str,
justify: bool = False,
indent: float = 0,
alignment: str = "CENTER",
line_width: float | None = None,
) -> str:
validate_error = manimpango.MarkupUtils.validate(markup_str)
if validate_error:
raise ValueError(
f"Invalid markup string \"{markup_str}\"\n" + \
f"{validate_error}"
)
# `manimpango` is under construction,
# so the following code is intended to suit its interface
alignment = _Alignment(alignment)
if line_width is None:
pango_width = -1
else:
pango_width = line_width / FRAME_WIDTH * DEFAULT_PIXEL_WIDTH
# Write the result to a temporary svg file, and return it's contents.
# TODO, better would be to have this not write to file at all
with tempfile.NamedTemporaryFile(suffix='.svg', mode='r+') as tmp:
manimpango.MarkupUtils.text2svg(
text=markup_str,
font="", # Already handled
slant="NORMAL", # Already handled
weight="NORMAL", # Already handled
size=1, # Already handled
_=0, # Empty parameter
disable_liga=False,
file_name=tmp.name,
START_X=0,
START_Y=0,
width=DEFAULT_CANVAS_WIDTH,
height=DEFAULT_CANVAS_HEIGHT,
justify=justify,
indent=indent,
line_spacing=None, # Already handled
alignment=alignment,
pango_width=pango_width
)
# Read the contents
tmp.seek(0)
return tmp.read()
class MarkupText(StringMobject): class MarkupText(StringMobject):
# See https://docs.gtk.org/Pango/pango_markup.html # See https://docs.gtk.org/Pango/pango_markup.html
MARKUP_TAGS = { MARKUP_TAGS = {
@ -102,13 +156,14 @@ class MarkupText(StringMobject):
isolate: Selector = re.compile(r"\w+", re.U), isolate: Selector = re.compile(r"\w+", re.U),
**kwargs **kwargs
): ):
default_style = get_global_config()["style"]
self.text = text self.text = text
self.font_size = font_size self.font_size = font_size
self.justify = justify self.justify = justify
self.indent = indent self.indent = indent
self.alignment = alignment or get_customization()["style"]["text_alignment"] self.alignment = alignment or default_style["text_alignment"]
self.line_width = line_width self.line_width = line_width
self.font = font or get_customization()["style"]["font"] self.font = font or default_style["font"]
self.slant = slant self.slant = slant
self.weight = weight self.weight = weight
@ -141,88 +196,14 @@ class MarkupText(StringMobject):
if height is None: if height is None:
self.scale(TEXT_MOB_SCALE_FACTOR) self.scale(TEXT_MOB_SCALE_FACTOR)
@property def get_svg_string_by_content(self, content: str) -> str:
def hash_seed(self) -> tuple: self.content = content
return ( return markup_to_svg(
self.__class__.__name__,
self.svg_default,
self.path_string_config,
self.base_color,
self.isolate,
self.protect,
self.text,
self.font_size,
self.lsh,
self.justify,
self.indent,
self.alignment,
self.line_width,
self.font,
self.slant,
self.weight,
self.t2c,
self.t2f,
self.t2s,
self.t2w,
self.global_config,
self.local_configs,
self.disable_ligatures
)
def get_file_path_by_content(self, content: str) -> str:
hash_content = str((
content, content,
self.justify,
self.indent,
self.alignment,
self.line_width
))
svg_file = os.path.join(
get_text_dir(), hash_string(hash_content) + ".svg"
)
if not os.path.exists(svg_file):
self.markup_to_svg(content, svg_file)
return svg_file
def markup_to_svg(self, markup_str: str, file_name: str) -> str:
self.validate_markup_string(markup_str)
# `manimpango` is under construction,
# so the following code is intended to suit its interface
alignment = _Alignment(self.alignment)
if self.line_width is None:
pango_width = -1
else:
pango_width = self.line_width / FRAME_WIDTH * DEFAULT_PIXEL_WIDTH
return manimpango.MarkupUtils.text2svg(
text=markup_str,
font="", # Already handled
slant="NORMAL", # Already handled
weight="NORMAL", # Already handled
size=1, # Already handled
_=0, # Empty parameter
disable_liga=False,
file_name=file_name,
START_X=0,
START_Y=0,
width=DEFAULT_CANVAS_WIDTH,
height=DEFAULT_CANVAS_HEIGHT,
justify=self.justify, justify=self.justify,
indent=self.indent, indent=self.indent,
line_spacing=None, # Already handled alignment=self.alignment,
alignment=alignment, line_width=self.line_width
pango_width=pango_width
)
@staticmethod
def validate_markup_string(markup_str: str) -> None:
validate_error = manimpango.MarkupUtils.validate(markup_str)
if not validate_error:
return
raise ValueError(
f"Invalid markup string \"{markup_str}\"\n" + \
f"{validate_error}"
) )
# Toolkits # Toolkits
@ -511,20 +492,10 @@ def register_font(font_file: str | Path):
method with previous releases will raise an :class:`AttributeError` on macOS. method with previous releases will raise an :class:`AttributeError` on macOS.
""" """
input_folder = Path(get_downloads_dir()).parent.resolve() file_path = Path(font_file).resolve()
possible_paths = [ if not file_path.exists():
Path(font_file), error = f"Can't find {font_file}."
input_folder / font_file,
]
for path in possible_paths:
path = path.resolve()
if path.exists():
file_path = path
break
else:
error = f"Can't find {font_file}." f"Tried these : {possible_paths}"
raise FileNotFoundError(error) raise FileNotFoundError(error)
try: try:
assert manimpango.register_font(str(file_path)) assert manimpango.register_font(str(file_path))
yield yield

View file

@ -37,8 +37,6 @@ class ReloadManager:
except KillEmbedded: except KillEmbedded:
# Requested via the `exit_raise` IPython runline magic # Requested via the `exit_raise` IPython runline magic
# by means of our scene.reload() command # by means of our scene.reload() command
print("Reloading...")
for scene in self.scenes: for scene in self.scenes:
scene.tear_down() scene.tear_down()
@ -61,12 +59,14 @@ class ReloadManager:
self.args.embed = str(overwrite_start_at_line) self.args.embed = str(overwrite_start_at_line)
# Args to Config # Args to Config
config = manimlib.config.get_configuration(self.args) scene_config = manimlib.config.get_scene_config(self.args)
if self.window: if self.window:
config["existing_window"] = self.window # see scene initialization scene_config["existing_window"] = self.window # see scene initialization
run_config = manimlib.config.get_run_config(self.args)
# Scenes # Scenes
self.scenes = manimlib.extract_scene.main(config) self.scenes = manimlib.extract_scene.main(scene_config, run_config)
if len(self.scenes) == 0: if len(self.scenes) == 0:
print("No scenes found to run") print("No scenes found to run")
return return
@ -78,7 +78,13 @@ class ReloadManager:
break break
for scene in self.scenes: for scene in self.scenes:
if self.args.embed:
print(" ".join([
"Loading interactive session for",
f"\033[96m{self.args.scene_names[0]}\033[0m",
f"in \033[96m{self.args.file}\033[0m",
f"at line \033[96m{self.args.embed}\033[0m"
]))
scene.run() scene.run()
reload_manager = ReloadManager() reload_manager = ReloadManager()

View file

@ -232,7 +232,11 @@ class Scene(object):
# the local namespace of the caller # the local namespace of the caller
caller_frame = inspect.currentframe().f_back caller_frame = inspect.currentframe().f_back
module = get_module(caller_frame.f_globals["__file__"]) module = get_module(caller_frame.f_globals["__file__"])
shell = InteractiveShellEmbed(user_module=module) shell = InteractiveShellEmbed(
user_module=module,
display_banner=False,
xmode=self.embed_exception_mode
)
self.shell = shell self.shell = shell
# Add a few custom shortcuts to that local namespace # Add a few custom shortcuts to that local namespace
@ -288,9 +292,6 @@ class Scene(object):
shell.set_custom_exc((Exception,), custom_exc) shell.set_custom_exc((Exception,), custom_exc)
# Set desired exception mode
shell.magic(f"xmode {self.embed_exception_mode}")
# Launch shell # Launch shell
shell() shell()

View file

@ -10,7 +10,7 @@ import numpy as np
from functools import lru_cache from functools import lru_cache
from manimlib.config import parse_cli from manimlib.config import parse_cli
from manimlib.config import get_configuration from manimlib.config import get_camera_config
from manimlib.utils.shaders import get_shader_code_from_file from manimlib.utils.shaders import get_shader_code_from_file
from manimlib.utils.shaders import get_shader_program from manimlib.utils.shaders import get_shader_program
from manimlib.utils.shaders import image_path_to_texture from manimlib.utils.shaders import image_path_to_texture
@ -410,7 +410,7 @@ class VShaderWrapper(ShaderWrapper):
which can display that texture as a simple quad onto a screen, which can display that texture as a simple quad onto a screen,
along with the rgb value which is meant to be discarded. along with the rgb value which is meant to be discarded.
""" """
cam_config = get_configuration(parse_cli())['camera_config'] cam_config = get_camera_config()
size = (cam_config['pixel_width'], cam_config['pixel_height']) size = (cam_config['pixel_width'], cam_config['pixel_height'])
double_size = (2 * size[0], 2 * size[1]) double_size = (2 * size[0], 2 * size[1])

34
manimlib/utils/cache.py Normal file
View file

@ -0,0 +1,34 @@
from __future__ import annotations
import os
from diskcache import Cache
from contextlib import contextmanager
from functools import wraps
from manimlib.utils.directories import get_cache_dir
from manimlib.utils.simple_functions import hash_string
from typing import TYPE_CHECKING
if TYPE_CHECKING:
T = TypeVar('T')
CACHE_SIZE = 1e9 # 1 Gig
_cache = Cache(get_cache_dir(), size_limit=CACHE_SIZE)
def cache_on_disk(func: Callable[..., T]) -> Callable[..., T]:
@wraps(func)
def wrapper(*args, **kwargs):
key = hash_string(f"{func.__name__}{args}{kwargs}")
value = _cache.get(key)
if value is None:
value = func(*args, **kwargs)
_cache.set(key, value)
return value
return wrapper
def clear_cache():
_cache.clear()

View file

@ -1,24 +0,0 @@
import os
import tempfile
from manimlib.config import get_custom_config
from manimlib.config import get_manim_dir
CUSTOMIZATION = {}
def get_customization():
if not CUSTOMIZATION:
CUSTOMIZATION.update(get_custom_config())
directories = CUSTOMIZATION["directories"]
# Unless user has specified otherwise, use the system default temp
# directory for storing tex files, mobject_data, etc.
if not directories["temporary_storage"]:
directories["temporary_storage"] = tempfile.gettempdir()
# Assumes all shaders are written into manimlib/shaders
directories["shaders"] = os.path.join(
get_manim_dir(), "manimlib", "shaders"
)
return CUSTOMIZATION

View file

@ -1,33 +1,29 @@
from __future__ import annotations from __future__ import annotations
import os import os
import tempfile
import appdirs
from manimlib.utils.customization import get_customization
from manimlib.config import get_global_config
from manimlib.config import get_manim_dir
from manimlib.utils.file_ops import guarantee_existence from manimlib.utils.file_ops import guarantee_existence
def get_directories() -> dict[str, str]: def get_directories() -> dict[str, str]:
return get_customization()["directories"] return get_global_config()["directories"]
def get_cache_dir() -> str:
return get_directories()["cache"] or appdirs.user_cache_dir("manim")
def get_temp_dir() -> str: def get_temp_dir() -> str:
return get_directories()["temporary_storage"] return get_directories()["temporary_storage"] or tempfile.gettempdir()
def get_tex_dir() -> str:
return guarantee_existence(os.path.join(get_temp_dir(), "Tex"))
def get_text_dir() -> str:
return guarantee_existence(os.path.join(get_temp_dir(), "Text"))
def get_mobject_data_dir() -> str:
return guarantee_existence(os.path.join(get_temp_dir(), "mobject_data"))
def get_downloads_dir() -> str: def get_downloads_dir() -> str:
return guarantee_existence(os.path.join(get_temp_dir(), "manim_downloads")) return get_directories()["downloads"] or appdirs.user_cache_dir("manim_downloads")
def get_output_dir() -> str: def get_output_dir() -> str:
@ -47,4 +43,4 @@ def get_sound_dir() -> str:
def get_shader_dir() -> str: def get_shader_dir() -> str:
return get_directories()["shaders"] return os.path.join(get_manim_dir(), "manimlib", "shaders")

View file

@ -6,6 +6,10 @@ import hashlib
import numpy as np import numpy as np
import validators import validators
import urllib.request
import manimlib.utils.directories
from manimlib.utils.simple_functions import hash_string
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@ -35,11 +39,9 @@ def find_file(
# Check if this is a file online first, and if so, download # Check if this is a file online first, and if so, download
# it to a temporary directory # it to a temporary directory
if validators.url(file_name): if validators.url(file_name):
import urllib.request
from manimlib.utils.directories import get_downloads_dir
suffix = Path(file_name).suffix suffix = Path(file_name).suffix
file_hash = hashlib.sha256(file_name.encode('utf-8')).hexdigest()[:32] file_hash = hash_string(file_name)
folder = get_downloads_dir() folder = manimlib.utils.directories.get_downloads_dir()
path = Path(folder, file_hash).with_suffix(suffix) path = Path(folder, file_hash).with_suffix(suffix)
urllib.request.urlretrieve(file_name, path) urllib.request.urlretrieve(file_name, path)

View file

@ -36,11 +36,15 @@ def init_customization() -> None:
configuration = { configuration = {
"directories": { "directories": {
"mirror_module_path": False, "mirror_module_path": False,
"output": "", "base": "",
"raster_images": "", "subdirs": {
"vector_images": "", "output": "videos",
"sounds": "", "raster_images": "raster_images",
"temporary_storage": "", "vector_images": "vector_images",
"sounds": "sounds",
"data": "data",
"downloads": "downloads",
}
}, },
"universal_import_line": "from manimlib import *", "universal_import_line": "from manimlib import *",
"style": { "style": {
@ -74,33 +78,39 @@ def init_customization() -> None:
console.print("[bold]Directories:[/bold]") console.print("[bold]Directories:[/bold]")
dir_config = configuration["directories"] dir_config = configuration["directories"]
dir_config["output"] = Prompt.ask( dir_config["base"] = Prompt.ask(
" Where should manim [bold]output[/bold] video and image files place [prompt.default](optional, default is none)", " What base directory should manim use for reading/writing video and images? [prompt.default](optional, default is none)",
default="", default="",
show_default=False show_default=False
) )
dir_config["raster_images"] = Prompt.ask( dir_config["subdirs"]["output"] = Prompt.ask(
" Which folder should manim find [bold]raster images[/bold] (.jpg .png .gif) in " + \ " Within that base directory, which subdirectory should manim [bold]output[/bold] video and image files to?" + \
"[prompt.default](optional, default is none)", " [prompt.default](optional, default is \"videos\")",
default="", default="videos",
show_default=False show_default=False
) )
dir_config["vector_images"] = Prompt.ask( dir_config["subdirs"]["raster_images"] = Prompt.ask(
" Which folder should manim find [bold]vector images[/bold] (.svg .xdv) in " + \ " Within that base directory, which subdirectory should manim look for raster images (.png, .jpg)" + \
"[prompt.default](optional, default is none)", " [prompt.default](optional, default is \"raster_images\")",
default="", default="raster_images",
show_default=False show_default=False
) )
dir_config["sounds"] = Prompt.ask( dir_config["subdirs"]["vector_images"] = Prompt.ask(
" Which folder should manim find [bold]sound files[/bold] (.mp3 .wav) in " + \ " Within that base directory, which subdirectory should manim look for raster images (.svg, .xdv)" + \
"[prompt.default](optional, default is none)", " [prompt.default](optional, default is \"vector_images\")",
default="", default="vector_images",
show_default=False show_default=False
) )
dir_config["temporary_storage"] = Prompt.ask( dir_config["subdirs"]["sounds"] = Prompt.ask(
" Which folder should manim storage [bold]temporary files[/bold] " + \ " Within that base directory, which subdirectory should manim look for sound files (.mp3, .wav)" + \
"[prompt.default](recommended, use system temporary folder by default)", " [prompt.default](optional, default is \"sounds\")",
default="", default="sounds",
show_default=False
)
dir_config["subdirs"]["downloads"] = Prompt.ask(
" Within that base directory, which subdirectory should manim output downloaded files" + \
" [prompt.default](optional, default is \"downloads\")",
default="downloads",
show_default=False show_default=False
) )

View file

@ -96,7 +96,6 @@ def binary_search(function: Callable[[float], float],
return mh return mh
def hash_string(string: str) -> str: def hash_string(string: str, n_bytes=16) -> str:
# Truncating at 16 bytes for cleanliness
hasher = hashlib.sha256(string.encode()) hasher = hashlib.sha256(string.encode())
return hasher.hexdigest()[:16] return hasher.hexdigest()[:n_bytes]

View file

@ -1,14 +1,18 @@
from __future__ import annotations from __future__ import annotations
from contextlib import contextmanager
import os import os
import re import re
import yaml import yaml
import subprocess
from functools import lru_cache
from manimlib.config import get_custom_config from pathlib import Path
import tempfile
from manimlib.utils.cache import cache_on_disk
from manimlib.config import get_global_config
from manimlib.config import get_manim_dir from manimlib.config import get_manim_dir
from manimlib.logger import log from manimlib.logger import log
from manimlib.utils.directories import get_tex_dir
from manimlib.utils.simple_functions import hash_string from manimlib.utils.simple_functions import hash_string
@ -17,9 +21,8 @@ SAVED_TEX_CONFIG = {}
def get_tex_template_config(template_name: str) -> dict[str, str]: def get_tex_template_config(template_name: str) -> dict[str, str]:
name = template_name.replace(" ", "_").lower() name = template_name.replace(" ", "_").lower()
with open(os.path.join( template_path = os.path.join(get_manim_dir(), "manimlib", "tex_templates.yml")
get_manim_dir(), "manimlib", "tex_templates.yml" with open(template_path, encoding="utf-8") as tex_templates_file:
), encoding="utf-8") as tex_templates_file:
templates_dict = yaml.safe_load(tex_templates_file) templates_dict = yaml.safe_load(tex_templates_file)
if name not in templates_dict: if name not in templates_dict:
log.warning( log.warning(
@ -41,7 +44,7 @@ def get_tex_config() -> dict[str, str]:
""" """
# Only load once, then save thereafter # Only load once, then save thereafter
if not SAVED_TEX_CONFIG: if not SAVED_TEX_CONFIG:
template_name = get_custom_config()["style"]["tex_template"] template_name = get_global_config()["style"]["tex_template"]
template_config = get_tex_template_config(template_name) template_config = get_tex_template_config(template_name)
SAVED_TEX_CONFIG.update({ SAVED_TEX_CONFIG.update({
"template": template_name, "template": template_name,
@ -51,22 +54,8 @@ def get_tex_config() -> dict[str, str]:
return SAVED_TEX_CONFIG return SAVED_TEX_CONFIG
def tex_content_to_svg_file( def get_full_tex(content: str, preamble: str = ""):
content: str, template: str, additional_preamble: str, return "\n\n".join((
short_tex: str
) -> str:
tex_config = get_tex_config()
if not template or template == tex_config["template"]:
compiler = tex_config["compiler"]
preamble = tex_config["preamble"]
else:
config = get_tex_template_config(template)
compiler = config["compiler"]
preamble = config["preamble"]
if additional_preamble:
preamble += "\n" + additional_preamble
full_tex = "\n\n".join((
"\\documentclass[preview]{standalone}", "\\documentclass[preview]{standalone}",
preamble, preamble,
"\\begin{document}", "\\begin{document}",
@ -74,17 +63,43 @@ def tex_content_to_svg_file(
"\\end{document}" "\\end{document}"
)) + "\n" )) + "\n"
svg_file = os.path.join(
get_tex_dir(), hash_string(full_tex) + ".svg"
)
if not os.path.exists(svg_file):
# If svg doesn't exist, create it
with display_during_execution("Writing " + short_tex):
create_tex_svg(full_tex, svg_file, compiler)
return svg_file
@lru_cache(maxsize=128)
@cache_on_disk
def latex_to_svg(
latex: str,
template: str = "",
additional_preamble: str = "",
short_tex: str = "",
show_message_during_execution: bool = True,
) -> str:
"""Convert LaTeX string to SVG string.
Args:
latex: LaTeX source code
template: Path to a template LaTeX file
additional_preamble: String including any added "\\usepackage{...}" style imports
Returns:
str: SVG source code
Raises:
LatexError: If LaTeX compilation fails
NotImplementedError: If compiler is not supported
"""
if show_message_during_execution:
max_message_len = 80
message = f"Writing {short_tex or latex}"
if len(message) > max_message_len:
message = message[:max_message_len - 3] + "..."
print(message, end="\r")
tex_config = get_tex_config()
if template and template != tex_config["template"]:
tex_config = get_tex_template_config(template)
compiler = tex_config["compiler"]
def create_tex_svg(full_tex: str, svg_file: str, compiler: str) -> None:
if compiler == "latex": if compiler == "latex":
program = "latex" program = "latex"
dvi_ext = ".dvi" dvi_ext = ".dvi"
@ -92,72 +107,65 @@ def create_tex_svg(full_tex: str, svg_file: str, compiler: str) -> None:
program = "xelatex -no-pdf" program = "xelatex -no-pdf"
dvi_ext = ".xdv" dvi_ext = ".xdv"
else: else:
raise NotImplementedError( raise NotImplementedError(f"Compiler '{compiler}' is not implemented")
f"Compiler '{compiler}' is not implemented"
preamble = tex_config["preamble"] + "\n" + additional_preamble
full_tex = get_full_tex(latex, preamble)
# Write intermediate files to a temporary directory
with tempfile.TemporaryDirectory() as temp_dir:
base_path = os.path.join(temp_dir, "working")
tex_path = base_path + ".tex"
dvi_path = base_path + dvi_ext
# Write tex file
with open(tex_path, "w", encoding="utf-8") as tex_file:
tex_file.write(full_tex)
# Run latex compiler
process = subprocess.run(
[
program.split()[0], # Split for xelatex case
"-interaction=batchmode",
"-halt-on-error",
"-output-directory=" + temp_dir,
tex_path
] + (["--no-pdf"] if compiler == "xelatex" else []),
capture_output=True,
text=True
) )
# Write tex file if process.returncode != 0:
root, _ = os.path.splitext(svg_file) # Handle error
with open(root + ".tex", "w", encoding="utf-8") as tex_file: error_str = ""
tex_file.write(full_tex) log_path = base_path + ".log"
if os.path.exists(log_path):
with open(log_path, "r", encoding="utf-8") as log_file:
content = log_file.read()
error_match = re.search(r"(?<=\n! ).*\n.*\n", content)
if error_match:
error_str = error_match.group()
raise LatexError(error_str or "LaTeX compilation failed")
# tex to dvi # Run dvisvgm and capture output directly
if os.system(" ".join(( process = subprocess.run(
program, [
"-interaction=batchmode", "dvisvgm",
"-halt-on-error", dvi_path,
f"-output-directory=\"{os.path.dirname(svg_file)}\"", "-n", # no fonts
f"\"{root}.tex\"", "-v", "0", # quiet
">", "--stdout", # output to stdout instead of file
os.devnull ],
))): capture_output=True
log.error(
"LaTeX Error! Not a worry, it happens to the best of us."
) )
error_str = ""
with open(root + ".log", "r", encoding="utf-8") as log_file:
error_match_obj = re.search(r"(?<=\n! ).*\n.*\n", log_file.read())
if error_match_obj:
error_str = error_match_obj.group()
log.debug(
f"The error could be:\n`{error_str}`",
)
raise LatexError(error_str)
# dvi to svg # Return SVG string
os.system(" ".join(( result = process.stdout.decode('utf-8')
"dvisvgm",
f"\"{root}{dvi_ext}\"",
"-n",
"-v",
"0",
"-o",
f"\"{svg_file}\"",
">",
os.devnull
)))
# Cleanup superfluous documents if show_message_during_execution:
for ext in (".tex", dvi_ext, ".log", ".aux"): print(" " * len(message), end="\r")
try:
os.remove(root + ext)
except FileNotFoundError:
pass
return result
# TODO, perhaps this should live elsewhere
@contextmanager
def display_during_execution(message: str):
# Merge into a single line
to_print = message.replace("\n", " ")
max_characters = os.get_terminal_size().columns - 1
if len(to_print) > max_characters:
to_print = to_print[:max_characters - 3] + "..."
try:
print(to_print, end="\r")
yield
finally:
print(" " * len(to_print), end="\r")
class LatexError(Exception): class LatexError(Exception):

View file

@ -8,8 +8,8 @@ from moderngl_window.timers.clock import Timer
from screeninfo import get_monitors from screeninfo import get_monitors
from functools import wraps from functools import wraps
from manimlib.config import get_global_config
from manimlib.constants import FRAME_SHAPE from manimlib.constants import FRAME_SHAPE
from manimlib.utils.customization import get_customization
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@ -86,9 +86,10 @@ class Window(PygletWindow):
self.size = (w, h) self.size = (w, h)
def find_initial_position(self, size: tuple[int, int]) -> tuple[int, int]: def find_initial_position(self, size: tuple[int, int]) -> tuple[int, int]:
custom_position = get_customization()["window_position"] global_config = get_global_config()
custom_position = global_config["window_position"]
mon_index = global_config["window_monitor"]
monitors = get_monitors() monitors = get_monitors()
mon_index = get_customization()["window_monitor"]
monitor = monitors[min(mon_index, len(monitors) - 1)] monitor = monitors[min(mon_index, len(monitors) - 1)]
window_width, window_height = size window_width, window_height = size
# Position might be specified with a string of the form # Position might be specified with a string of the form

View file

@ -1,4 +1,6 @@
appdirs
colour colour
diskcache
ipython>=8.18.0 ipython>=8.18.0
isosurfaces isosurfaces
fontTools fontTools
@ -20,6 +22,7 @@ screeninfo
skia-pathops skia-pathops
svgelements>=1.8.1 svgelements>=1.8.1
sympy sympy
tempfile
tqdm tqdm
typing-extensions; python_version < "3.11" typing-extensions; python_version < "3.11"
validators validators