diff --git a/manimlib/animation/indication.py b/manimlib/animation/indication.py index 160464b3..bccd697f 100644 --- a/manimlib/animation/indication.py +++ b/manimlib/animation/indication.py @@ -12,6 +12,7 @@ from manimlib.animation.fading import FadeOut from manimlib.animation.fading import FadeIn from manimlib.animation.movement import Homotopy from manimlib.animation.transform import Transform +from manimlib.constants import FRAME_X_RADIUS, FRAME_Y_RADIUS from manimlib.constants import ORIGIN, RIGHT, UP from manimlib.constants import SMALL_BUFF from manimlib.constants import TAU diff --git a/manimlib/animation/transform_matching_parts.py b/manimlib/animation/transform_matching_parts.py index ef62ff7d..452333fd 100644 --- a/manimlib/animation/transform_matching_parts.py +++ b/manimlib/animation/transform_matching_parts.py @@ -167,83 +167,95 @@ class TransformMatchingStrings(AnimationGroup): digest_config(self, kwargs) assert isinstance(source, StringMobject) assert isinstance(target, StringMobject) - anims = [] - source_indices = list(range(len(source.labels))) - target_indices = list(range(len(target.labels))) - def get_filtered_indices_lists(indices_lists, rest_indices): + def get_matched_indices_lists(*part_items_list): + part_items_list_len = len(part_items_list) + indexed_part_items = sorted(it.chain(*[ + [ + (substr, items_index, indices_list) + for substr, indices_list in part_items + ] + for items_index, part_items in enumerate(part_items_list) + ])) + grouped_part_items = [ + (substr, [ + [indices_lists for _, _, indices_lists in grouper_2] + for _, grouper_2 in it.groupby( + grouper_1, key=lambda t: t[1] + ) + ]) + for substr, grouper_1 in it.groupby( + indexed_part_items, key=lambda t: t[0] + ) + ] + return [ + tuple(indices_lists_list) + for _, indices_lists_list in sorted(filter( + lambda t: t[0] and len(t[1]) == part_items_list_len, + grouped_part_items + ), key=lambda t: len(t[0]), reverse=True) + ] + + def get_filtered_indices_lists(indices_lists, used_indices): result = [] + used = [] for indices_list in indices_lists: - if not indices_list: - continue - if not all(index in rest_indices for index in indices_list): + if not all( + index not in used_indices and index not in used + for index in indices_list + ): continue result.append(indices_list) - for index in indices_list: - rest_indices.remove(index) - return result + used.extend(indices_list) + return result, used - def add_anims(anim_class, indices_lists_pairs): - for source_indices_lists, target_indices_lists in indices_lists_pairs: - source_indices_lists = get_filtered_indices_lists( - source_indices_lists, source_indices - ) - target_indices_lists = get_filtered_indices_lists( - target_indices_lists, target_indices - ) - if not source_indices_lists or not target_indices_lists: - source_indices.extend(it.chain(*source_indices_lists)) - target_indices.extend(it.chain(*target_indices_lists)) - continue - anims.append(anim_class( - source.build_parts_from_indices_lists(source_indices_lists), - target.build_parts_from_indices_lists(target_indices_lists), - **kwargs - )) - - def get_substr_to_indices_lists_map(part_items): - result = {} - for substr, indices_list in part_items: - if substr not in result: - result[substr] = [] - result[substr].append(indices_list) - return result - - def add_anims_from(anim_class, func): - source_substr_map = get_substr_to_indices_lists_map(func(source)) - target_substr_map = get_substr_to_indices_lists_map(func(target)) - common_substrings = sorted([ - s for s in source_substr_map if s and s in target_substr_map - ], key=len, reverse=True) - add_anims( - anim_class, - [ - (source_substr_map[substr], target_substr_map[substr]) - for substr in common_substrings - ] - ) - - add_anims( - ReplacementTransform, - [ + anim_class_items = [ + (ReplacementTransform, [ ( source.get_submob_indices_lists_by_selector(k), target.get_submob_indices_lists_by_selector(v) ) for k, v in self.key_map.items() - ] - ) - add_anims_from( - FadeTransformPieces, - StringMobject.get_specified_part_items - ) - add_anims_from( - FadeTransformPieces, - StringMobject.get_group_part_items - ) + ]), + (FadeTransformPieces, get_matched_indices_lists( + source.get_specified_part_items(), + target.get_specified_part_items() + )), + (FadeTransformPieces, get_matched_indices_lists( + source.get_group_part_items(), + target.get_group_part_items() + )) + ] - rest_source = VGroup(*[source[index] for index in source_indices]) - rest_target = VGroup(*[target[index] for index in target_indices]) + anims = [] + source_used_indices = [] + target_used_indices = [] + for anim_class, pairs in anim_class_items: + for source_indices_lists, target_indices_lists in pairs: + source_filtered, source_used = get_filtered_indices_lists( + source_indices_lists, source_used_indices + ) + target_filtered, target_used = get_filtered_indices_lists( + target_indices_lists, target_used_indices + ) + if not source_filtered or not target_filtered: + continue + anims.append(anim_class( + source.build_parts_from_indices_lists(source_filtered), + target.build_parts_from_indices_lists(target_filtered), + **kwargs + )) + source_used_indices.extend(source_used) + target_used_indices.extend(target_used) + + rest_source = VGroup(*[ + submob for index, submob in enumerate(source.submobjects) + if index not in source_used_indices + ]) + rest_target = VGroup(*[ + submob for index, submob in enumerate(target.submobjects) + if index not in target_used_indices + ]) if self.transform_mismatches: anims.append( ReplacementTransform(rest_source, rest_target, **kwargs) diff --git a/manimlib/default_config.yml b/manimlib/default_config.yml index 261eec56..65001983 100644 --- a/manimlib/default_config.yml +++ b/manimlib/default_config.yml @@ -16,17 +16,9 @@ directories: # return whatever is at to the TMPDIR environment variable. If you want to # specify them elsewhere, temporary_storage: "" -tex: - executable: "latex" - template_file: "tex_template.tex" - intermediate_filetype: "dvi" - text_to_replace: "[tex_expression]" - # For ctex, use the following configuration - # executable: "xelatex -no-pdf" - # template_file: "ctex_template.tex" - # intermediate_filetype: "xdv" universal_import_line: "from manimlib import *" style: + tex_template: "default" font: "Consolas" text_alignment: "LEFT" background_color: "#333333" @@ -49,4 +41,4 @@ camera_resolutions: high: "1920x1080" 4k: "3840x2160" default_resolution: "high" -fps: 30 \ No newline at end of file +fps: 30 diff --git a/manimlib/mobject/svg/mtex_mobject.py b/manimlib/mobject/svg/mtex_mobject.py index 149f313f..35a49695 100644 --- a/manimlib/mobject/svg/mtex_mobject.py +++ b/manimlib/mobject/svg/mtex_mobject.py @@ -1,9 +1,10 @@ from __future__ import annotations +import re + from manimlib.mobject.svg.string_mobject import StringMobject from manimlib.utils.tex_file_writing import display_during_execution -from manimlib.utils.tex_file_writing import get_tex_config -from manimlib.utils.tex_file_writing import tex_to_svg_file +from manimlib.utils.tex_file_writing import tex_content_to_svg_file from typing import TYPE_CHECKING @@ -37,6 +38,8 @@ class MTex(StringMobject): "alignment": "\\centering", "tex_environment": "align*", "tex_to_color_map": {}, + "template": "", + "additional_preamble": "", } def __init__(self, tex_string: str, **kwargs): @@ -57,77 +60,112 @@ class MTex(StringMobject): 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.tex_to_color_map, + self.template, + self.additional_preamble ) def get_file_path_by_content(self, content: str) -> str: - tex_config = get_tex_config() - full_tex = tex_config["tex_body"].replace( - tex_config["text_to_replace"], - content - ) - with display_during_execution(f"Writing \"{self.string}\""): - file_path = tex_to_svg_file(full_tex) + with display_during_execution(f"Writing \"{self.tex_string}\""): + file_path = tex_content_to_svg_file( + content, self.template, self.additional_preamble + ) return file_path # Parsing - def get_cmd_spans(self) -> list[Span]: - return self.find_spans(r"\\(?:[a-zA-Z]+|\s|\S)|[_^{}]") - - def get_substr_flag(self, substr: str) -> int: - return {"{": 1, "}": -1}.get(substr, 0) - - def get_repl_substr_for_content(self, substr: str) -> str: - return substr - - def get_repl_substr_for_matching(self, substr: str) -> str: - return substr if substr.startswith("\\") else "" - - def get_specified_items( - self, cmd_span_pairs: list[tuple[Span, Span]] - ) -> list[tuple[Span, dict[str, str]]]: - cmd_content_spans = [ - (span_begin, span_end) - for (_, span_begin), (span_end, _) in cmd_span_pairs - ] - specified_spans = [ - *[ - cmd_content_spans[range_begin] - for _, (range_begin, range_end) in self.compress_neighbours([ - (span_begin + index, span_end - index) - for index, (span_begin, span_end) in enumerate( - cmd_content_spans - ) - ]) - if range_end - range_begin >= 2 - ], - *[ - span - for selector in self.tex_to_color_map - for span in self.find_spans_by_selector(selector) - ], - *self.find_spans_by_selector(self.isolate) - ] - return [(span, {}) for span in specified_spans] + @staticmethod + def get_command_matches(string: str) -> list[re.Match]: + # Lump together adjacent brace pairs + pattern = re.compile(r""" + (?P\\(?:[a-zA-Z]+|.)) + |(?P{+) + |(?P}+) + """, flags=re.X | re.S) + result = [] + open_stack = [] + for match_obj in pattern.finditer(string): + if match_obj.group("open"): + open_stack.append((match_obj.span(), len(result))) + elif match_obj.group("close"): + close_start, close_end = match_obj.span() + while True: + if not open_stack: + raise ValueError("Missing '{' inserted") + (open_start, open_end), index = open_stack.pop() + n = min(open_end - open_start, close_end - close_start) + result.insert(index, pattern.fullmatch( + string, pos=open_end - n, endpos=open_end + )) + result.append(pattern.fullmatch( + string, pos=close_start, endpos=close_start + n + )) + close_start += n + if close_start < close_end: + continue + open_end -= n + if open_start < open_end: + open_stack.append(((open_start, open_end), index)) + break + else: + result.append(match_obj) + if open_stack: + raise ValueError("Missing '}' inserted") + return result @staticmethod - def get_color_cmd_str(rgb_hex: str) -> str: + def get_command_flag(match_obj: re.Match) -> int: + if match_obj.group("open"): + return 1 + if match_obj.group("close"): + return -1 + return 0 + + @staticmethod + def replace_for_content(match_obj: re.Match) -> str: + return match_obj.group() + + @staticmethod + def replace_for_matching(match_obj: re.Match) -> str: + if match_obj.group("command"): + return match_obj.group() + return "" + + @staticmethod + def get_attr_dict_from_command_pair( + open_command: re.Match, close_command: re.Match + ) -> dict[str, str] | None: + if len(open_command.group()) >= 2: + return {} + return None + + def get_configured_items(self) -> list[tuple[Span, dict[str, str]]]: + return [ + (span, {}) + for selector in self.tex_to_color_map + for span in self.find_spans_by_selector(selector) + ] + + @staticmethod + def get_color_command(rgb_hex: str) -> str: rgb = MTex.hex_to_int(rgb_hex) rg, b = divmod(rgb, 256) r, g = divmod(rg, 256) return f"\\color[RGB]{{{r}, {g}, {b}}}" @staticmethod - def get_cmd_str_pair( - attr_dict: dict[str, str], label_hex: str | None - ) -> tuple[str, str]: + def get_command_string( + attr_dict: dict[str, str], is_end: bool, label_hex: str | None + ) -> str: if label_hex is None: - return "", "" - return "{{" + MTex.get_color_cmd_str(label_hex), "}}" + return "" + if is_end: + return "}}" + return "{{" + MTex.get_color_command(label_hex) def get_content_prefix_and_suffix( self, is_labelled: bool @@ -135,17 +173,14 @@ class MTex(StringMobject): prefix_lines = [] suffix_lines = [] if not is_labelled: - prefix_lines.append(self.get_color_cmd_str(self.base_color_hex)) + prefix_lines.append(self.get_color_command( + self.color_to_hex(self.base_color) + )) if self.alignment: prefix_lines.append(self.alignment) if self.tex_environment: - if isinstance(self.tex_environment, str): - env_prefix = f"\\begin{{{self.tex_environment}}}" - env_suffix = f"\\end{{{self.tex_environment}}}" - else: - env_prefix, env_suffix = self.tex_environment - prefix_lines.append(env_prefix) - suffix_lines.append(env_suffix) + prefix_lines.append(f"\\begin{{{self.tex_environment}}}") + suffix_lines.append(f"\\end{{{self.tex_environment}}}") return ( "".join([line + "\n" for line in prefix_lines]), "".join(["\n" + line for line in suffix_lines]) @@ -156,8 +191,8 @@ class MTex(StringMobject): def get_parts_by_tex(self, selector: Selector) -> VGroup: return self.select_parts(selector) - def get_part_by_tex(self, selector: Selector) -> VGroup: - return self.select_part(selector) + def get_part_by_tex(self, selector: Selector, **kwargs) -> VGroup: + return self.select_part(selector, **kwargs) def set_color_by_tex(self, selector: Selector, color: ManimColor): return self.set_parts_color(selector, color) diff --git a/manimlib/mobject/svg/string_mobject.py b/manimlib/mobject/svg/string_mobject.py index e4376a33..e40fc5a5 100644 --- a/manimlib/mobject/svg/string_mobject.py +++ b/manimlib/mobject/svg/string_mobject.py @@ -18,7 +18,7 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: from colour import Color - from typing import Iterable, Sequence, TypeVar, Union + from typing import Callable, Iterable, Union ManimColor = Union[str, Color] Span = tuple[int, int] @@ -32,7 +32,6 @@ if TYPE_CHECKING: tuple[Union[int, None], Union[int, None]] ]] ] - T = TypeVar("T") class StringMobject(SVGMobject, ABC): @@ -47,7 +46,7 @@ class StringMobject(SVGMobject, ABC): if they want to do anything with their corresponding submobjects. `isolate` parameter can be either a string, a `re.Pattern` object, or a 2-tuple containing integers or None, or a collection of the above. - Note, substrings specified cannot *partially* overlap with each other. + Note, substrings specified cannot *partly* overlap with each other. Each instance of `StringMobject` generates 2 svg files. The additional one is generated with some color commands inserted, @@ -64,6 +63,7 @@ class StringMobject(SVGMobject, ABC): }, "base_color": WHITE, "isolate": (), + "protect": (), } def __init__(self, string: str, **kwargs): @@ -71,9 +71,7 @@ class StringMobject(SVGMobject, ABC): digest_config(self, kwargs) if self.base_color is None: self.base_color = WHITE - self.base_color_hex = self.color_to_hex(self.base_color) - self.full_span = (0, len(self.string)) self.parse() super().__init__(**kwargs) self.labels = [submob.label for submob in self.submobjects] @@ -90,9 +88,9 @@ class StringMobject(SVGMobject, ABC): super().generate_mobject() labels_count = len(self.labelled_spans) - if not labels_count: + if labels_count == 1: for submob in self.submobjects: - submob.label = -1 + submob.label = 0 return labelled_content = self.get_content(is_labelled=True) @@ -104,7 +102,7 @@ class StringMobject(SVGMobject, ABC): "to the original svg. Skip the labelling process." ) for submob in self.submobjects: - submob.label = -1 + submob.label = 0 return self.rearrange_submobjects_by_positions(labelled_svg) @@ -112,18 +110,21 @@ class StringMobject(SVGMobject, ABC): for submob, labelled_svg_submob in zip( self.submobjects, labelled_svg.submobjects ): - color_int = self.hex_to_int(self.color_to_hex( + label = self.hex_to_int(self.color_to_hex( labelled_svg_submob.get_fill_color() )) - if color_int > labels_count: - unrecognizable_colors.append(color_int) - color_int = 0 - submob.label = color_int - 1 + if label >= labels_count: + unrecognizable_colors.append(label) + label = 0 + submob.label = label if unrecognizable_colors: log.warning( - "Unrecognizable color labels detected (%s, etc). " + "Unrecognizable color labels detected (%s). " "The result could be unexpected.", - self.int_to_hex(unrecognizable_colors[0]) + ", ".join( + self.int_to_hex(color) + for color in unrecognizable_colors + ) ) def rearrange_submobjects_by_positions( @@ -153,30 +154,27 @@ class StringMobject(SVGMobject, ABC): # Toolkits - def get_substr(self, span: Span) -> str: - return self.string[slice(*span)] - - def find_spans(self, pattern: str | re.Pattern) -> list[Span]: - return [ - match_obj.span() - for match_obj in re.finditer(pattern, self.string) - ] - def find_spans_by_selector(self, selector: Selector) -> list[Span]: def find_spans_by_single_selector(sel): if isinstance(sel, str): - return self.find_spans(re.escape(sel)) + return [ + match_obj.span() + for match_obj in re.finditer(re.escape(sel), self.string) + ] if isinstance(sel, re.Pattern): - return self.find_spans(sel) + return [ + match_obj.span() + for match_obj in sel.finditer(self.string) + ] if isinstance(sel, tuple) and len(sel) == 2 and all( isinstance(index, int) or index is None for index in sel ): - l = self.full_span[1] + l = len(self.string) span = tuple( default_index if index is None else min(index, l) if index >= 0 else max(index + l, 0) - for index, default_index in zip(sel, self.full_span) + for index, default_index in zip(sel, (0, l)) ) return [span] return None @@ -189,57 +187,12 @@ class StringMobject(SVGMobject, ABC): if spans is None: raise TypeError(f"Invalid selector: '{sel}'") result.extend(spans) - return result - - @staticmethod - def get_neighbouring_pairs(vals: Sequence[T]) -> list[tuple[T, T]]: - return list(zip(vals[:-1], vals[1:])) - - @staticmethod - def compress_neighbours(vals: Sequence[T]) -> list[tuple[T, Span]]: - if not vals: - return [] - - unique_vals = [vals[0]] - indices = [0] - for index, val in enumerate(vals): - if val == unique_vals[-1]: - continue - unique_vals.append(val) - indices.append(index) - indices.append(len(vals)) - val_ranges = StringMobject.get_neighbouring_pairs(indices) - return list(zip(unique_vals, val_ranges)) + return list(filter(lambda span: span[0] <= span[1], result)) @staticmethod def span_contains(span_0: Span, span_1: Span) -> bool: return span_0[0] <= span_1[0] and span_0[1] >= span_1[1] - @staticmethod - def get_complement_spans( - universal_span: Span, interval_spans: list[Span] - ) -> list[Span]: - if not interval_spans: - return [universal_span] - - span_ends, span_begins = zip(*interval_spans) - return list(zip( - (universal_span[0], *span_begins), - (*span_ends, universal_span[1]) - )) - - def replace_substr(self, span: Span, repl_items: list[Span, str]): - if not repl_items: - return self.get_substr(span) - - repl_spans, repl_strs = zip(*sorted(repl_items, key=lambda t: t[0])) - pieces = [ - self.get_substr(piece_span) - for piece_span in self.get_complement_spans(span, repl_spans) - ] - repl_strs = [*repl_strs, ""] - return "".join(it.chain(*zip(pieces, repl_strs))) - @staticmethod def color_to_hex(color: ManimColor) -> str: return rgb_to_hex(color_to_rgb(color)) @@ -255,131 +208,220 @@ class StringMobject(SVGMobject, ABC): # Parsing def parse(self) -> None: - cmd_spans = self.get_cmd_spans() - cmd_substrs = [self.get_substr(span) for span in cmd_spans] - flags = [self.get_substr_flag(substr) for substr in cmd_substrs] - specified_items = self.get_specified_items( - self.get_cmd_span_pairs(cmd_spans, flags) - ) - split_items = [ - (span, attr_dict) - for specified_span, attr_dict in specified_items - for span in self.split_span_by_levels( - specified_span, cmd_spans, flags + def get_substr(span: Span) -> str: + return self.string[slice(*span)] + + configured_items = self.get_configured_items() + isolated_spans = self.find_spans_by_selector(self.isolate) + protected_spans = self.find_spans_by_selector(self.protect) + command_matches = self.get_command_matches(self.string) + + def get_key(category, i, flag): + def get_span_by_category(category, i): + if category == 0: + return configured_items[i][0] + if category == 1: + return isolated_spans[i] + if category == 2: + return protected_spans[i] + return command_matches[i].span() + + index, paired_index = get_span_by_category(category, i)[::flag] + return ( + index, + flag * (2 if index != paired_index else -1), + -paired_index, + flag * category, + flag * i ) - ] - self.specified_spans = [span for span, _ in specified_items] - self.split_items = split_items - self.labelled_spans = [span for span, _ in split_items] - self.cmd_repl_items_for_content = [ - (span, self.get_repl_substr_for_content(substr)) - for span, substr in zip(cmd_spans, cmd_substrs) - ] - self.cmd_repl_items_for_matching = [ - (span, self.get_repl_substr_for_matching(substr)) - for span, substr in zip(cmd_spans, cmd_substrs) - ] - self.check_overlapping() + index_items = sorted([ + (category, i, flag) + for category, item_length in enumerate(( + len(configured_items), + len(isolated_spans), + len(protected_spans), + len(command_matches) + )) + for i in range(item_length) + for flag in (1, -1) + ], key=lambda t: get_key(*t)) + inserted_items = [] + labelled_items = [] + overlapping_spans = [] + level_mismatched_spans = [] + + label = 1 + protect_level = 0 + bracket_stack = [0] + bracket_count = 0 + open_command_stack = [] + open_stack = [] + for category, i, flag in index_items: + if category >= 2: + protect_level += flag + if flag == 1 or category == 2: + continue + inserted_items.append((i, 0)) + command_match = command_matches[i] + command_flag = self.get_command_flag(command_match) + if command_flag == 1: + bracket_count += 1 + bracket_stack.append(bracket_count) + open_command_stack.append((len(inserted_items), i)) + continue + if command_flag == 0: + continue + pos, i_ = open_command_stack.pop() + bracket_stack.pop() + open_command_match = command_matches[i_] + attr_dict = self.get_attr_dict_from_command_pair( + open_command_match, command_match + ) + if attr_dict is None: + continue + span = (open_command_match.end(), command_match.start()) + labelled_items.append((span, attr_dict)) + inserted_items.insert(pos, (label, 1)) + inserted_items.insert(-1, (label, -1)) + label += 1 + continue + if flag == 1: + open_stack.append(( + len(inserted_items), category, i, + protect_level, bracket_stack.copy() + )) + continue + span, attr_dict = configured_items[i] \ + if category == 0 else (isolated_spans[i], {}) + pos, category_, i_, protect_level_, bracket_stack_ \ + = open_stack.pop() + if category_ != category or i_ != i: + overlapping_spans.append(span) + continue + if protect_level_ or protect_level: + continue + if bracket_stack_ != bracket_stack: + level_mismatched_spans.append(span) + continue + labelled_items.append((span, attr_dict)) + inserted_items.insert(pos, (label, 1)) + inserted_items.append((label, -1)) + label += 1 + labelled_items.insert(0, ((0, len(self.string)), {})) + inserted_items.insert(0, (0, 1)) + inserted_items.append((0, -1)) + + if overlapping_spans: + log.warning( + "Partly overlapping substrings detected: %s", + ", ".join( + f"'{get_substr(span)}'" + for span in overlapping_spans + ) + ) + if level_mismatched_spans: + log.warning( + "Cannot handle substrings: %s", + ", ".join( + f"'{get_substr(span)}'" + for span in level_mismatched_spans + ) + ) + + def reconstruct_string( + start_item: tuple[int, int], + end_item: tuple[int, int], + command_replace_func: Callable[[re.Match], str], + command_insert_func: Callable[[int, int, dict[str, str]], str] + ) -> str: + def get_edge_item(i: int, flag: int) -> tuple[Span, str]: + if flag == 0: + match_obj = command_matches[i] + return ( + match_obj.span(), + command_replace_func(match_obj) + ) + span, attr_dict = labelled_items[i] + index = span[flag < 0] + return ( + (index, index), + command_insert_func(i, flag, attr_dict) + ) + + items = [ + get_edge_item(i, flag) + for i, flag in inserted_items[slice( + inserted_items.index(start_item), + inserted_items.index(end_item) + 1 + )] + ] + pieces = [ + get_substr((start, end)) + for start, end in zip( + [interval_end for (_, interval_end), _ in items[:-1]], + [interval_start for (interval_start, _), _ in items[1:]] + ) + ] + interval_pieces = [piece for _, piece in items[1:-1]] + return "".join(it.chain(*zip(pieces, (*interval_pieces, "")))) + + self.labelled_spans = [span for span, _ in labelled_items] + self.reconstruct_string = reconstruct_string + + def get_content(self, is_labelled: bool) -> str: + content = self.reconstruct_string( + (0, 1), (0, -1), + self.replace_for_content, + lambda label, flag, attr_dict: self.get_command_string( + attr_dict, + is_end=flag < 0, + label_hex=self.int_to_hex(label) if is_labelled else None + ) + ) + prefix, suffix = self.get_content_prefix_and_suffix( + is_labelled=is_labelled + ) + return "".join((prefix, content, suffix)) + + @staticmethod @abstractmethod - def get_cmd_spans(self) -> list[Span]: + def get_command_matches(string: str) -> list[re.Match]: return [] + @staticmethod @abstractmethod - def get_substr_flag(self, substr: str) -> int: + def get_command_flag(match_obj: re.Match) -> int: return 0 + @staticmethod @abstractmethod - def get_repl_substr_for_content(self, substr: str) -> str: - return "" - - @abstractmethod - def get_repl_substr_for_matching(self, substr: str) -> str: + def replace_for_content(match_obj: re.Match) -> str: return "" @staticmethod - def get_cmd_span_pairs( - cmd_spans: list[Span], flags: list[int] - ) -> list[tuple[Span, Span]]: - result = [] - begin_cmd_spans_stack = [] - for cmd_span, flag in zip(cmd_spans, flags): - if flag == 1: - begin_cmd_spans_stack.append(cmd_span) - elif flag == -1: - if not begin_cmd_spans_stack: - raise ValueError("Missing open command") - begin_cmd_span = begin_cmd_spans_stack.pop() - result.append((begin_cmd_span, cmd_span)) - if begin_cmd_spans_stack: - raise ValueError("Missing close command") - return result + @abstractmethod + def replace_for_matching(match_obj: re.Match) -> str: + return "" + + @staticmethod + @abstractmethod + def get_attr_dict_from_command_pair( + open_command: re.Match, close_command: re.Match, + ) -> dict[str, str] | None: + return None @abstractmethod - def get_specified_items( - self, cmd_span_pairs: list[tuple[Span, Span]] - ) -> list[tuple[Span, dict[str, str]]]: + def get_configured_items(self) -> list[tuple[Span, dict[str, str]]]: return [] - def split_span_by_levels( - self, arbitrary_span: Span, cmd_spans: list[Span], flags: list[int] - ) -> list[Span]: - cmd_range = ( - sum([ - arbitrary_span[0] > interval_begin - for interval_begin, _ in cmd_spans - ]), - sum([ - arbitrary_span[1] >= interval_end - for _, interval_end in cmd_spans - ]) - ) - complement_spans = self.get_complement_spans( - self.full_span, cmd_spans - ) - adjusted_span = ( - max(arbitrary_span[0], complement_spans[cmd_range[0]][0]), - min(arbitrary_span[1], complement_spans[cmd_range[1]][1]) - ) - if adjusted_span[0] > adjusted_span[1]: - return [] - - upward_cmd_spans = [] - downward_cmd_spans = [] - for cmd_span, flag in list(zip(cmd_spans, flags))[slice(*cmd_range)]: - if flag == 1: - upward_cmd_spans.append(cmd_span) - elif flag == -1: - if upward_cmd_spans: - upward_cmd_spans.pop() - else: - downward_cmd_spans.append(cmd_span) - return list(filter( - lambda span: self.get_substr(span).strip(), - self.get_complement_spans( - adjusted_span, downward_cmd_spans + upward_cmd_spans - ) - )) - - def check_overlapping(self) -> None: - labelled_spans = self.labelled_spans - if len(labelled_spans) >= 16777216: - raise ValueError("Cannot handle that many substrings") - for span_0, span_1 in it.product(labelled_spans, repeat=2): - if not span_0[0] < span_1[0] < span_0[1] < span_1[1]: - continue - raise ValueError( - "Partially overlapping substrings detected: " - f"'{self.get_substr(span_0)}' and '{self.get_substr(span_1)}'" - ) - @staticmethod @abstractmethod - def get_cmd_str_pair( - attr_dict: dict[str, str], label_hex: str | None - ) -> tuple[str, str]: - return "", "" + def get_command_string( + attr_dict: dict[str, str], is_end: bool, label_hex: str | None + ) -> str: + return "" @abstractmethod def get_content_prefix_and_suffix( @@ -387,38 +429,6 @@ class StringMobject(SVGMobject, ABC): ) -> tuple[str, str]: return "", "" - def get_content(self, is_labelled: bool) -> str: - inserted_str_pairs = [ - (span, self.get_cmd_str_pair( - attr_dict, - label_hex=self.int_to_hex(label + 1) if is_labelled else None - )) - for label, (span, attr_dict) in enumerate(self.split_items) - ] - inserted_str_items = sorted([ - (index, s) - for (index, _), s in [ - *sorted([ - (span[::-1], end_str) - for span, (_, end_str) in reversed(inserted_str_pairs) - ], key=lambda t: (t[0][0], -t[0][1])), - *sorted([ - (span, begin_str) - for span, (begin_str, _) in inserted_str_pairs - ], key=lambda t: (t[0][0], -t[0][1])) - ] - ], key=lambda t: t[0]) - repl_items = self.cmd_repl_items_for_content + [ - ((index, index), inserted_str) - for index, inserted_str in inserted_str_items - ] - prefix, suffix = self.get_content_prefix_and_suffix(is_labelled) - return "".join([ - prefix, - self.replace_substr(self.full_span, repl_items), - suffix - ]) - # Selector def get_submob_indices_list_by_span( @@ -427,59 +437,69 @@ class StringMobject(SVGMobject, ABC): return [ submob_index for submob_index, label in enumerate(self.labels) - if label != -1 and self.span_contains( - arbitrary_span, self.labelled_spans[label] - ) + if self.span_contains(arbitrary_span, self.labelled_spans[label]) ] def get_specified_part_items(self) -> list[tuple[str, list[int]]]: return [ ( - self.get_substr(span), + self.string[slice(*span)], self.get_submob_indices_list_by_span(span) ) - for span in self.specified_spans + for span in self.labelled_spans[1:] ] def get_group_part_items(self) -> list[tuple[str, list[int]]]: if not self.labels: return [] - group_labels, labelled_submob_ranges = zip( - *self.compress_neighbours(self.labels) - ) - ordered_spans = [ - self.labelled_spans[label] if label != -1 else self.full_span - for label in group_labels - ] - interval_spans = [ - ( - next_span[0] - if self.span_contains(prev_span, next_span) - else prev_span[1], - prev_span[1] - if self.span_contains(next_span, prev_span) - else next_span[0] - ) - for prev_span, next_span in self.get_neighbouring_pairs( - ordered_spans - ) - ] - group_substrs = [ - re.sub(r"\s+", "", self.replace_substr( - span, [ - (cmd_span, repl_str) - for cmd_span, repl_str in self.cmd_repl_items_for_matching - if self.span_contains(span, cmd_span) - ] - )) - for span in self.get_complement_spans( - (ordered_spans[0][0], ordered_spans[-1][1]), interval_spans - ) - ] + def get_neighbouring_pairs(vals): + return list(zip(vals[:-1], vals[1:])) + + range_lens, group_labels = zip(*( + (len(list(grouper)), val) + for val, grouper in it.groupby(self.labels) + )) submob_indices_lists = [ list(range(*submob_range)) - for submob_range in labelled_submob_ranges + for submob_range in get_neighbouring_pairs( + [0, *it.accumulate(range_lens)] + ) + ] + labelled_spans = self.labelled_spans + start_items = [ + (group_labels[0], 1), + *( + (curr_label, 1) + if self.span_contains( + labelled_spans[prev_label], labelled_spans[curr_label] + ) + else (prev_label, -1) + for prev_label, curr_label in get_neighbouring_pairs( + group_labels + ) + ) + ] + end_items = [ + *( + (curr_label, -1) + if self.span_contains( + labelled_spans[next_label], labelled_spans[curr_label] + ) + else (next_label, 1) + for curr_label, next_label in get_neighbouring_pairs( + group_labels + ) + ), + (group_labels[-1], -1) + ] + group_substrs = [ + re.sub(r"\s+", "", self.reconstruct_string( + start_item, end_item, + self.replace_for_matching, + lambda label, flag, attr_dict: "" + )) + for start_item, end_item in zip(start_items, end_items) ] return list(zip(group_substrs, submob_indices_lists)) @@ -497,13 +517,13 @@ class StringMobject(SVGMobject, ABC): def build_parts_from_indices_lists( self, indices_lists: list[list[int]] ) -> VGroup: - return VGroup(*[ - VGroup(*[ + return VGroup(*( + VGroup(*( self.submobjects[submob_index] for submob_index in indices_list - ]) + )) for indices_list in indices_lists - ]) + )) def build_groups(self) -> VGroup: return self.build_parts_from_indices_lists([ diff --git a/manimlib/mobject/svg/svg_mobject.py b/manimlib/mobject/svg/svg_mobject.py index 5a918e66..bccb1705 100644 --- a/manimlib/mobject/svg/svg_mobject.py +++ b/manimlib/mobject/svg/svg_mobject.py @@ -1,6 +1,5 @@ from __future__ import annotations -import hashlib import os from xml.etree import ElementTree as ET @@ -19,6 +18,7 @@ from manimlib.mobject.types.vectorized_mobject import VMobject from manimlib.utils.directories import get_mobject_data_dir from manimlib.utils.images import get_full_vector_image_path from manimlib.utils.iterables import hash_obj +from manimlib.utils.simple_functions import hash_string SVG_HASH_TO_MOB_MAP: dict[int, VMobject] = {} @@ -106,7 +106,7 @@ class SVGMobject(VMobject): return get_full_vector_image_path(self.file_name) def modify_xml_tree(self, element_tree: ET.ElementTree) -> ET.ElementTree: - config_style_dict = self.generate_config_style_dict() + config_style_attrs = self.generate_config_style_dict() style_keys = ( "fill", "fill-opacity", @@ -116,14 +116,17 @@ class SVGMobject(VMobject): "style" ) root = element_tree.getroot() - root_style_dict = { - k: v for k, v in root.attrib.items() + style_attrs = { + k: v + for k, v in root.attrib.items() if k in style_keys } - new_root = ET.Element("svg", {}) - config_style_node = ET.SubElement(new_root, "g", config_style_dict) - root_style_node = ET.SubElement(config_style_node, "g", root_style_dict) + # Ignore other attributes in case that svgelements cannot parse them + SVG_XMLNS = "{http://www.w3.org/2000/svg}" + new_root = ET.Element("svg") + config_style_node = ET.SubElement(new_root, f"{SVG_XMLNS}g", config_style_attrs) + root_style_node = ET.SubElement(config_style_node, f"{SVG_XMLNS}g", style_attrs) root_style_node.extend(root) return ET.ElementTree(new_root) @@ -147,7 +150,7 @@ class SVGMobject(VMobject): def get_mobjects_from(self, svg: se.SVG) -> list[VMobject]: result = [] for shape in svg.elements(): - if isinstance(shape, se.Group): + if isinstance(shape, (se.Group, se.Use)): continue elif isinstance(shape, se.Path): mob = self.path_to_mobject(shape) @@ -155,9 +158,7 @@ class SVGMobject(VMobject): mob = self.line_to_mobject(shape) elif isinstance(shape, se.Rect): mob = self.rect_to_mobject(shape) - elif isinstance(shape, se.Circle): - mob = self.circle_to_mobject(shape) - elif isinstance(shape, se.Ellipse): + elif isinstance(shape, (se.Circle, se.Ellipse)): mob = self.ellipse_to_mobject(shape) elif isinstance(shape, se.Polygon): mob = self.polygon_to_mobject(shape) @@ -168,11 +169,12 @@ class SVGMobject(VMobject): elif type(shape) == se.SVGElement: continue else: - log.warning(f"Unsupported element type: {type(shape)}") + log.warning("Unsupported element type: %s", type(shape)) continue if not mob.has_points(): continue - self.apply_style_to_mobject(mob, shape) + if isinstance(shape, se.GraphicObject): + self.apply_style_to_mobject(mob, shape) if isinstance(shape, se.Transformable) and shape.apply: self.handle_transform(mob, shape.transform) result.append(mob) @@ -203,21 +205,10 @@ class SVGMobject(VMobject): ) return mob - @staticmethod - def handle_transform(mob, matrix): - mat = np.array([ - [matrix.a, matrix.c], - [matrix.b, matrix.d] - ]) - vec = np.array([matrix.e, matrix.f, 0.0]) - mob.apply_matrix(mat) - mob.shift(vec) - return mob - def path_to_mobject(self, path: se.Path) -> VMobjectFromSVGPath: return VMobjectFromSVGPath(path, **self.path_string_config) - def line_to_mobject(self, line: se.Line) -> Line: + def line_to_mobject(self, line: se.SimpleLine) -> Line: return Line( start=_convert_point_to_3d(line.x1, line.y1), end=_convert_point_to_3d(line.x2, line.y2) @@ -242,15 +233,7 @@ class SVGMobject(VMobject): )) return mob - def circle_to_mobject(self, circle: se.Circle) -> Circle: - # svgelements supports `rx` & `ry` but `r` - mob = Circle(radius=circle.rx) - mob.shift(_convert_point_to_3d( - circle.cx, circle.cy - )) - return mob - - def ellipse_to_mobject(self, ellipse: se.Ellipse) -> Circle: + def ellipse_to_mobject(self, ellipse: se.Circle | se.Ellipse) -> Circle: mob = Circle(radius=ellipse.rx) mob.stretch_to_fit_height(2 * ellipse.ry) mob.shift(_convert_point_to_3d( @@ -302,8 +285,7 @@ class VMobjectFromSVGPath(VMobject): # will be saved to a file so that future calls for the same path # don't need to retrace the same computation. path_string = self.path_obj.d() - hasher = hashlib.sha256(path_string.encode()) - path_hash = hasher.hexdigest()[:16] + path_hash = hash_string(path_string) points_filepath = os.path.join(get_mobject_data_dir(), f"{path_hash}_points.npy") tris_filepath = os.path.join(get_mobject_data_dir(), f"{path_hash}_tris.npy") diff --git a/manimlib/mobject/svg/tex_mobject.py b/manimlib/mobject/svg/tex_mobject.py index 52fabe81..3c5e2ea1 100644 --- a/manimlib/mobject/svg/tex_mobject.py +++ b/manimlib/mobject/svg/tex_mobject.py @@ -13,8 +13,7 @@ from manimlib.mobject.svg.svg_mobject import SVGMobject from manimlib.mobject.types.vectorized_mobject import VGroup from manimlib.utils.config_ops import digest_config from manimlib.utils.tex_file_writing import display_during_execution -from manimlib.utils.tex_file_writing import get_tex_config -from manimlib.utils.tex_file_writing import tex_to_svg_file +from manimlib.utils.tex_file_writing import tex_content_to_svg_file from typing import TYPE_CHECKING @@ -44,6 +43,8 @@ class SingleStringTex(SVGMobject): "alignment": "\\centering", "math_mode": True, "organize_left_to_right": False, + "template": "", + "additional_preamble": "", } def __init__(self, tex_string: str, **kwargs): @@ -64,27 +65,24 @@ class SingleStringTex(SVGMobject): self.path_string_config, self.tex_string, self.alignment, - self.math_mode + self.math_mode, + self.template, + self.additional_preamble ) def get_file_path(self) -> str: - full_tex = self.get_tex_file_body(self.tex_string) + content = self.get_tex_file_body(self.tex_string) with display_during_execution(f"Writing \"{self.tex_string}\""): - file_path = tex_to_svg_file(full_tex) + file_path = tex_content_to_svg_file( + content, self.template, self.additional_preamble + ) return file_path def get_tex_file_body(self, tex_string: str) -> str: new_tex = self.get_modified_expression(tex_string) if self.math_mode: new_tex = "\\begin{align*}\n" + new_tex + "\n\\end{align*}" - - new_tex = self.alignment + "\n" + new_tex - - tex_config = get_tex_config() - return tex_config["tex_body"].replace( - tex_config["text_to_replace"], - new_tex - ) + return self.alignment + "\n" + new_tex def get_modified_expression(self, tex_string: str) -> str: return self.modify_special_strings(tex_string.strip()) diff --git a/manimlib/mobject/svg/text_mobject.py b/manimlib/mobject/svg/text_mobject.py index b5613614..027928eb 100644 --- a/manimlib/mobject/svg/text_mobject.py +++ b/manimlib/mobject/svg/text_mobject.py @@ -18,7 +18,7 @@ from manimlib.utils.config_ops import digest_config from manimlib.utils.customization import get_customization from manimlib.utils.directories import get_downloads_dir from manimlib.utils.directories import get_text_dir -from manimlib.utils.tex_file_writing import tex_hash +from manimlib.utils.simple_functions import hash_string from typing import TYPE_CHECKING @@ -63,7 +63,6 @@ class _Alignment: class MarkupText(StringMobject): CONFIG = { - "is_markup": True, "font_size": 48, "lsh": None, "justify": False, @@ -81,21 +80,11 @@ class MarkupText(StringMobject): "t2w": {}, "global_config": {}, "local_configs": {}, - # For backward compatibility - "isolate": (re.compile(r"[a-zA-Z]+"), re.compile(r"\S+")), + "disable_ligatures": True, + "isolate": re.compile(r"\w+", re.U), } # See https://docs.gtk.org/Pango/pango_markup.html - MARKUP_COLOR_KEYS = { - "foreground": False, - "fgcolor": False, - "color": False, - "background": True, - "bgcolor": True, - "underline_color": True, - "overline_color": True, - "strikethrough_color": True, - } MARKUP_TAGS = { "b": {"font_weight": "bold"}, "big": {"font_size": "larger"}, @@ -107,17 +96,24 @@ class MarkupText(StringMobject): "tt": {"font_family": "monospace"}, "u": {"underline": "single"}, } + MARKUP_ENTITY_DICT = { + "<": "<", + ">": ">", + "&": "&", + "\"": """, + "'": "'" + } def __init__(self, text: str, **kwargs): self.full2short(kwargs) digest_config(self, kwargs) + if not isinstance(self, Text): + self.validate_markup_string(text) if not self.font: self.font = get_customization()["style"]["font"] if not self.alignment: self.alignment = get_customization()["style"]["text_alignment"] - if self.is_markup: - self.validate_markup_string(text) self.text = text super().__init__(text, **kwargs) @@ -140,8 +136,8 @@ class MarkupText(StringMobject): self.path_string_config, self.base_color, self.isolate, + self.protect, self.text, - self.is_markup, self.font_size, self.lsh, self.justify, @@ -156,7 +152,8 @@ class MarkupText(StringMobject): self.t2s, self.t2w, self.global_config, - self.local_configs + self.local_configs, + self.disable_ligatures ) def full2short(self, config: dict) -> None: @@ -182,7 +179,7 @@ class MarkupText(StringMobject): self.line_width )) svg_file = os.path.join( - get_text_dir(), tex_hash(hash_content) + ".svg" + get_text_dir(), hash_string(hash_content) + ".svg" ) if not os.path.exists(svg_file): self.markup_to_svg(content, svg_file) @@ -229,76 +226,92 @@ class MarkupText(StringMobject): f"{validate_error}" ) + # Toolkits + + @staticmethod + def escape_markup_char(substr: str) -> str: + return MarkupText.MARKUP_ENTITY_DICT.get(substr, substr) + + @staticmethod + def unescape_markup_char(substr: str) -> str: + return { + v: k + for k, v in MarkupText.MARKUP_ENTITY_DICT.items() + }.get(substr, substr) + # Parsing - def get_cmd_spans(self) -> list[Span]: - if not self.is_markup: - return self.find_spans(r"""[<>&"']""") + @staticmethod + def get_command_matches(string: str) -> list[re.Match]: + pattern = re.compile(r""" + (?P + < + (?P/)? + (?P\w+)\s* + (?P(?:\w+\s*\=\s*(?P["']).*?(?P=quot)\s*)*) + (?P/)? + > + ) + |(?P + <\?.*?\?>||| + ) + |(?P&(?P\#(?Px)?)?(?P.*?);) + |(?P[>"']) + """, flags=re.X | re.S) + return list(pattern.finditer(string)) - # Unsupported passthroughs: - # "", "", "", "" - # See https://gitlab.gnome.org/GNOME/glib/-/blob/main/glib/gmarkup.c - return self.find_spans( - r"""&[\s\S]*?;|[>"']|""" - ) - - def get_substr_flag(self, substr: str) -> int: - if re.fullmatch(r"<\w[\s\S]*[^/]>", substr): - return 1 - if substr.startswith(" int: + if match_obj.group("tag"): + if match_obj.group("close_slash"): + return -1 + if not match_obj.group("elision_slash"): + return 1 return 0 - def get_repl_substr_for_content(self, substr: str) -> str: - if substr.startswith("<") and substr.endswith(">"): + @staticmethod + def replace_for_content(match_obj: re.Match) -> str: + if match_obj.group("tag"): return "" - return { - "<": "<", - ">": ">", - "&": "&", - "\"": """, - "'": "'" - }.get(substr, substr) + if match_obj.group("char"): + return MarkupText.escape_markup_char(match_obj.group("char")) + return match_obj.group() - def get_repl_substr_for_matching(self, substr: str) -> str: - if substr.startswith("<") and substr.endswith(">"): + @staticmethod + def replace_for_matching(match_obj: re.Match) -> str: + if match_obj.group("tag") or match_obj.group("passthrough"): return "" - if substr.startswith("&#") and substr.endswith(";"): - if substr.startswith("&#x"): - char_reference = int(substr[3:-1], 16) - else: - char_reference = int(substr[2:-1], 10) - return chr(char_reference) - return { - "<": "<", - ">": ">", - "&": "&", - """: "\"", - "'": "'" - }.get(substr, substr) + if match_obj.group("entity"): + if match_obj.group("unicode"): + base = 10 + if match_obj.group("hex"): + base = 16 + return chr(int(match_obj.group("content"), base)) + return MarkupText.unescape_markup_char(match_obj.group("entity")) + return match_obj.group() - def get_specified_items( - self, cmd_span_pairs: list[tuple[Span, Span]] - ) -> list[tuple[Span, dict[str, str]]]: - attr_pattern = r"""(\w+)\s*\=\s*(["'])([\s\S]*?)\2""" - internal_items = [] - for begin_cmd_span, end_cmd_span in cmd_span_pairs: - begin_tag = self.get_substr(begin_cmd_span) - tag_name = re.match(r"<(\w+)", begin_tag).group(1) - if tag_name == "span": - attr_dict = { - attr_match_obj.group(1): attr_match_obj.group(3) - for attr_match_obj in re.finditer(attr_pattern, begin_tag) - } - else: - attr_dict = MarkupText.MARKUP_TAGS.get(tag_name, {}) - internal_items.append( - ((begin_cmd_span[1], end_cmd_span[0]), attr_dict) - ) + @staticmethod + def get_attr_dict_from_command_pair( + open_command: re.Match, close_command: re.Match + ) -> dict[str, str] | None: + pattern = r""" + (?P\w+) + \s*\=\s* + (?P["'])(?P.*?)(?P=quot) + """ + tag_name = open_command.group("tag_name") + if tag_name == "span": + return { + match_obj.group("attr_name"): match_obj.group("attr_val") + for match_obj in re.finditer( + pattern, open_command.group("attr_list"), re.S | re.X + ) + } + return MarkupText.MARKUP_TAGS.get(tag_name, {}) + def get_configured_items(self) -> list[tuple[Span, dict[str, str]]]: return [ - *internal_items, - *[ + *( (span, {key: val}) for t2x_dict, key in ( (self.t2c, "foreground"), @@ -308,49 +321,49 @@ class MarkupText(StringMobject): ) for selector, val in t2x_dict.items() for span in self.find_spans_by_selector(selector) - ], - *[ + ), + *( (span, local_config) for selector, local_config in self.local_configs.items() for span in self.find_spans_by_selector(selector) - ], - *[ - (span, {}) - for span in self.find_spans_by_selector(self.isolate) - ] + ) ] @staticmethod - def get_cmd_str_pair( - attr_dict: dict[str, str], label_hex: str | None - ) -> tuple[str, str]: + def get_command_string( + attr_dict: dict[str, str], is_end: bool, label_hex: str | None + ) -> str: + if is_end: + return "" + if label_hex is not None: converted_attr_dict = {"foreground": label_hex} for key, val in attr_dict.items(): - substitute_key = MarkupText.MARKUP_COLOR_KEYS.get(key, None) - if substitute_key is None: - converted_attr_dict[key] = val - elif substitute_key: + if key in ( + "background", "bgcolor", + "underline_color", "overline_color", "strikethrough_color" + ): converted_attr_dict[key] = "black" + elif key not in ("foreground", "fgcolor", "color"): + converted_attr_dict[key] = val else: converted_attr_dict = attr_dict.copy() attrs_str = " ".join([ f"{key}='{val}'" for key, val in converted_attr_dict.items() ]) - return f"", "" + return f"" def get_content_prefix_and_suffix( self, is_labelled: bool ) -> tuple[str, str]: global_attr_dict = { - "foreground": self.base_color_hex, + "foreground": self.color_to_hex(self.base_color), "font_family": self.font, "font_style": self.slant, "font_weight": self.weight, - "font_size": str(self.font_size * 1024), + "font_size": str(round(self.font_size * 1024)), } - global_attr_dict.update(self.global_config) # `line_height` attribute is supported since Pango 1.50. pango_version = manimpango.pango_version() if tuple(map(int, pango_version.split("."))) < (1, 50): @@ -365,10 +378,17 @@ class MarkupText(StringMobject): global_attr_dict["line_height"] = str( ((line_spacing_scale) + 1) * 0.6 ) + if self.disable_ligatures: + global_attr_dict["font_features"] = "liga=0,dlig=0,clig=0,hlig=0" - return self.get_cmd_str_pair( - global_attr_dict, - label_hex=self.int_to_hex(0) if is_labelled else None + global_attr_dict.update(self.global_config) + return tuple( + self.get_command_string( + global_attr_dict, + is_end=is_end, + label_hex=self.int_to_hex(0) if is_labelled else None + ) + for is_end in (False, True) ) # Method alias @@ -376,8 +396,8 @@ class MarkupText(StringMobject): def get_parts_by_text(self, selector: Selector) -> VGroup: return self.select_parts(selector) - def get_part_by_text(self, selector: Selector) -> VGroup: - return self.select_part(selector) + def get_part_by_text(self, selector: Selector, **kwargs) -> VGroup: + return self.select_part(selector, **kwargs) def set_color_by_text(self, selector: Selector, color: ManimColor): return self.set_parts_color(selector, color) @@ -393,9 +413,27 @@ class MarkupText(StringMobject): class Text(MarkupText): CONFIG = { - "is_markup": False, + # For backward compatibility + "isolate": (re.compile(r"\w+", re.U), re.compile(r"\S+", re.U)), } + @staticmethod + def get_command_matches(string: str) -> list[re.Match]: + pattern = re.compile(r"""[<>&"']""") + return list(pattern.finditer(string)) + + @staticmethod + def get_command_flag(match_obj: re.Match) -> int: + return 0 + + @staticmethod + def replace_for_content(match_obj: re.Match) -> str: + return Text.escape_markup_char(match_obj.group()) + + @staticmethod + def replace_for_matching(match_obj: re.Match) -> str: + return match_obj.group() + class Code(MarkupText): CONFIG = { diff --git a/manimlib/tex_templates.yml b/manimlib/tex_templates.yml new file mode 100644 index 00000000..ddf16047 --- /dev/null +++ b/manimlib/tex_templates.yml @@ -0,0 +1,732 @@ +# Classical TeX templates + +default: + description: "" + compiler: latex + preamble: |- + \usepackage[english]{babel} + \usepackage[utf8]{inputenc} + \usepackage[T1]{fontenc} + \usepackage{amsmath} + \usepackage{amssymb} + \usepackage{dsfont} + \usepackage{setspace} + \usepackage{tipa} + \usepackage{relsize} + \usepackage{textcomp} + \usepackage{mathrsfs} + \usepackage{calligra} + \usepackage{wasysym} + \usepackage{ragged2e} + \usepackage{physics} + \usepackage{xcolor} + \usepackage{microtype} + \usepackage{pifont} + \DisableLigatures{encoding = *, family = * } + \linespread{1} + +ctex: + description: "" + compiler: xelatex + preamble: |- + \usepackage[UTF8]{ctex} + \usepackage[english]{babel} + \usepackage{amsmath} + \usepackage{amssymb} + \usepackage{dsfont} + \usepackage{setspace} + \usepackage{tipa} + \usepackage{relsize} + \usepackage{textcomp} + \usepackage{mathrsfs} + \usepackage{calligra} + \usepackage{wasysym} + \usepackage{ragged2e} + \usepackage{physics} + \usepackage{xcolor} + \usepackage{microtype} + \linespread{1} + +# Simplified TeX templates + +basic: + description: "" + compiler: latex + preamble: |- + \usepackage[english]{babel} + \usepackage{amsmath} + \usepackage{amssymb} + \usepackage{xcolor} + +basic_ctex: + description: "" + compiler: xelatex + preamble: |- + \usepackage[UTF8]{ctex} + \usepackage{amsmath} + \usepackage{amssymb} + \usepackage{xcolor} + +empty: + description: "" + compiler: latex + preamble: "" + +empty_ctex: + description: "" + compiler: xelatex + preamble: "" + +# A collection of TeX templates for the fonts described at +# http://jf.burnol.free.fr/showcase.html + +american_typewriter: + description: American Typewriter + compiler: xelatex + preamble: |- + \usepackage{amsmath} + \usepackage{amssymb} + \usepackage{xcolor} + \usepackage[no-math]{fontspec} + \setmainfont[Mapping=tex-text]{American Typewriter} + \usepackage[defaultmathsizes]{mathastext} + +antykwa: + description: Antykwa Poltawskiego (TX Fonts for Greek and math symbols) + compiler: latex + preamble: |- + \usepackage{amsmath} + \usepackage{amssymb} + \usepackage{xcolor} + \usepackage[OT4,OT1]{fontenc} + \usepackage{txfonts} + \usepackage[upright]{txgreeks} + \usepackage{antpolt} + \usepackage[defaultmathsizes,nolessnomore]{mathastext} + +apple_chancery: + description: Apple Chancery + compiler: xelatex + preamble: |- + \usepackage{amsmath} + \usepackage{amssymb} + \usepackage{xcolor} + \usepackage[no-math]{fontspec} + \setmainfont[Mapping=tex-text]{Apple Chancery} + \usepackage[defaultmathsizes]{mathastext} + +auriocus_kalligraphicus: + description: Auriocus Kalligraphicus (Symbol Greek) + compiler: latex + preamble: |- + \usepackage{amsmath} + \usepackage{amssymb} + \usepackage{xcolor} + \usepackage[T1]{fontenc} + \usepackage{aurical} + \renewcommand{\rmdefault}{AuriocusKalligraphicus} + \usepackage[symbolgreek]{mathastext} + +baskervald_adf_fourier: + description: Baskervald ADF with Fourier + compiler: latex + preamble: |- + \usepackage{amsmath} + \usepackage{amssymb} + \usepackage{xcolor} + \usepackage[upright]{fourier} + \usepackage{baskervald} + \usepackage[defaultmathsizes,noasterisk]{mathastext} + +baskerville_it: + description: Baskerville (Italic) + compiler: xelatex + preamble: |- + \usepackage{amsmath} + \usepackage{amssymb} + \usepackage{xcolor} + \usepackage[no-math]{fontspec} + \setmainfont[Mapping=tex-text]{Baskerville} + \usepackage[defaultmathsizes,italic]{mathastext} + +biolinum: + description: Biolinum + compiler: latex + preamble: |- + \usepackage{amsmath} + \usepackage{amssymb} + \usepackage{xcolor} + \usepackage{txfonts} + \usepackage[upright]{txgreeks} + \usepackage[no-math]{fontspec} + \setmainfont[Mapping=tex-text]{Minion Pro} + \setsansfont[Mapping=tex-text,Scale=MatchUppercase]{Myriad Pro} + \renewcommand\familydefault\sfdefault + \usepackage[defaultmathsizes]{mathastext} + \renewcommand\familydefault\rmdefault + +brushscriptx: + description: BrushScriptX-Italic (PX math and Greek) + compiler: xelatex + preamble: |- + \usepackage{amsmath} + \usepackage{amssymb} + \usepackage{xcolor} + \usepackage[T1]{fontenc} + \usepackage{pxfonts} + \renewcommand{\rmdefault}{pbsi} + \renewcommand{\mddefault}{xl} + \renewcommand{\bfdefault}{xl} + \usepackage[defaultmathsizes,noasterisk]{mathastext} + \boldmath + +chalkboard_se: + description: Chalkboard SE + compiler: xelatex + preamble: |- + \usepackage{amsmath} + \usepackage{amssymb} + \usepackage{xcolor} + \usepackage[no-math]{fontspec} + \setmainfont[Mapping=tex-text]{Chalkboard SE} + \usepackage[defaultmathsizes]{mathastext} + +chalkduster: + description: Chalkduster + compiler: lualatex + preamble: |- + \usepackage{amsmath} + \usepackage{amssymb} + \usepackage{xcolor} + \usepackage[no-math]{fontspec} + \setmainfont[Mapping=tex-text]{Chalkduster} + \usepackage[defaultmathsizes]{mathastext} + +comfortaa: + description: Comfortaa + compiler: latex + preamble: |- + \usepackage{amsmath} + \usepackage{amssymb} + \usepackage{xcolor} + \usepackage[default]{comfortaa} + \usepackage[LGRgreek,defaultmathsizes,noasterisk]{mathastext} + \let\varphi\phi + \linespread{1.06} + +comic_sans: + description: Comic Sans MS + compiler: xelatex + preamble: |- + \usepackage{amsmath} + \usepackage{amssymb} + \usepackage{xcolor} + \usepackage[no-math]{fontspec} + \setmainfont[Mapping=tex-text]{Comic Sans MS} + \usepackage[defaultmathsizes]{mathastext} + +droid_sans: + description: Droid Sans + compiler: latex + preamble: |- + \usepackage{amsmath} + \usepackage{amssymb} + \usepackage{xcolor} + \usepackage[T1]{fontenc} + \usepackage[default]{droidsans} + \usepackage[LGRgreek]{mathastext} + \let\varepsilon\epsilon + +droid_sans_it: + description: Droid Sans (Italic) + compiler: latex + preamble: |- + \usepackage{amsmath} + \usepackage{amssymb} + \usepackage{xcolor} + \usepackage[T1]{fontenc} + \usepackage[default]{droidsans} + \usepackage[LGRgreek,defaultmathsizes,italic]{mathastext} + \let\varphi\phi + +droid_serif: + description: Droid Serif + compiler: latex + preamble: |- + \usepackage{amsmath} + \usepackage{amssymb} + \usepackage{xcolor} + \usepackage[T1]{fontenc} + \usepackage[default]{droidserif} + \usepackage[LGRgreek]{mathastext} + \let\varepsilon\epsilon + +droid_serif_px_it: + description: Droid Serif (PX math symbols) (Italic) + compiler: latex + preamble: |- + \usepackage{amsmath} + \usepackage{amssymb} + \usepackage{xcolor} + \usepackage[T1]{fontenc} + \usepackage{pxfonts} + \usepackage[default]{droidserif} + \usepackage[LGRgreek,defaultmathsizes,italic,basic]{mathastext} + \let\varphi\phi + +ecf_augie: + description: ECF Augie (Euler Greek) + compiler: latex + preamble: |- + \usepackage{amsmath} + \usepackage{amssymb} + \usepackage{xcolor} + \renewcommand\familydefault{fau} + \usepackage[defaultmathsizes,eulergreek]{mathastext} + +ecf_jd: + description: ECF JD (with TX fonts) + compiler: latex + preamble: |- + \usepackage{amsmath} + \usepackage{amssymb} + \usepackage{xcolor} + \usepackage{txfonts} + \usepackage[upright]{txgreeks} + \renewcommand\familydefault{fjd} + \usepackage{mathastext} + \mathversion{bold} + +ecf_skeetch: + description: ECF Skeetch (CM Greek) + compiler: latex + preamble: |- + \usepackage{amsmath} + \usepackage{amssymb} + \usepackage{xcolor} + \usepackage[T1]{fontenc} + \DeclareFontFamily{T1}{fsk}{} + \DeclareFontShape{T1}{fsk}{m}{n}{<->s*[1.315] fskmw8t}{} + \renewcommand\rmdefault{fsk} + \usepackage[noendash,defaultmathsizes,nohbar,defaultimath]{mathastext} + +ecf_tall_paul: + description: ECF Tall Paul (with Symbol font) + compiler: latex + preamble: |- + \usepackage{amsmath} + \usepackage{amssymb} + \usepackage{xcolor} + \DeclareFontFamily{T1}{ftp}{} + \DeclareFontShape{T1}{ftp}{m}{n}{<->s*[1.4] ftpmw8t}{} + \renewcommand\familydefault{ftp} + \usepackage[symbol]{mathastext} + \let\infty\inftypsy + +ecf_webster: + description: ECF Webster (with TX fonts) + compiler: xelatex + preamble: |- + \usepackage{amsmath} + \usepackage{amssymb} + \usepackage{xcolor} + \usepackage{txfonts} + \usepackage[upright]{txgreeks} + \renewcommand\familydefault{fwb} + \usepackage{mathastext} + \renewcommand{\int}{\intop\limits} + \linespread{1.5} + \mathversion{bold} + +electrum_adf: + description: Electrum ADF (CM Greek) + compiler: latex + preamble: |- + \usepackage{amsmath} + \usepackage{amssymb} + \usepackage{xcolor} + \usepackage[T1]{fontenc} + \usepackage[LGRgreek,basic,defaultmathsizes]{mathastext} + \usepackage[lf]{electrum} + \Mathastext + \let\varphi\phi + +epigrafica: + description: Epigrafica + compiler: latex + preamble: |- + \usepackage{amsmath} + \usepackage{amssymb} + \usepackage{xcolor} + \usepackage[LGR,OT1]{fontenc} + \usepackage{epigrafica} + \usepackage[basic,LGRgreek,defaultmathsizes]{mathastext} + \let\varphi\phi + \linespread{1.2} + +fourier_utopia: + description: Fourier Utopia (Fourier upright Greek) + compiler: latex + preamble: |- + \usepackage{amsmath} + \usepackage{amssymb} + \usepackage{xcolor} + \usepackage[T1]{fontenc} + \usepackage[upright]{fourier} + \usepackage{mathastext} + +french_cursive: + description: French Cursive (Euler Greek) + compiler: latex + preamble: |- + \usepackage{amsmath} + \usepackage{amssymb} + \usepackage{xcolor} + \usepackage[T1]{fontenc} + \usepackage[default]{frcursive} + \usepackage[eulergreek,noplusnominus,noequal,nohbar,nolessnomore,noasterisk]{mathastext} + +gfs_bodoni: + description: GFS Bodoni + compiler: latex + preamble: |- + \usepackage{amsmath} + \usepackage{amssymb} + \usepackage{xcolor} + \usepackage[T1]{fontenc} + \renewcommand{\rmdefault}{bodoni} + \usepackage[LGRgreek]{mathastext} + \let\varphi\phi + \linespread{1.06} + +gfs_didot: + description: GFS Didot (Italic) + compiler: latex + preamble: |- + \usepackage{amsmath} + \usepackage{amssymb} + \usepackage{xcolor} + \usepackage[T1]{fontenc} + \renewcommand\rmdefault{udidot} + \usepackage[LGRgreek,defaultmathsizes,italic]{mathastext} + \let\varphi\phi + +gfs_neohellenic: + description: GFS NeoHellenic + compiler: latex + preamble: |- + \usepackage{amsmath} + \usepackage{amssymb} + \usepackage{xcolor} + \usepackage[T1]{fontenc} + \renewcommand{\rmdefault}{neohellenic} + \usepackage[LGRgreek]{mathastext} + \let\varphi\phi + \linespread{1.06} + +gnu_freesans_tx: + description: GNU FreeSerif (and TX fonts symbols) + compiler: xelatex + preamble: |- + \usepackage{amsmath} + \usepackage{amssymb} + \usepackage{xcolor} + \usepackage[no-math]{fontspec} + \usepackage{txfonts} + \setmainfont[ExternalLocation,Mapping=tex-text,BoldFont=FreeSerifBold,ItalicFont=FreeSerifItalic,BoldItalicFont=FreeSerifBoldItalic]{FreeSerif} + \usepackage[defaultmathsizes]{mathastext} + +gnu_freeserif_freesans: + description: GNU FreeSerif and FreeSans + compiler: xelatex + preamble: |- + \usepackage{amsmath} + \usepackage{amssymb} + \usepackage{xcolor} + \usepackage[no-math]{fontspec} + \setmainfont[ExternalLocation,Mapping=tex-text,BoldFont=FreeSerifBold,ItalicFont=FreeSerifItalic,BoldItalicFont=FreeSerifBoldItalic]{FreeSerif} + \setsansfont[ExternalLocation,Mapping=tex-text,BoldFont=FreeSansBold,ItalicFont=FreeSansOblique,BoldItalicFont=FreeSansBoldOblique,Scale=MatchLowercase]{FreeSans} + \renewcommand{\familydefault}{lmss} + \usepackage[LGRgreek,defaultmathsizes,noasterisk]{mathastext} + \renewcommand{\familydefault}{\sfdefault} + \Mathastext + \let\varphi\phi + \renewcommand{\familydefault}{\rmdefault} + +helvetica_fourier_it: + description: Helvetica with Fourier (Italic) + compiler: latex + preamble: |- + \usepackage{amsmath} + \usepackage{amssymb} + \usepackage{xcolor} + \usepackage[T1]{fontenc} + \usepackage[scaled]{helvet} + \usepackage{fourier} + \renewcommand{\rmdefault}{phv} + \usepackage[italic,defaultmathsizes,noasterisk]{mathastext} + +latin_modern_tw: + description: Latin Modern Typewriter Proportional + compiler: latex + preamble: |- + \usepackage{amsmath} + \usepackage{amssymb} + \usepackage{xcolor} + \usepackage[T1]{fontenc} + \usepackage[variablett]{lmodern} + \renewcommand{\rmdefault}{\ttdefault} + \usepackage[LGRgreek]{mathastext} + \MTgreekfont{lmtt} + \Mathastext + \let\varepsilon\epsilon + +latin_modern_tw_it: + description: Latin Modern Typewriter Proportional (CM Greek) (Italic) + compiler: latex + preamble: |- + \usepackage{amsmath} + \usepackage{amssymb} + \usepackage{xcolor} + \usepackage[T1]{fontenc} + \usepackage[variablett,nomath]{lmodern} + \renewcommand{\familydefault}{\ttdefault} + \usepackage[frenchmath]{mathastext} + \linespread{1.08} + +libertine: + description: Libertine + compiler: latex + preamble: |- + \usepackage{amsmath} + \usepackage{amssymb} + \usepackage{xcolor} + \usepackage[T1]{fontenc} + \usepackage{libertine} + \usepackage[greek=n]{libgreek} + \usepackage[noasterisk,defaultmathsizes]{mathastext} + +libris_adf_fourier: + description: Libris ADF with Fourier + compiler: latex + preamble: |- + \usepackage{amsmath} + \usepackage{amssymb} + \usepackage{xcolor} + \usepackage[T1]{fontenc} + \usepackage[upright]{fourier} + \usepackage{libris} + \renewcommand{\familydefault}{\sfdefault} + \usepackage[noasterisk]{mathastext} + +minion_pro_myriad_pro: + description: Minion Pro and Myriad Pro (and TX fonts symbols) + compiler: xelatex + preamble: |- + \usepackage{amsmath} + \usepackage{amssymb} + \usepackage{xcolor} + \usepackage[T1]{fontenc} + \usepackage[default]{droidserif} + \usepackage[LGRgreek]{mathastext} + \let\varepsilon\epsilon + +minion_pro_tx: + description: Minion Pro (and TX fonts symbols) + compiler: xelatex + preamble: |- + \usepackage{amsmath} + \usepackage{amssymb} + \usepackage{xcolor} + \usepackage{txfonts} + \usepackage[no-math]{fontspec} + \setmainfont[Mapping=tex-text]{Minion Pro} + \usepackage[defaultmathsizes]{mathastext} + +new_century_schoolbook: + description: New Century Schoolbook (Symbol Greek) + compiler: latex + preamble: |- + \usepackage{amsmath} + \usepackage{amssymb} + \usepackage{xcolor} + \usepackage[T1]{fontenc} + \usepackage{newcent} + \usepackage[symbolgreek]{mathastext} + \linespread{1.1} + +new_century_schoolbook_px: + description: New Century Schoolbook (Symbol Greek, PX math symbols) + compiler: latex + preamble: |- + \usepackage{amsmath} + \usepackage{amssymb} + \usepackage{xcolor} + \usepackage[T1]{fontenc} + \usepackage{pxfonts} + \usepackage{newcent} + \usepackage[symbolgreek,defaultmathsizes]{mathastext} + \linespread{1.06} + +noteworthy_light: + description: Noteworthy Light + compiler: latex + preamble: |- + \usepackage{amsmath} + \usepackage{amssymb} + \usepackage{xcolor} + \usepackage[no-math]{fontspec} + \setmainfont[Mapping=tex-text]{Noteworthy Light} + \usepackage[defaultmathsizes]{mathastext} + +palatino: + description: Palatino (Symbol Greek) + compiler: latex + preamble: |- + \usepackage{amsmath} + \usepackage{amssymb} + \usepackage{xcolor} + \usepackage[T1]{fontenc} + \usepackage{palatino} + \usepackage[symbolmax,defaultmathsizes]{mathastext} + +papyrus: + description: Papyrus + compiler: xelatex + preamble: |- + \usepackage{amsmath} + \usepackage{amssymb} + \usepackage{xcolor} + \usepackage[no-math]{fontspec} + \setmainfont[Mapping=tex-text]{Papyrus} + \usepackage[defaultmathsizes]{mathastext} + +romande_adf_fourier_it: + description: Romande ADF with Fourier (Italic) + compiler: latex + preamble: |- + \usepackage{amsmath} + \usepackage{amssymb} + \usepackage{xcolor} + \usepackage[T1]{fontenc} + \usepackage{fourier} + \usepackage{romande} + \usepackage[italic,defaultmathsizes,noasterisk]{mathastext} + \renewcommand{\itshape}{\swashstyle} + +slitex: + description: SliTeX (Euler Greek) + compiler: latex + preamble: |- + \usepackage{amsmath} + \usepackage{amssymb} + \usepackage{xcolor} + \usepackage[T1]{fontenc} + \usepackage{tpslifonts} + \usepackage[eulergreek,defaultmathsizes]{mathastext} + \MTEulerScale{1.06} + \linespread{1.2} + +times_fourier_it: + description: Times with Fourier (Italic) + compiler: latex + preamble: |- + \usepackage{amsmath} + \usepackage{amssymb} + \usepackage{xcolor} + \usepackage{fourier} + \renewcommand{\rmdefault}{ptm} + \usepackage[italic,defaultmathsizes,noasterisk]{mathastext} + +urw_avant_garde: + description: URW Avant Garde (Symbol Greek) + compiler: latex + preamble: |- + \usepackage{amsmath} + \usepackage{amssymb} + \usepackage{xcolor} + \usepackage[T1]{fontenc} + \usepackage{avant} + \renewcommand{\familydefault}{\sfdefault} + \usepackage[symbolgreek,defaultmathsizes]{mathastext} + +urw_zapf_chancery: + description: URW Zapf Chancery (CM Greek) + compiler: latex + preamble: |- + \usepackage{amsmath} + \usepackage{amssymb} + \usepackage{xcolor} + \usepackage[T1]{fontenc} + \DeclareFontFamily{T1}{pzc}{} + \DeclareFontShape{T1}{pzc}{mb}{it}{<->s*[1.2] pzcmi8t}{} + \DeclareFontShape{T1}{pzc}{m}{it}{<->ssub * pzc/mb/it}{} + \DeclareFontShape{T1}{pzc}{mb}{sl}{<->ssub * pzc/mb/it}{} + \DeclareFontShape{T1}{pzc}{m}{sl}{<->ssub * pzc/mb/sl}{} + \DeclareFontShape{T1}{pzc}{m}{n}{<->ssub * pzc/mb/it}{} + \usepackage{chancery} + \usepackage{mathastext} + \linespread{1.05} + \boldmath + +venturis_adf_fourier_it: + description: Venturis ADF with Fourier (Italic) + compiler: latex + preamble: |- + \usepackage{amsmath} + \usepackage{amssymb} + \usepackage{xcolor} + \usepackage{fourier} + \usepackage[lf]{venturis} + \usepackage[italic,defaultmathsizes,noasterisk]{mathastext} + +verdana_it: + description: Verdana (Italic) + compiler: xelatex + preamble: |- + \usepackage{amsmath} + \usepackage{amssymb} + \usepackage{xcolor} + \usepackage[no-math]{fontspec} + \setmainfont[Mapping=tex-text]{Verdana} + \usepackage[defaultmathsizes,italic]{mathastext} + +vollkorn: + description: Vollkorn (TX fonts for Greek and math symbols) + compiler: latex + preamble: |- + \usepackage{amsmath} + \usepackage{amssymb} + \usepackage{xcolor} + \usepackage[T1]{fontenc} + \usepackage{txfonts} + \usepackage[upright]{txgreeks} + \usepackage{vollkorn} + \usepackage[defaultmathsizes]{mathastext} + +vollkorn_fourier_it: + description: Vollkorn with Fourier (Italic) + compiler: latex + preamble: |- + \usepackage{amsmath} + \usepackage{amssymb} + \usepackage{xcolor} + \usepackage{fourier} + \usepackage{vollkorn} + \usepackage[italic,nohbar]{mathastext} + +zapf_chancery: + description: Zapf Chancery + compiler: latex + preamble: |- + \usepackage{amsmath} + \usepackage{amssymb} + \usepackage{xcolor} + \DeclareFontFamily{T1}{pzc}{} + \DeclareFontShape{T1}{pzc}{mb}{it}{<->s*[1.2] pzcmi8t}{} + \DeclareFontShape{T1}{pzc}{m}{it}{<->ssub * pzc/mb/it}{} + \usepackage{chancery} + \renewcommand\shapedefault\itdefault + \renewcommand\bfdefault\mddefault + \usepackage[defaultmathsizes]{mathastext} + \linespread{1.05} diff --git a/manimlib/tex_templates/ctex_template.tex b/manimlib/tex_templates/ctex_template.tex deleted file mode 100644 index 65ff5df1..00000000 --- a/manimlib/tex_templates/ctex_template.tex +++ /dev/null @@ -1,25 +0,0 @@ -\documentclass[preview]{standalone} -\usepackage[UTF8]{ctex} - -\usepackage[english]{babel} -\usepackage{amsmath} -\usepackage{amssymb} -\usepackage{dsfont} -\usepackage{setspace} -\usepackage{tipa} -\usepackage{relsize} -\usepackage{textcomp} -\usepackage{mathrsfs} -\usepackage{calligra} -\usepackage{wasysym} -\usepackage{ragged2e} -\usepackage{physics} -\usepackage{xcolor} -\usepackage{microtype} -\linespread{1} - -\begin{document} - -[tex_expression] - -\end{document} diff --git a/manimlib/tex_templates/tex_template.tex b/manimlib/tex_templates/tex_template.tex deleted file mode 100644 index 5f665b61..00000000 --- a/manimlib/tex_templates/tex_template.tex +++ /dev/null @@ -1,28 +0,0 @@ -\documentclass[preview]{standalone} - -\usepackage[english]{babel} -\usepackage[utf8]{inputenc} -\usepackage[T1]{fontenc} -\usepackage{amsmath} -\usepackage{amssymb} -\usepackage{dsfont} -\usepackage{setspace} -\usepackage{tipa} -\usepackage{relsize} -\usepackage{textcomp} -\usepackage{mathrsfs} -\usepackage{calligra} -\usepackage{wasysym} -\usepackage{ragged2e} -\usepackage{physics} -\usepackage{xcolor} -\usepackage{microtype} -\usepackage{pifont} -\DisableLigatures{encoding = *, family = * } -\linespread{1} - -\begin{document} - -[tex_expression] - -\end{document} diff --git a/manimlib/utils/init_config.py b/manimlib/utils/init_config.py index a2786ca0..d24711e2 100644 --- a/manimlib/utils/init_config.py +++ b/manimlib/utils/init_config.py @@ -42,14 +42,9 @@ def init_customization() -> None: "sounds": "", "temporary_storage": "", }, - "tex": { - "executable": "", - "template_file": "", - "intermediate_filetype": "", - "text_to_replace": "[tex_expression]", - }, "universal_import_line": "from manimlib import *", "style": { + "tex_template": "", "font": "Consolas", "background_color": "", }, @@ -62,7 +57,7 @@ def init_customization() -> None: "medium": "1280x720", "high": "1920x1080", "4k": "3840x2160", - "default_resolution": "high", + "default_resolution": "", }, "fps": 30, } @@ -109,24 +104,14 @@ def init_customization() -> None: show_default=False ) - console.print("[bold]LaTeX:[/bold]") - tex_config = configuration["tex"] - tex = Prompt.ask( - " Select an executable program to use to compile a LaTeX source file", - choices=["latex", "xelatex"], - default="latex" - ) - if tex == "latex": - tex_config["executable"] = "latex" - tex_config["template_file"] = "tex_template.tex" - tex_config["intermediate_filetype"] = "dvi" - else: - tex_config["executable"] = "xelatex -no-pdf" - tex_config["template_file"] = "ctex_template.tex" - tex_config["intermediate_filetype"] = "xdv" - console.print("[bold]Styles:[/bold]") - configuration["style"]["background_color"] = Prompt.ask( + style_config = configuration["style"] + tex_template = Prompt.ask( + " Select a TeX template to compile a LaTeX source file", + default="default" + ) + style_config["tex_template"] = tex_template + style_config["background_color"] = Prompt.ask( " Which [bold]background color[/bold] do you want [italic](hex code)", default="#333333" ) @@ -139,7 +124,7 @@ def init_customization() -> None: ) table.add_row("480p15", "720p30", "1080p60", "2160p60") console.print(table) - configuration["camera_qualities"]["default_quality"] = Prompt.ask( + configuration["camera_resolutions"]["default_resolution"] = Prompt.ask( " Which one to choose as the default rendering quality", choices=["low", "medium", "high", "ultra_high"], default="high" @@ -161,7 +146,7 @@ def init_customization() -> None: file_name = os.path.join(os.getcwd(), "custom_config.yml") with open(file_name, "w", encoding="utf-8") as f: yaml.dump(configuration, f) - + console.print(f"\n:rocket: You have successfully set up a {scope} configuration file!") console.print(f"You can manually modify it in: [cyan]`{file_name}`[/cyan]") diff --git a/manimlib/utils/simple_functions.py b/manimlib/utils/simple_functions.py index 1371a744..143bf350 100644 --- a/manimlib/utils/simple_functions.py +++ b/manimlib/utils/simple_functions.py @@ -1,4 +1,5 @@ from functools import lru_cache +import hashlib import inspect import math @@ -76,3 +77,9 @@ def binary_search(function, else: return None return mh + + +def hash_string(string): + # Truncating at 16 bytes for cleanliness + hasher = hashlib.sha256(string.encode()) + return hasher.hexdigest()[:16] diff --git a/manimlib/utils/tex_file_writing.py b/manimlib/utils/tex_file_writing.py index e700de9d..35304b9e 100644 --- a/manimlib/utils/tex_file_writing.py +++ b/manimlib/utils/tex_file_writing.py @@ -1,135 +1,152 @@ from __future__ import annotations from contextlib import contextmanager -import hashlib import os -import sys +import re +import yaml from manimlib.config import get_custom_config from manimlib.config import get_manim_dir from manimlib.logger import log from manimlib.utils.directories import get_tex_dir +from manimlib.utils.simple_functions import hash_string SAVED_TEX_CONFIG = {} +def get_tex_template_config(template_name: str) -> dict[str, str]: + name = template_name.replace(" ", "_").lower() + with open(os.path.join( + get_manim_dir(), "manimlib", "tex_templates.yml" + ), encoding="utf-8") as tex_templates_file: + templates_dict = yaml.safe_load(tex_templates_file) + if name not in templates_dict: + log.warning( + "Cannot recognize template '%s', falling back to 'default'.", + name + ) + name = "default" + return templates_dict[name] + + def get_tex_config() -> dict[str, str]: """ Returns a dict which should look something like this: { - "executable": "latex", - "template_file": "tex_template.tex", - "intermediate_filetype": "dvi", - "text_to_replace": "YourTextHere", - "tex_body": "..." + "template": "default", + "compiler": "latex", + "preamble": "..." } """ # Only load once, then save thereafter if not SAVED_TEX_CONFIG: - custom_config = get_custom_config() - SAVED_TEX_CONFIG.update(custom_config["tex"]) - # Read in template file - template_filename = os.path.join( - get_manim_dir(), "manimlib", "tex_templates", - SAVED_TEX_CONFIG["template_file"], - ) - with open(template_filename, "r", encoding="utf-8") as file: - SAVED_TEX_CONFIG["tex_body"] = file.read() + template_name = get_custom_config()["style"]["tex_template"] + template_config = get_tex_template_config(template_name) + SAVED_TEX_CONFIG.update({ + "template": template_name, + "compiler": template_config["compiler"], + "preamble": template_config["preamble"] + }) return SAVED_TEX_CONFIG -def tex_hash(tex_file_content: str) -> int: - # Truncating at 16 bytes for cleanliness - hasher = hashlib.sha256(tex_file_content.encode()) - return hasher.hexdigest()[:16] +def tex_content_to_svg_file( + content: str, template: str, additional_preamble: 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}", + preamble, + "\\begin{document}", + content, + "\\end{document}" + )) + "\n" -def tex_to_svg_file(tex_file_content: str) -> str: svg_file = os.path.join( - get_tex_dir(), tex_hash(tex_file_content) + ".svg" + get_tex_dir(), hash_string(full_tex) + ".svg" ) if not os.path.exists(svg_file): # If svg doesn't exist, create it - tex_to_svg(tex_file_content, svg_file) + create_tex_svg(full_tex, svg_file, compiler) return svg_file -def tex_to_svg(tex_file_content: str, svg_file: str) -> str: - tex_file = svg_file.replace(".svg", ".tex") - with open(tex_file, "w", encoding="utf-8") as outfile: - outfile.write(tex_file_content) - svg_file = dvi_to_svg(tex_to_dvi(tex_file)) +def create_tex_svg(full_tex: str, svg_file: str, compiler: str) -> None: + if compiler == "latex": + program = "latex" + dvi_ext = ".dvi" + elif compiler == "xelatex": + program = "xelatex -no-pdf" + dvi_ext = ".xdv" + else: + raise NotImplementedError( + f"Compiler '{compiler}' is not implemented" + ) + + # Write tex file + root, _ = os.path.splitext(svg_file) + with open(root + ".tex", "w", encoding="utf-8") as tex_file: + tex_file.write(full_tex) + + # tex to dvi + if os.system(" ".join(( + program, + "-interaction=batchmode", + "-halt-on-error", + f"-output-directory=\"{os.path.dirname(svg_file)}\"", + f"\"{root}.tex\"", + ">", + os.devnull + ))): + log.error( + "LaTeX Error! Not a worry, it happens to the best of us." + ) + with open(root + ".log", "r", encoding="utf-8") as log_file: + error_match_obj = re.search(r"(?<=\n! ).*", log_file.read()) + if error_match_obj: + log.debug( + "The error could be: `%s`", + error_match_obj.group() + ) + raise LatexError() + + # dvi to svg + os.system(" ".join(( + "dvisvgm", + f"\"{root}{dvi_ext}\"", + "-n", + "-v", + "0", + "-o", + f"\"{svg_file}\"", + ">", + os.devnull + ))) # Cleanup superfluous documents - tex_dir, name = os.path.split(svg_file) - stem, end = name.split(".") - for file in filter(lambda s: s.startswith(stem), os.listdir(tex_dir)): - if not file.endswith(end): - os.remove(os.path.join(tex_dir, file)) - - return svg_file - - -def tex_to_dvi(tex_file: str) -> str: - tex_config = get_tex_config() - program = tex_config["executable"] - file_type = tex_config["intermediate_filetype"] - result = tex_file.replace(".tex", "." + file_type) - if not os.path.exists(result): - commands = [ - program, - "-interaction=batchmode", - "-halt-on-error", - f"-output-directory=\"{os.path.dirname(tex_file)}\"", - f"\"{tex_file}\"", - ">", - os.devnull - ] - exit_code = os.system(" ".join(commands)) - if exit_code != 0: - log_file = tex_file.replace(".tex", ".log") - log.error("LaTeX Error! Not a worry, it happens to the best of us.") - error_str = "" - with open(log_file, "r", encoding="utf-8") as file: - for line in file.readlines(): - if line.startswith("!"): - error_str = line[2:-1] - log.debug(f"The error could be: `{error_str}`") - raise LatexError(error_str) - return result - - -def dvi_to_svg(dvi_file: str) -> str: - """ - 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 - """ - file_type = get_tex_config()["intermediate_filetype"] - result = dvi_file.replace("." + file_type, ".svg") - if not os.path.exists(result): - commands = [ - "dvisvgm", - "\"{}\"".format(dvi_file), - "-n", - "-v", - "0", - "-o", - "\"{}\"".format(result), - ">", - os.devnull - ] - os.system(" ".join(commands)) - return result + for ext in (".tex", dvi_ext, ".log", ".aux"): + try: + os.remove(root + ext) + except FileNotFoundError: + pass # TODO, perhaps this should live elsewhere @contextmanager -def display_during_execution(message: str) -> None: - # Only show top line - to_print = message.split("\n")[0] +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] + "..." @@ -140,6 +157,5 @@ def display_during_execution(message: str) -> None: print(" " * len(to_print), end="\r") - class LatexError(Exception): pass diff --git a/requirements.txt b/requirements.txt index 5c6ae599..ab0b5026 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,7 +17,7 @@ rich scipy screeninfo skia-pathops -svgelements +svgelements>=1.8.1 sympy tqdm validators diff --git a/setup.cfg b/setup.cfg index 82c033f9..6871e906 100644 --- a/setup.cfg +++ b/setup.cfg @@ -48,7 +48,7 @@ install_requires = scipy screeninfo skia-pathops - svgelements + svgelements>=1.8.1 sympy tqdm validators