Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions manimlib/mobject/svg/string_mobject.py
Original file line number Diff line number Diff line change
Expand Up @@ -416,10 +416,9 @@ def get_attr_dict_from_command_pair(
def get_configured_items(self) -> list[tuple[Span, dict[str, str]]]:
return []

@staticmethod
@abstractmethod
def get_command_string(
attr_dict: dict[str, str], is_end: bool, label_hex: str | None
self, attr_dict: dict[str, str], is_end: bool, label_hex: str | None
) -> str:
return ""

Expand Down
5 changes: 2 additions & 3 deletions manimlib/mobject/svg/tex_mobject.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,15 +156,14 @@ def get_color_command(rgb_hex: str) -> str:
r, g = divmod(rg, 256)
return f"\\color[RGB]{{{r}, {g}, {b}}}"

@staticmethod
def get_command_string(
attr_dict: dict[str, str], is_end: bool, label_hex: str | None
self, attr_dict: dict[str, str], is_end: bool, label_hex: str | None
) -> str:
if label_hex is None:
return ""
if is_end:
return "}}"
return "{{" + Tex.get_color_command(label_hex)
return "{{" + self.get_color_command(label_hex)

def get_content_prefix_and_suffix(
self, is_labelled: bool
Expand Down
3 changes: 1 addition & 2 deletions manimlib/mobject/svg/text_mobject.py
Original file line number Diff line number Diff line change
Expand Up @@ -303,9 +303,8 @@ def get_configured_items(self) -> list[tuple[Span, dict[str, str]]]:
)
]

@staticmethod
def get_command_string(
attr_dict: dict[str, str], is_end: bool, label_hex: str | None
self, attr_dict: dict[str, str], is_end: bool, label_hex: str | None
) -> str:
if is_end:
return "</span>"
Expand Down
239 changes: 239 additions & 0 deletions manimlib/mobject/svg/typst_tex_mobject.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
import re
import tempfile
import subprocess
from pathlib import Path
from functools import lru_cache

from manimlib.config import manim_config
from manimlib.mobject.types.vectorized_mobject import VGroup
from manimlib.utils.color import color_to_hex
from manimlib.utils.cache import cache_on_disk
from manimlib.mobject.svg.tex_mobject import Tex
from manimlib.mobject.geometry import RoundedRectangle
from manimlib.utils.typst_tex_symbol_count import (
ACCENT_COMMANDS,
TYPST_TEX_SYMBOL_COUNT,
DELIMITER_COMMANDS,
)
from manimlib.utils.tex_file_writing import LatexError, get_tex_template_config
from manimlib.logger import log


@lru_cache(maxsize=128)
def get_tex_preamble(template: str = "") -> str:
template = template or manim_config.tex.template
config = get_tex_template_config(template)
return config["preamble"]


@cache_on_disk
def typst_tex2svg(content: str) -> str:
with tempfile.TemporaryDirectory() as temp_dir:
tex_path = Path(temp_dir, "working").with_suffix(".typ")
svg_path = tex_path.with_suffix(".svg")
tex_path.write_text(content)

process = subprocess.run(
["typst", "compile", "--format", "svg", tex_path, svg_path],
capture_output=True,
text=True,
)

# Handle error
if process.returncode != 0:
error_str = ""
log_path = tex_path.with_suffix(".log")
if log_path.exists():
content = log_path.read_text()
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")

with open(svg_path) as file_svg:
result = file_svg.read()
return result


def typst_latex2svg(
latex: str,
template: str = "",
additional_preamble: str = "",
short_tex: str = "",
show_message_during_execution: bool = True,
) -> str:
if show_message_during_execution:
short_tex = f"Writing {(short_tex or latex)[:70]}..."
print(short_tex, end="\r")

preamble = get_tex_preamble(template)
full_tex = "\n".join([preamble, additional_preamble, latex])
print(" " * len(short_tex), end="\r")
return typst_tex2svg(full_tex)


class TypstTex(Tex):
# NOTE: To render fraction, kindly use `frac(a, b)` instead of `a/b` for proper indexing.
tex_environment: str = "$"

def __init__(
self, *tex_strings: str, alignment: str = "center", fill_border_width: int = 0, **kwargs
):
alignment = f"#set align({alignment})"
super().__init__(
*tex_strings, alignment=alignment, fill_border_width=fill_border_width, **kwargs
)

# horizontal line has no fill.
for mob in self.family_members_with_points():
if not mob.get_fill_opacity():
rect = RoundedRectangle(width=mob.get_width(), height=0.025, corner_radius=0.01)
rect.set_fill(mob.get_color(), 1).set_stroke(width=0).move_to(mob)
mob.become(rect)
self.set_symbol_count()

@staticmethod
def get_color_command(rgb_hex: str) -> str:
return f'#text(fill: rgb("{rgb_hex}"))'

def get_command_string(
self, attr_dict: dict[str, str], is_end: bool, label_hex: str | None
) -> str:
if label_hex is None:
return ""
if is_end:
return f"{self.tex_environment}]"
return self.get_color_command(label_hex) + f"[{self.tex_environment}"

def get_content_prefix_and_suffix(self, is_labelled: bool) -> tuple[str, str]:
prefix_lines = []
suffix_line = ""

if not is_labelled:
prefix_lines.append(self.get_color_command(color_to_hex(self.base_color)))
if self.alignment:
prefix_lines.append(self.alignment)

prefix_lines = "".join([line + "\n" for line in prefix_lines])

if self.tex_environment:
prefix_lines += f"{self.tex_environment} "
suffix_line = f" {self.tex_environment}"

return prefix_lines, suffix_line

