diff --git a/manim/constants.py b/manim/constants.py index ccf99a0293..444ec63b49 100644 --- a/manim/constants.py +++ b/manim/constants.py @@ -63,6 +63,9 @@ "DEFAULT_POINT_DENSITY_1D", "DEFAULT_STROKE_WIDTH", "DEFAULT_FONT_SIZE", + "DEFAULT_FONT_SIZE_IN_WORLD_SPACE", + "DEFAULT_FONTSIZE_IN_WORLD_SPACE", + "DEFAULT_FONT_SIZE_IN_WOLRD_SPACE", "SCALE_FACTOR_PER_FONT_POINT", "PI", "TAU", @@ -177,12 +180,25 @@ DEFAULT_POINTWISE_FUNCTION_RUN_TIME = 3.0 DEFAULT_WAIT_TIME = 1.0 +# Font calculation +DEFAULT_FONT_SIZE_IN_WORLD_SPACE = 0.5 +"""Length occupied by an 'EM' character in manim space, when manim default font size is used. +An example of an 'EM' character is the em dash: '—'. +The chosen value is an arbitrary convention.""" +DEFAULT_FONT_SIZE_IN_WOLRD_SPACE = DEFAULT_FONT_SIZE_IN_WORLD_SPACE +DEFAULT_FONTSIZE_IN_WORLD_SPACE = DEFAULT_FONT_SIZE_IN_WORLD_SPACE + # Misc DEFAULT_POINT_DENSITY_2D = 25 DEFAULT_POINT_DENSITY_1D = 10 DEFAULT_STROKE_WIDTH = 4 DEFAULT_FONT_SIZE = 48 -SCALE_FACTOR_PER_FONT_POINT = 1 / 960 +SCALE_FACTOR_PER_FONT_POINT = ( # legacy, not used in code anymore + DEFAULT_FONT_SIZE_IN_WORLD_SPACE + / 10 # LaTeX default font size in pixels + / DEFAULT_FONT_SIZE +) +"""Downscale factor from LaTeX pixel to world space, divided by DEFAULT_FONT_SIZE""" # Mathematical constants PI = np.pi diff --git a/manim/mobject/text/tex_mobject.py b/manim/mobject/text/tex_mobject.py index 729fbb158b..b9fd80fd2b 100644 --- a/manim/mobject/text/tex_mobject.py +++ b/manim/mobject/text/tex_mobject.py @@ -41,6 +41,12 @@ from ..opengl.opengl_compatibility import ConvertToOpenGL MATHTEX_SUBSTRING = "substring" +TEX_SVG_UNITS_PER_PT = 1 +"""Scale factor from TeX SVG output to point units. +TeX outputs 1 svg unit per point (72 DPI).""" +TEX_DEFAULT_FONT_SIZE_PT = 10 +"""The fontsize used by default by tex: 10pt. +This means that one 'EM' character like '—' will be 13.333 svg units, since 1pt=4/3 px""" class SingleStringMathTex(SVGMobject): @@ -70,7 +76,7 @@ def __init__( if color is None: color = VMobject().color - self._font_size = font_size + self.initial_font_size = font_size self.organize_left_to_right = organize_left_to_right self.tex_environment = tex_environment if tex_template is None: @@ -97,12 +103,18 @@ def __init__( ) self.init_colors() + if height is None: + self.scale( + font_size + / DEFAULT_FONT_SIZE + / TEX_DEFAULT_FONT_SIZE_PT # convert latex svg output to "fontsize" or "em" units + * DEFAULT_FONTSIZE_IN_WORLD_SPACE # then to worldspace + ) + # used for scaling via font_size.setter + # we use the initial size in world space self.initial_height = self.height - if height is None: - self.font_size = self._font_size - if self.organize_left_to_right: self._organize_submobjects_left_to_right() @@ -112,7 +124,7 @@ def __repr__(self) -> str: @property def font_size(self) -> float: """The font size of the tex mobject.""" - return self.height / self.initial_height / SCALE_FACTOR_PER_FONT_POINT + return self.height / self.initial_height * self.initial_font_size @font_size.setter def font_size(self, font_val: float) -> None: diff --git a/manim/mobject/text/text_mobject.py b/manim/mobject/text/text_mobject.py index d484420301..16263fc322 100644 --- a/manim/mobject/text/text_mobject.py +++ b/manim/mobject/text/text_mobject.py @@ -80,9 +80,13 @@ def construct(self): from manim.typing import Point3D -TEXT_MOB_SCALE_FACTOR = 0.05 DEFAULT_LINE_SPACING_SCALE = 0.3 -TEXT2SVG_ADJUSTMENT_FACTOR = 4.8 +PANGO_SVG_UNITS_PER_PT = 4 / 3 +"""Scale factor from Pango SVG output to point units. +Pango outputs 4/3 svg units per point (72 DPI).""" +TEXT_FONT_SIZE_PT = 10 +"""The font size we use to render the unscaled text in the SVG. +Note that the typical EM dash (—) will be 13.333 svg units, since 1pt = 4/3 px""" __all__ = ["Text", "Paragraph", "MarkupText", "register_font"] @@ -466,7 +470,7 @@ def __init__( else: logger.warning(f"Font {font} not in {fonts_list}.") self.font = font - self._font_size = float(font_size) + self.initial_font_size = float(font_size) # needs to be a float or else size is inflated when font_size = 24 # (unknown cause) self.slant = slant @@ -508,10 +512,13 @@ def __init__( self.text = text_without_tabs if self.line_spacing == -1: self.line_spacing = ( - self._font_size + self._font_size * DEFAULT_LINE_SPACING_SCALE + self.initial_font_size + + self.initial_font_size * DEFAULT_LINE_SPACING_SCALE ) else: - self.line_spacing = self._font_size + self._font_size * self.line_spacing + self.line_spacing = ( + self.initial_font_size + self.initial_font_size * self.line_spacing + ) parsed_color: ManimColor = ManimColor(color) if color else VMobject().color file_name = self._text2svg(parsed_color.to_hex()) @@ -590,7 +597,12 @@ def add_line_to(end: Point3D) -> None: each.points = np.array(closed_curve_points, ndmin=2) # anti-aliasing if height is None and width is None: - self.scale(TEXT_MOB_SCALE_FACTOR) + self.scale( + 1 + / PANGO_SVG_UNITS_PER_PT # convert svg output to "pt" units + / TEXT_FONT_SIZE_PT # then to "fontsize" or "EM" units + * DEFAULT_FONT_SIZE_IN_WORLD_SPACE # then to world space + ) self.initial_height = self.height def __repr__(self) -> str: @@ -598,14 +610,7 @@ def __repr__(self) -> str: @property def font_size(self) -> float: - return ( - self.height - / self.initial_height - / TEXT_MOB_SCALE_FACTOR - * 2.4 - * self._font_size - / DEFAULT_FONT_SIZE - ) + return self.height / self.initial_height * self.initial_font_size @font_size.setter def font_size(self, font_val: float) -> None: @@ -657,7 +662,7 @@ def _text2hash(self, color: ParsableManimColor) -> str: "PANGO" + self.font + self.slant + self.weight + str(color) ) # to differentiate Text and CairoText settings += str(self.t2f) + str(self.t2s) + str(self.t2w) + str(self.t2c) - settings += str(self.line_spacing) + str(self._font_size) + settings += str(self.line_spacing) + str(self.initial_font_size) settings += str(self.disable_ligatures) settings += str(self.gradient) id_str = self.text + settings @@ -798,10 +803,8 @@ def _text2settings(self, color: ParsableManimColor) -> list[TextSetting]: def _text2svg(self, color: ParsableManimColor) -> str: """Convert the text to SVG using Pango.""" - size = self._font_size - line_spacing = self.line_spacing - size /= TEXT2SVG_ADJUSTMENT_FACTOR - line_spacing /= TEXT2SVG_ADJUSTMENT_FACTOR + size = TEXT_FONT_SIZE_PT * self.initial_font_size / DEFAULT_FONT_SIZE + line_spacing = TEXT_FONT_SIZE_PT * self.line_spacing / DEFAULT_FONT_SIZE dir_name = config.get_dir("text_dir") dir_name.mkdir(parents=True, exist_ok=True) @@ -1181,7 +1184,7 @@ def __init__( else: logger.warning(f"Font {font} not in {fonts_list}.") self.font = font - self._font_size = float(font_size) + self.initial_font_size = float(font_size) self.slant = slant self.weight = weight self.gradient = gradient @@ -1206,10 +1209,13 @@ def __init__( if self.line_spacing == -1: self.line_spacing = ( - self._font_size + self._font_size * DEFAULT_LINE_SPACING_SCALE + self.initial_font_size + + self.initial_font_size * DEFAULT_LINE_SPACING_SCALE ) else: - self.line_spacing = self._font_size + self._font_size * self.line_spacing + self.line_spacing = ( + self.initial_font_size + self.initial_font_size * self.line_spacing + ) parsed_color: ManimColor = ManimColor(color) if color else VMobject().color file_name = self._text2svg(parsed_color) @@ -1302,20 +1308,18 @@ def add_line_to(end: Point3D) -> None: ) # anti-aliasing if height is None and width is None: - self.scale(TEXT_MOB_SCALE_FACTOR) + self.scale( + 1 + / PANGO_SVG_UNITS_PER_PT # convert svg output to "pt" units + / TEXT_FONT_SIZE_PT # then to "fontsize" or "EM" units + * DEFAULT_FONT_SIZE_IN_WORLD_SPACE # then to world space + ) self.initial_height = self.height @property def font_size(self) -> float: - return ( - self.height - / self.initial_height - / TEXT_MOB_SCALE_FACTOR - * 2.4 - * self._font_size - / DEFAULT_FONT_SIZE - ) + return self.height / self.initial_height * self.initial_font_size @font_size.setter def font_size(self, font_val: float) -> None: @@ -1334,7 +1338,7 @@ def _text2hash(self, color: ParsableManimColor) -> str: + self.weight + ManimColor(color).to_hex().lower() ) # to differentiate from classical Pango Text - settings += str(self.line_spacing) + str(self._font_size) + settings += str(self.line_spacing) + str(self.initial_font_size) settings += str(self.disable_ligatures) settings += str(self.justify) id_str = self.text + settings @@ -1345,10 +1349,9 @@ def _text2hash(self, color: ParsableManimColor) -> str: def _text2svg(self, color: ParsableManimColor | None) -> str: """Convert the text to SVG using Pango.""" color = ManimColor(color) - size = self._font_size - line_spacing: float = self.line_spacing - size /= TEXT2SVG_ADJUSTMENT_FACTOR - line_spacing /= TEXT2SVG_ADJUSTMENT_FACTOR + # scale down so that manim font size becomes a specific target size in pt for pango + size = TEXT_FONT_SIZE_PT * self.initial_font_size / DEFAULT_FONT_SIZE + line_spacing = TEXT_FONT_SIZE_PT * self.line_spacing / DEFAULT_FONT_SIZE dir_name = config.get_dir("text_dir") dir_name.mkdir(parents=True, exist_ok=True) diff --git a/tests/module/mobject/text/test_texmobject.py b/tests/module/mobject/text/test_texmobject.py index 48297abec6..5ecfb2c435 100644 --- a/tests/module/mobject/text/test_texmobject.py +++ b/tests/module/mobject/text/test_texmobject.py @@ -6,6 +6,7 @@ import pytest from manim import MathTex, SingleStringMathTex, Tex, TexTemplate, tempconfig +from manim.constants import DEFAULT_FONT_SIZE_IN_WORLD_SPACE def test_MathTex(config): @@ -256,6 +257,11 @@ def test_changing_font_size(): assert num.height == Tex("0", font_size=48).height +def test_mathtex_em_dash_width_default_font_size(): + em_dash = MathTex(r"\text{—}") + assert abs(em_dash.width - DEFAULT_FONT_SIZE_IN_WORLD_SPACE) < 0.005 + + def test_log_error_context(capsys): """Test that the environment context of an error is correctly logged if it exists""" invalid_tex = r""" diff --git a/tests/module/mobject/text/test_text_mobject.py b/tests/module/mobject/text/test_text_mobject.py index 2e656b90ae..d6b1725178 100644 --- a/tests/module/mobject/text/test_text_mobject.py +++ b/tests/module/mobject/text/test_text_mobject.py @@ -3,6 +3,7 @@ from contextlib import redirect_stdout from io import StringIO +from manim.constants import DEFAULT_FONT_SIZE_IN_WORLD_SPACE from manim.mobject.text.text_mobject import MarkupText, Text @@ -32,3 +33,17 @@ def warning_printed(font: str, **kwargs) -> bool: # check random string (should be warning) assert warning_printed("Manim!" * 3, warn_missing_font=True) + + +def test_em_dash_width_default_font_size(): + from manimpango import list_fonts + + available = list_fonts() + # Fonts that render em dash at full font size width + candidates = ["Arial", "Liberation Sans"] + + font = next((f for f in candidates if f in available), None) + assert font is not None, f"No suitable font found. Available: {available}" + + text_em_dash = Text("—", font=font) + assert abs(text_em_dash.width - DEFAULT_FONT_SIZE_IN_WORLD_SPACE) < 0.01