mirror of
https://github.com/3b1b/manim.git
synced 2025-08-05 16:49:03 +00:00
Add MarkupText
This would use a Pango specific markup which looks like html. There are some specific implementation here about `<color>` and `<gradient>` Pango doesn't support `<gradient>` or `<color> ` instead it works with `color` attribute and gradient isn't supported. Since, `SVGMobject` doesn't know about parsing colors from SVG image and implmentation of `<color>` and `<gradient>` is added. Co-authored-by: Philipp Imhof <52650214+PhilippImhof@users.noreply.github.com> Signed-off-by: Naveen M K <naveen@syrusdark.website>
This commit is contained in:
parent
5765ab9055
commit
5986d0e7d2
1 changed files with 236 additions and 2 deletions
|
@ -2,8 +2,12 @@ import copy
|
||||||
import hashlib
|
import hashlib
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import io
|
||||||
import typing
|
import typing
|
||||||
import warnings
|
import warnings
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
import functools
|
||||||
|
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
@ -15,8 +19,7 @@ from manimlib.mobject.types.vectorized_mobject import VGroup
|
||||||
from manimlib.utils.config_ops import digest_config
|
from manimlib.utils.config_ops import digest_config
|
||||||
from manimlib.utils.customization import get_customization
|
from manimlib.utils.customization import get_customization
|
||||||
from manimlib.utils.directories import get_downloads_dir, get_text_dir
|
from manimlib.utils.directories import get_downloads_dir, get_text_dir
|
||||||
from manimpango import PangoUtils
|
from manimpango import PangoUtils, TextSetting, MarkupUtils
|
||||||
from manimpango import TextSetting
|
|
||||||
|
|
||||||
TEXT_MOB_SCALE_FACTOR = 1/100
|
TEXT_MOB_SCALE_FACTOR = 1/100
|
||||||
DEFAULT_LINE_SPACING_SCALE = 0.3
|
DEFAULT_LINE_SPACING_SCALE = 0.3
|
||||||
|
@ -238,6 +241,237 @@ class Text(SVGMobject):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MarkupText(SVGMobject):
|
||||||
|
CONFIG = {
|
||||||
|
# Mobject
|
||||||
|
"color": WHITE,
|
||||||
|
"height": None,
|
||||||
|
# Text
|
||||||
|
"font": '',
|
||||||
|
"font_size": 48,
|
||||||
|
"lsh": None,
|
||||||
|
"justify": False,
|
||||||
|
"slant": NORMAL,
|
||||||
|
"weight": NORMAL,
|
||||||
|
"tab_width": 4,
|
||||||
|
"gradient": None,
|
||||||
|
"disable_ligatures": True,
|
||||||
|
}
|
||||||
|
def __init__(self, text, **config):
|
||||||
|
digest_config(self, config)
|
||||||
|
self.text = f'<span>{text}</span>'
|
||||||
|
self.original_text = self.text
|
||||||
|
self.text_for_parsing = self.text
|
||||||
|
text_without_tabs = text
|
||||||
|
if "\t" in text:
|
||||||
|
text_without_tabs = text.replace("\t", " " * self.tab_width)
|
||||||
|
try:
|
||||||
|
colormap = self.extract_color_tags()
|
||||||
|
gradientmap = self.extract_gradient_tags()
|
||||||
|
except ET.ParseError:
|
||||||
|
# let pango handle that error
|
||||||
|
pass
|
||||||
|
validate_error = MarkupUtils.validate(self.text)
|
||||||
|
if validate_error:
|
||||||
|
raise ValueError(validate_error)
|
||||||
|
file_name = self.text2svg()
|
||||||
|
PangoUtils.remove_last_M(file_name)
|
||||||
|
super().__init__(
|
||||||
|
file_name,
|
||||||
|
**config,
|
||||||
|
)
|
||||||
|
self.chars = self.get_group_class()(*self.submobjects)
|
||||||
|
self.text = text_without_tabs.replace(" ", "").replace("\n", "")
|
||||||
|
if self.gradient:
|
||||||
|
self.set_color_by_gradient(*self.gradient)
|
||||||
|
for col in colormap:
|
||||||
|
self.chars[
|
||||||
|
col["start"]
|
||||||
|
- col["start_offset"] : col["end"]
|
||||||
|
- col["start_offset"]
|
||||||
|
- col["end_offset"]
|
||||||
|
].set_color(self._parse_color(col["color"]))
|
||||||
|
for grad in gradientmap:
|
||||||
|
self.chars[
|
||||||
|
grad["start"]
|
||||||
|
- grad["start_offset"] : grad["end"]
|
||||||
|
- grad["start_offset"]
|
||||||
|
- grad["end_offset"]
|
||||||
|
].set_color_by_gradient(
|
||||||
|
*(self._parse_color(grad["from"]), self._parse_color(grad["to"]))
|
||||||
|
)
|
||||||
|
# anti-aliasing
|
||||||
|
if self.height is None:
|
||||||
|
self.scale(TEXT_MOB_SCALE_FACTOR)
|
||||||
|
def text2hash(self):
|
||||||
|
"""Generates ``sha256`` hash for file name."""
|
||||||
|
settings = (
|
||||||
|
"MARKUPPANGO" + self.font + self.slant + self.weight + self.color
|
||||||
|
) # to differentiate from classical Pango Text
|
||||||
|
settings += str(self.lsh) + str(self.font_size)
|
||||||
|
settings += str(self.disable_ligatures)
|
||||||
|
settings += str(self.justify)
|
||||||
|
id_str = self.text + settings
|
||||||
|
hasher = hashlib.sha256()
|
||||||
|
hasher.update(id_str.encode())
|
||||||
|
return hasher.hexdigest()[:16]
|
||||||
|
|
||||||
|
def text2svg(self):
|
||||||
|
"""Convert the text to SVG using Pango."""
|
||||||
|
size = self.font_size
|
||||||
|
dir_name = get_text_dir()
|
||||||
|
disable_liga = self.disable_ligatures
|
||||||
|
if not os.path.exists(dir_name):
|
||||||
|
os.makedirs(dir_name)
|
||||||
|
hash_name = self.text2hash()
|
||||||
|
file_name = os.path.join(dir_name, hash_name) + ".svg"
|
||||||
|
if os.path.exists(file_name):
|
||||||
|
return file_name
|
||||||
|
|
||||||
|
extra_kwargs = {}
|
||||||
|
extra_kwargs['justify'] = self.justify
|
||||||
|
extra_kwargs['pango_width'] = DEFAULT_PIXEL_WIDTH - 100
|
||||||
|
if self.lsh:
|
||||||
|
extra_kwargs['line_spacing']=self.lsh
|
||||||
|
return MarkupUtils.text2svg(
|
||||||
|
f'<span foreground="{self.color}">{self.text}</span>',
|
||||||
|
self.font,
|
||||||
|
self.slant,
|
||||||
|
self.weight,
|
||||||
|
size,
|
||||||
|
0, # empty parameter
|
||||||
|
disable_liga,
|
||||||
|
file_name,
|
||||||
|
START_X,
|
||||||
|
START_Y,
|
||||||
|
DEFAULT_PIXEL_WIDTH, # width
|
||||||
|
DEFAULT_PIXEL_HEIGHT, # height
|
||||||
|
**extra_kwargs
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_color(self, col):
|
||||||
|
"""Parse color given in ``<color>`` or ``<gradient>`` tags."""
|
||||||
|
if re.match("#[0-9a-f]{6}", col):
|
||||||
|
return col
|
||||||
|
else:
|
||||||
|
return globals()[col.upper()] # this is hacky
|
||||||
|
|
||||||
|
@functools.lru_cache(10)
|
||||||
|
def get_text_from_markup(self, element=None):
|
||||||
|
if not element:
|
||||||
|
element = ET.fromstring(self.text_for_parsing)
|
||||||
|
final_text = ''
|
||||||
|
for i in element.itertext():
|
||||||
|
final_text += i
|
||||||
|
return final_text
|
||||||
|
|
||||||
|
def extract_color_tags(self, text=None, colormap = None):
|
||||||
|
"""Used to determine which parts (if any) of the string should be formatted
|
||||||
|
with a custom color.
|
||||||
|
Removes the ``<color>`` tag, as it is not part of Pango's markup and would cause an error.
|
||||||
|
Note: Using the ``<color>`` tags is deprecated. As soon as the legacy syntax is gone, this function
|
||||||
|
will be removed.
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
text = self.text_for_parsing
|
||||||
|
if not colormap:
|
||||||
|
colormap = list()
|
||||||
|
elements = ET.fromstring(text)
|
||||||
|
text_from_markup = self.get_text_from_markup()
|
||||||
|
final_xml = ET.fromstring(f'<span>{elements.text if elements.text else ""}</span>')
|
||||||
|
def get_color_map(elements):
|
||||||
|
for element in elements:
|
||||||
|
if element.tag == 'color':
|
||||||
|
element_text = self.get_text_from_markup(element)
|
||||||
|
start = text_from_markup.find(element_text)
|
||||||
|
end = start + len(element_text)
|
||||||
|
offsets = element.get('offset').split(",") if element.get('offset') else [0]
|
||||||
|
start_offset = int(offsets[0]) if offsets[0] else 0
|
||||||
|
end_offset = int(offsets[1]) if len(offsets) == 2 and offsets[1] else 0
|
||||||
|
colormap.append(
|
||||||
|
{
|
||||||
|
"start": start,
|
||||||
|
"end": end,
|
||||||
|
"color": element.get('col'),
|
||||||
|
"start_offset": start_offset,
|
||||||
|
"end_offset": end_offset,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
_elements_list = list(element.iter())
|
||||||
|
if len(_elements_list) <= 1:
|
||||||
|
final_xml.append(ET.fromstring(f'<span>{element.text if element.text else ""}</span>'))
|
||||||
|
else:
|
||||||
|
final_xml.append(_elements_list[-1])
|
||||||
|
else:
|
||||||
|
if len(list(element.iter())) == 1:
|
||||||
|
final_xml.append(element)
|
||||||
|
else:
|
||||||
|
get_color_map(element)
|
||||||
|
get_color_map(elements)
|
||||||
|
with io.BytesIO() as f:
|
||||||
|
tree = ET.ElementTree()
|
||||||
|
tree._setroot(final_xml)
|
||||||
|
tree.write(f)
|
||||||
|
self.text = f.getvalue().decode()
|
||||||
|
self.text_for_parsing = self.text # gradients will use it
|
||||||
|
return colormap
|
||||||
|
|
||||||
|
def extract_gradient_tags(self, text=None,gradientmap=None):
|
||||||
|
"""Used to determine which parts (if any) of the string should be formatted
|
||||||
|
with a gradient.
|
||||||
|
Removes the ``<gradient>`` tag, as it is not part of Pango's markup and would cause an error.
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
text = self.text_for_parsing
|
||||||
|
if not gradientmap:
|
||||||
|
gradientmap = list()
|
||||||
|
|
||||||
|
elements = ET.fromstring(text)
|
||||||
|
text_from_markup = self.get_text_from_markup()
|
||||||
|
final_xml = ET.fromstring(f'<span>{elements.text if elements.text else ""}</span>')
|
||||||
|
def get_gradient_map(elements):
|
||||||
|
for element in elements:
|
||||||
|
if element.tag == 'gradient':
|
||||||
|
element_text = self.get_text_from_markup(element)
|
||||||
|
start = text_from_markup.find(element_text)
|
||||||
|
end = start + len(element_text)
|
||||||
|
offsets = element.get('offset').split(",") if element.get('offset') else [0]
|
||||||
|
start_offset = int(offsets[0]) if offsets[0] else 0
|
||||||
|
end_offset = int(offsets[1]) if len(offsets) == 2 and offsets[1] else 0
|
||||||
|
gradientmap.append(
|
||||||
|
{
|
||||||
|
"start": start,
|
||||||
|
"end": end,
|
||||||
|
"from": element.get('from'),
|
||||||
|
"to": element.get('to'),
|
||||||
|
"start_offset": start_offset,
|
||||||
|
"end_offset": end_offset,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
_elements_list = list(element.iter())
|
||||||
|
if len(_elements_list) == 1:
|
||||||
|
final_xml.append(ET.fromstring(f'<span>{element.text if element.text else ""}</span>'))
|
||||||
|
else:
|
||||||
|
final_xml.append(_elements_list[-1])
|
||||||
|
else:
|
||||||
|
if len(list(element.iter())) == 1:
|
||||||
|
final_xml.append(element)
|
||||||
|
else:
|
||||||
|
get_gradient_map(element)
|
||||||
|
get_gradient_map(elements)
|
||||||
|
with io.BytesIO() as f:
|
||||||
|
tree = ET.ElementTree()
|
||||||
|
tree._setroot(final_xml)
|
||||||
|
tree.write(f)
|
||||||
|
self.text = f.getvalue().decode()
|
||||||
|
|
||||||
|
return gradientmap
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"MarkupText({repr(self.original_text)})"
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def register_font(font_file: typing.Union[str, Path]):
|
def register_font(font_file: typing.Union[str, Path]):
|
||||||
"""Temporarily add a font file to Pango's search path.
|
"""Temporarily add a font file to Pango's search path.
|
||||||
|
|
Loading…
Add table
Reference in a new issue