def get_svg_string_by_content(self, content: str) -> str:
return typst_latex2svg(
content, self.template, self.additional_preamble, short_tex=self.tex_string
)

def set_symbol_count(self):
pattern = r"""
(?P<cmd>[a-zA-Z][a-zA-Z0-9\.]*[a-zA-Z0-9])|
(?P<script>[_^])|
(?P<operator>->|=>|<=|>=|==|!=|\.\.\.)|
(?P<fraction>\bfrac\b)|
(?P<char>\S)
"""

counts = [0] * len(self.string)
group_stack = []
current_group = "normal"

for match in re.finditer(pattern, self.string, re.VERBOSE):
text = match.group()
start = match.start()
num = TYPST_TEX_SYMBOL_COUNT.get(text, 1)

if text == "(":
group_stack.append(current_group)
to_hide = current_group != "normal"
current_group = "normal"
if to_hide:
continue

elif text == ")":
if group_stack:
if (group := group_stack.pop()) != "normal":
if group in ("frac", "delimiter"):
counts[start] += 1
continue

elif text == ",":
if group_stack and group_stack[-1] == "frac":
continue

elif match.group("script") or (match.group("command") and num == 0):
current_group = "wrapper"
continue

elif text in ACCENT_COMMANDS:
current_group = "wrapper"

elif current_group in DELIMITER_COMMANDS:
current_group = "delimiter"

else:
current_group = "normal"

counts[start] += num if match.group("command") else 1
if sum(counts) != len(self):
log.warning(f"Estimated size of {self.get_tex()} does not match true size")
self.symbol_count = counts

def get_symbol_substrings(self):
pattern = "|".join(
(
r"[a-zA-Z](?:[a-zA-Z0-9\.]*[a-zA-Z0-9])?",
r"->|=>|<=|>=|==|!=|\.\.\.",
r"[0-9]+",
r"[^\^\{\}\s\_\$\&\\\"]",
)
)
return re.findall(pattern, self.string)

# TransformMatchingTex uses this function
def substr_to_path_count(self, substr: str) -> int:
return TYPST_TEX_SYMBOL_COUNT.get(substr, 1)

def select_unisolated_substring(self, pattern: str | re.Pattern) -> VGroup:
counts = self.symbol_count
if isinstance(pattern, re.Pattern):
matches = pattern.finditer(self.string)
else:
escape_pat = re.escape(pattern)
if pattern[0].isalnum():
escape_pat = r"(?<![a-zA-Z0-9_])" + escape_pat

if pattern[-1].isalnum():
escape_pat = escape_pat + r"(?![a-zA-Z0-9_])"

matches = re.finditer(escape_pat, self.string)

result = []
for match in matches:
start, end = match.start(), match.end()
start_idx = sum(counts[:start])
end_idx = start_idx + sum(counts[start:end])
result.append(self[start_idx:end_idx])

return VGroup(*result)


class TypstTexText(TypstTex):
tex_environment: str = ""

def set_symbol_count(self):
pattern = r"""
(?P<escape_char>\\[\S])|
(?P<char>\S)
"""
counts = [0] * len(self.string)
for match in re.finditer(pattern, self.string, re.VERBOSE):
start = match.start()
if match.group("escape_char"):
counts[start + 1] = 1
else:
counts[start] = 1

self.symbol_count = counts
6 changes: 6 additions & 0 deletions manimlib/tex_templates.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ default:
\linespread{1}
%% Borrowed from https://tex.stackexchange.com/questions/6058/making-a-shorter-minus
\DeclareMathSymbol{\minus}{\mathbin}{AMSa}{"39}

typst:
description: ""
preamble: |-
#set page(width: auto, height: auto, margin: 0pt, fill: none)

ctex:
description: ""
compiler: xelatex
Expand Down
98 changes: 98 additions & 0 deletions manimlib/utils/typst_tex_symbol_count.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
HIDEEN_COMMANDS = [
"bold",
"italic",
"upright",
"serif",
"sans",
"cal",
"frak",
"mono",
"bb",
"op",
"text",
"frac",
"binom",
"mat",
"vec",
"cases",
"stack",
"lr",
"display",
"inline",
"script",
"scripts",
"sscript",
"attach",
"h",
"v",
"phantom",
"hide",
"pad",
]

ACCENT_COMMANDS = [
"hat",
"tilde",
"bar",
"macron",
"breve",
"dot",
"diaer",
"circle",
"acute",
"grave",
"caron",
"arrow",
"harpoon",
"dot.double",
"dot.triple",
"dot.quad",
"underline",
"overline",
"cancel",
"strike",
"sqrt",
"root",
]

DELIMITER_COMMANDS = ["abs", "norm", "floor", "ceil", "round"]

TYPST_TEX_SYMBOL_COUNT = {
**{sym: 0 for sym in HIDEEN_COMMANDS},
**{sym: 1 for sym in DELIMITER_COMMANDS},
"": 0,
" ": 0,
"/": 0,
"^": 0,
"_": 0,
"lg": 2,
"ln": 2,
"sqrt": 2,
"lim": 3,
"log": 3,
"cos": 3,
"cot": 3,
"csc": 3,
"arg": 3,
"mod": 3,
"sec": 3,
"sin": 3,
"sup": 3,
"tan": 3,
"max": 3,
"min": 3,
"exp": 3,
"sinh": 4,
"tanh": 4,
"cosh": 4,
"coth": 4,
"liminf": 6,
"limsup": 6,
"arccos": 6,
"arcsin": 6,
"arctan": 6,
"integral": 1,
"sum": 1,
"(": 1,
")": 1,
}