diff --git a/manim/mobject/text/typst_mobject.py b/manim/mobject/text/typst_mobject.py index b2e5aeb793..3220d6548e 100644 --- a/manim/mobject/text/typst_mobject.py +++ b/manim/mobject/text/typst_mobject.py @@ -123,6 +123,7 @@ def construct(self): ] import re +from collections.abc import Iterator from pathlib import Path from typing import Any, Self, cast from xml.etree import ElementTree as ET @@ -137,8 +138,6 @@ def construct(self): from manim.utils.color import BLACK, ParsableManimColor from manim.utils.typst_file_writing import typst_to_svg_file -_MANIMGRP_PREAMBLE = "#let manimgrp(lbl, body) = [#box(body) #label(lbl)]" - # Pattern for the label part of {{ content : label }}. # The label must be a valid Typst label identifier. _LABEL_RE = re.compile(r"^(.*)\s*:\s*([a-zA-Z_][a-zA-Z0-9_-]*)\s*$", re.DOTALL) @@ -148,6 +147,128 @@ def construct(self): # underlines, etc.) visually match the weight of TeX-derived geometry more # closely after import into Manim's pixel-based stroke model. _TYPST_SVG_STROKE_WIDTH_SCALE = 0.5 +_SVG_LEAF_TAGS = { + "circle", + "ellipse", + "image", + "line", + "path", + "polygon", + "polyline", + "rect", + "text", + "use", +} + + +def _manimgrp_preamble(target: str | None) -> str: + """Return a layout-transparent Typst capture helper. + + The final render uses ``none`` as the target and therefore returns every + group body unchanged. Probe renders target one label at a time and use + Typst's layout-preserving ``hide`` element to remove just that group's SVG + leaves. + """ + target_value = "none" if target is None else f'"{target}"' + return ( + f"#let manimgrp(lbl, body) = if lbl == {target_value} " + "{ hide(body) } else { body }" + ) + + +def _svg_tag_name(element: ET.Element) -> str: + """Return an XML element's local tag name.""" + if not isinstance(element.tag, str): + return "" + return element.tag.rsplit("}", 1)[-1] + + +def _iter_svg_leaves( + parent: ET.Element, + transform: se.Matrix | None = None, + inside_defs: bool = False, +) -> Iterator[tuple[ET.Element, int, ET.Element, se.Matrix]]: + """Yield rendered SVG leaves with their parent and effective transform.""" + if transform is None: + transform = se.Matrix() + + for index, element in enumerate(parent): + tag = _svg_tag_name(element) + element_inside_defs = inside_defs or tag == "defs" + local_transform = ( + se.Matrix(element.get("transform")) + if element.get("transform") is not None + else se.Matrix() + ) + effective_transform = transform * local_transform + + if not element_inside_defs and tag in _SVG_LEAF_TAGS: + yield parent, index, element, effective_transform + + yield from _iter_svg_leaves( + element, + effective_transform, + element_inside_defs, + ) + + +def _svg_leaf_signatures(svg_file: Path) -> list[tuple[Any, ...]]: + """Return stable signatures for rendered SVG leaves in drawing order.""" + root = ET.parse(svg_file).getroot() + signatures: list[tuple[Any, ...]] = [] + + for _, _, element, transform in _iter_svg_leaves(root): + attributes = tuple( + sorted( + (key, value) + for key, value in element.attrib.items() + if key != "transform" + ) + ) + matrix = tuple( + round(value, 12) + for value in ( + transform.a, + transform.b, + transform.c, + transform.d, + transform.e, + transform.f, + ) + ) + text = tuple(element.itertext()) + signatures.append((_svg_tag_name(element), attributes, text, matrix)) + + return signatures + + +def _hidden_leaf_indices( + visible: list[tuple[Any, ...]], + probe: list[tuple[Any, ...]], +) -> set[int]: + """Return visible leaf indices removed from a layout-preserving probe. + + A valid ``hide`` probe is the final SVG leaf sequence with zero or more + entries deleted. Matching the probe as a subsequence handles repeated + glyphs by retaining their drawing order and effective positions. + """ + hidden: set[int] = set() + probe_index = 0 + + for visible_index, signature in enumerate(visible): + if probe_index < len(probe) and signature == probe[probe_index]: + probe_index += 1 + else: + hidden.add(visible_index) + + if probe_index != len(probe): + raise ValueError( + "The MathTypst grouping probe changed visible SVG geometry instead of " + "only hiding captured leaves. A custom Typst show rule for `hide` may " + "be interfering with subexpression selection." + ) + + return hidden class Typst(SVGMobject): @@ -240,6 +361,11 @@ def __init__( self._baseline_tracked_submobjects: list[VMobject] = [] self._stroke_width_tracked_submobjects: list[VMobject] = [] self._label_aliases: dict[str, list[str]] = {} + self._svg_leaf_labels: dict[int, list[str]] = getattr( + self, + "_svg_leaf_labels", + {}, + ) file_name = typst_to_svg_file( typst_code, @@ -499,36 +625,56 @@ def _select_label(self, label: str) -> VGroup: # -- SVG post-processing ------------------------------------------------- def modify_xml_tree(self, element_tree: ET.ElementTree) -> ET.ElementTree: - """Convert ``data-typst-label`` attributes to ``id`` before parsing. - - Typst's SVG renderer emits ``data-typst-label`` on ```` elements - that carry a label (created via ``#box(body)