Files
manim/manimlib/mobject/svg/text_mobject.py

540 lines
17 KiB
Python

from __future__ import annotations
from contextlib import contextmanager
import os
from pathlib import Path
import re
import tempfile
import hashlib
import manimpango
import pygments
import pygments.formatters
import pygments.lexers
from manimlib.constants import DEFAULT_PIXEL_WIDTH, FRAME_WIDTH
from manimlib.constants import NORMAL
from manimlib.logger import log
from manimlib.mobject.svg.string_mobject import StringMobject
from manimlib.utils.cache import cache_on_disk
from manimlib.utils.color import color_to_hex
from manimlib.utils.color import int_to_hex
from manimlib.utils.customization import get_customization
from manimlib.utils.directories import get_downloads_dir
from manimlib.utils.simple_functions import hash_string
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Iterable
from manimlib.mobject.types.vectorized_mobject import VGroup
from manimlib.typing import ManimColor, Span, Selector
TEXT_MOB_SCALE_FACTOR = 0.0076
DEFAULT_LINE_SPACING_SCALE = 0.6
# Ensure the canvas is large enough to hold all glyphs.
DEFAULT_CANVAS_WIDTH = 16384
DEFAULT_CANVAS_HEIGHT = 16384
# Temporary handler
class _Alignment:
VAL_DICT = {
"LEFT": 0,
"CENTER": 1,
"RIGHT": 2
}
def __init__(self, s: str):
self.value = _Alignment.VAL_DICT[s.upper()]
@cache_on_disk
def markup_to_svg_string(
markup_str: str,
justify: bool = False,
indent: float = 0,
alignment: str = "",
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):
# See https://docs.gtk.org/Pango/pango_markup.html
MARKUP_TAGS = {
"b": {"font_weight": "bold"},
"big": {"font_size": "larger"},
"i": {"font_style": "italic"},
"s": {"strikethrough": "true"},
"sub": {"baseline_shift": "subscript", "font_scale": "subscript"},
"sup": {"baseline_shift": "superscript", "font_scale": "superscript"},
"small": {"font_size": "smaller"},
"tt": {"font_family": "monospace"},
"u": {"underline": "single"},
}
MARKUP_ENTITY_DICT = {
"<": "&lt;",
">": "&gt;",
"&": "&amp;",
"\"": "&quot;",
"'": "&apos;"
}
def __init__(
self,
text: str,
font_size: int = 48,
height: float | None = None,
justify: bool = False,
indent: float = 0,
alignment: str = "",
line_width: float | None = None,
font: str = "",
slant: str = NORMAL,
weight: str = NORMAL,
gradient: Iterable[ManimColor] | None = None,
line_spacing_height: float | None = None,
text2color: dict = {},
text2font: dict = {},
text2gradient: dict = {},
text2slant: dict = {},
text2weight: dict = {},
# For convenience, one can use shortened names
lsh: float | None = None, # Overrides line_spacing_height
t2c: dict = {}, # Overrides text2color if nonempty
t2f: dict = {}, # Overrides text2font if nonempty
t2g: dict = {}, # Overrides text2gradient if nonempty
t2s: dict = {}, # Overrides text2slant if nonempty
t2w: dict = {}, # Overrides text2weight if nonempty
global_config: dict = {},
local_configs: dict = {},
disable_ligatures: bool = True,
isolate: Selector = re.compile(r"\w+", re.U),
**kwargs
):
self.text = text
self.font_size = font_size
self.justify = justify
self.indent = indent
self.alignment = alignment or get_customization()["style"]["text_alignment"]
self.line_width = line_width
self.font = font or get_customization()["style"]["font"]
self.slant = slant
self.weight = weight
self.lsh = line_spacing_height or lsh
self.t2c = text2color or t2c
self.t2f = text2font or t2f
self.t2g = text2gradient or t2g
self.t2s = text2slant or t2s
self.t2w = text2weight or t2w
self.global_config = global_config
self.local_configs = local_configs
self.disable_ligatures = disable_ligatures
self.isolate = isolate
if not isinstance(self, Text):
self.validate_markup_string(text)
super().__init__(text, height=height, **kwargs)
if self.t2g:
log.warning("""
Manim currently cannot parse gradient from svg.
Please set gradient via `set_color_by_gradient`.
""")
if gradient:
self.set_color_by_gradient(*gradient)
if self.t2c:
self.set_color_by_text_to_color_map(self.t2c)
if height is None:
self.scale(TEXT_MOB_SCALE_FACTOR)
@property
def hash_seed(self) -> tuple:
return (
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_svg_string_by_content(self, content: str) -> str:
self.content = content
return markup_to_svg_string(
content,
justify=self.justify,
indent=self.indent,
alignment=self.alignment,
line_width=self.line_width
)
# 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
@staticmethod
def get_command_matches(string: str) -> list[re.Match]:
pattern = re.compile(r"""
(?P<tag>
<
(?P<close_slash>/)?
(?P<tag_name>\w+)\s*
(?P<attr_list>(?:\w+\s*\=\s*(?P<quot>["']).*?(?P=quot)\s*)*)
(?P<elision_slash>/)?
>
)
|(?P<passthrough>
<\?.*?\?>|<!--.*?-->|<!\[CDATA\[.*?\]\]>|<!DOCTYPE.*?>
)
|(?P<entity>&(?P<unicode>\#(?P<hex>x)?)?(?P<content>.*?);)
|(?P<char>[>"'])
""", flags=re.X | re.S)
return list(pattern.finditer(string))
@staticmethod
def get_command_flag(match_obj: re.Match) -> 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
@staticmethod
def replace_for_content(match_obj: re.Match) -> str:
if match_obj.group("tag"):
return ""
if match_obj.group("char"):
return MarkupText.escape_markup_char(match_obj.group("char"))
return match_obj.group()
@staticmethod
def replace_for_matching(match_obj: re.Match) -> str:
if match_obj.group("tag") or match_obj.group("passthrough"):
return ""
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()
@staticmethod
def get_attr_dict_from_command_pair(
open_command: re.Match, close_command: re.Match
) -> dict[str, str] | None:
pattern = r"""
(?P<attr_name>\w+)
\s*\=\s*
(?P<quot>["'])(?P<attr_val>.*?)(?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 [
*(
(span, {key: val})
for t2x_dict, key in (
(self.t2c, "foreground"),
(self.t2f, "font_family"),
(self.t2s, "font_style"),
(self.t2w, "font_weight")
)
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)
)
]
@staticmethod
def get_command_string(
attr_dict: dict[str, str], is_end: bool, label_hex: str | None
) -> str:
if is_end:
return "</span>"
if label_hex is not None:
converted_attr_dict = {"foreground": label_hex}
for key, val in attr_dict.items():
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"<span {attrs_str}>"
def get_content_prefix_and_suffix(
self, is_labelled: bool
) -> tuple[str, str]:
global_attr_dict = {
"foreground": color_to_hex(self.base_color),
"font_family": self.font,
"font_style": self.slant,
"font_weight": self.weight,
"font_size": str(round(self.font_size * 1024)),
}
# `line_height` attribute is supported since Pango 1.50.
pango_version = manimpango.pango_version()
if tuple(map(int, pango_version.split("."))) < (1, 50):
if self.lsh is not None:
log.warning(
"Pango version %s found (< 1.50), "
"unable to set `line_height` attribute",
pango_version
)
else:
line_spacing_scale = self.lsh or DEFAULT_LINE_SPACING_SCALE
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"
global_attr_dict.update(self.global_config)
return tuple(
self.get_command_string(
global_attr_dict,
is_end=is_end,
label_hex=int_to_hex(0) if is_labelled else None
)
for is_end in (False, True)
)
# Method alias
def get_parts_by_text(self, selector: Selector) -> VGroup:
return self.select_parts(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)
def set_color_by_text_to_color_map(
self, color_map: dict[Selector, ManimColor]
):
return self.set_parts_color_by_dict(color_map)
def get_text(self) -> str:
return self.get_string()
class Text(MarkupText):
def __init__(
self,
text: str,
# For backward compatibility
isolate: Selector = (re.compile(r"\w+", re.U), re.compile(r"\S+", re.U)),
use_labelled_svg: bool = True,
path_string_config: dict = dict(
use_simple_quadratic_approx=True,
),
**kwargs
):
super().__init__(
text,
isolate=isolate,
use_labelled_svg=use_labelled_svg,
path_string_config=path_string_config,
**kwargs
)
@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):
def __init__(
self,
code: str,
font: str = "Consolas",
font_size: int = 24,
lsh: float = 1.0,
fill_color: ManimColor = None,
stroke_color: ManimColor = None,
language: str = "python",
# Visit https://pygments.org/demo/ to have a preview of more styles.
code_style: str = "monokai",
**kwargs
):
lexer = pygments.lexers.get_lexer_by_name(language)
formatter = pygments.formatters.PangoMarkupFormatter(
style=code_style
)
markup = pygments.highlight(code, lexer, formatter)
markup = re.sub(r"</?tt>", "", markup)
super().__init__(
markup,
font=font,
font_size=font_size,
lsh=lsh,
stroke_color=stroke_color,
fill_color=fill_color,
**kwargs
)
@contextmanager
def register_font(font_file: str | Path):
"""Temporarily add a font file to Pango's search path.
This searches for the font_file at various places. The order it searches it described below.
1. Absolute path.
2. Downloads dir.
Parameters
----------
font_file :
The font file to add.
Examples
--------
Use ``with register_font(...)`` to add a font file to search
path.
.. code-block:: python
with register_font("path/to/font_file.ttf"):
a = Text("Hello", font="Custom Font Name")
Raises
------
FileNotFoundError:
If the font doesn't exists.
AttributeError:
If this method is used on macOS.
Notes
-----
This method of adding font files also works with :class:`CairoText`.
.. important ::
This method is available for macOS for ``ManimPango>=v0.2.3``. Using this
method with previous releases will raise an :class:`AttributeError` on macOS.
"""
input_folder = Path(get_downloads_dir()).parent.resolve()
possible_paths = [
Path(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)
try:
assert manimpango.register_font(str(file_path))
yield
finally:
manimpango.unregister_font(str(file_path))