"""SVG renderer for E-SMILES captions.""" from __future__ import annotations import logging import math import random import re import xml.etree.ElementTree as ET from pathlib import Path from typing import Any, Dict, List, Literal, Optional, Tuple, Union from pydantic import BaseModel, Field from rdkit import Chem, RDLogger from rdkit.Chem.Draw import rdMolDraw2D from rdkit.Chem.rdchem import Mol from rdkit.Geometry import Point2D try: from . import chem_utils from .translator import ( AtomIndex, GroupDesc, Patterns, RingIndex, TextType, Tokens, ) except ImportError: # Support running from the package directory as working directory. import chem_utils from translator import ( AtomIndex, GroupDesc, Patterns, RingIndex, TextType, Tokens, ) __all__ = ["DrawingConfig", "draw"] logger = logging.getLogger(__name__) _SVG_NAMESPACE = "http://www.w3.org/2000/svg" # `[...]` is otherwise an open-ended textual annotation. Keep endpoint # balls behind an explicit allow-list so existing patent labels continue to be # rendered as text. Colours use RGB values in RDKit's 0..1 range. _ENDPOINT_BALL_STYLES: Dict[ str, Tuple[Tuple[float, float, float], Tuple[float, float, float]], ] = { "ball": ((1.00, 1.00, 1.00), (0.08, 0.08, 0.08)), "grey": ((0.78, 0.78, 0.78), (0.28, 0.28, 0.28)), "black": ((0.00, 0.00, 0.00), (0.00, 0.00, 0.00)), "green": ((0.62, 0.89, 0.76), (0.07, 0.53, 0.31)), "blue": ((0.66, 0.73, 0.92), (0.18, 0.30, 0.60)), "yellow": ((0.97, 0.85, 0.42), (0.66, 0.47, 0.00)), "purple": ((0.78, 0.65, 0.91), (0.44, 0.25, 0.63)), "orange": ((0.97, 0.75, 0.51), (0.78, 0.42, 0.09)), "pink": ((0.90, 0.65, 0.76), (0.61, 0.19, 0.36)), "brown": ((0.73, 0.55, 0.42), (0.42, 0.25, 0.15)), } _ENDPOINT_BALL_RADIUS = 0.43 class VisualConfig(BaseModel): # Do not change these defaults: callers and tracked figures rely on the # exact SVG emitted by the original drawer. padding: float = Field(default=0.1, ge=0.0, le=1.0) additionalAtomLabelPadding: float = Field(default=0.05, ge=0.0, le=0.5) fixedFontSize: int = Field(default=10, ge=1, le=100) # RDKit accepts an explicit TTF/OTF path through MolDrawOptions.fontFile. # Keep this optional so existing callers retain the built-in font exactly. fontFile: Optional[str] = Field(default=None) # Optional fail-closed escape hatch for unusually wide font glyphs in # virtual-arc annotations. None preserves the historical arc layout # byte-for-byte; controlled augmentation may request modest extra spacing. virtualArcLabelSpacingScale: Optional[float] = Field( default=None, ge=1.0, le=2.0 ) bondLineWidth: int = Field(default=1, ge=1, le=10) multipleBondOffset: float = Field(default=0.1, ge=0.01, le=1.0) dummiesAreAttachments: bool = Field(default=False) addAtomIndices: Union[bool, float] = Field(default=False) singleColourWedgeBonds: bool = Field(default=True) legendFontSize: int = Field(default=9, ge=1, le=50) class StylingConfig(BaseModel): palette: Union[Literal["cdk", "bw"], float] = Field(default="cdk") use_modern_symbols: bool = Field(default=True) enhanced_contrast: bool = Field(default=True) highlight_atoms: Union[bool, float] = Field(default=False) highlight_bonds: Union[bool, float] = Field(default=False) custom_colors: Union[bool, float] = Field(default=False) bold_r_groups: bool = Field(default=True) ring_connector_style: Literal["dashed", "solid"] = Field(default="solid") ring_connector_extension: float = Field(default=0.8, ge=0.0, le=5.0) dummy_line_style: Literal["wavy", "dashed", "dotted", "solid"] = Field(default="wavy") show_default_sru_count: bool = Field(default=True) # Local E-SMILES 2.0 repeats may use either publication-style # parentheses or square brackets. The caller resolves the style for a # record; the drawer itself remains deterministic. Whole-SRU rendering # deliberately ignores this option and stays round for compatibility. sgroup_bracket_style: Literal["round", "square"] = Field(default="round") class FeaturesConfig(BaseModel): dummy_atoms: bool = Field(default=True) circled_r_groups: bool = Field(default=True) ring_annotations: bool = Field(default=True) # None selects syntax-aware compatibility mode: new constructs render # academically while already-supported 1.0 captions keep the legacy path. # Explicit False disables an overlay and True forces its new implementation. repeat_units: Optional[bool] = Field(default=None) academic_virtual_arcs: Optional[bool] = Field(default=None) endpoint_balls: Optional[bool] = Field(default=None) class DrawingConfig(BaseModel): visual: VisualConfig = Field(default_factory=VisualConfig) styling: StylingConfig = Field(default_factory=StylingConfig) features: FeaturesConfig = Field(default_factory=FeaturesConfig) @classmethod def from_dict(cls, config_dict: Optional[Dict[str, Any]] = None) -> "DrawingConfig": if config_dict is None: return cls() merged_dict: Dict[str, Dict] = {"visual": {}, "styling": {}, "features": {}} for section in ("visual", "styling", "features"): if section in config_dict and isinstance(config_dict[section], dict): merged_dict[section] = config_dict[section] return cls(**merged_dict) def _validate_config(config: Optional[Dict[str, Any]] = None) -> DrawingConfig: return DrawingConfig.from_dict(config) def _resolve_probability(value: Union[bool, float]) -> bool: if isinstance(value, bool): return value prob = min(max(value, 0.0), 1.0) return random.random() < prob def _random_color() -> Tuple[float, float, float]: return ( random.random() / 2 + 0.5, random.random() / 2 + 0.5, random.random() / 2 + 0.5, ) def _update_bounds(bounds: Optional[List[float]], x: float, y: float) -> List[float]: if bounds is None: return [x, y, x, y] bounds[0] = min(bounds[0], x) bounds[1] = min(bounds[1], y) bounds[2] = max(bounds[2], x) bounds[3] = max(bounds[3], y) return bounds def _estimate_text_bounds( label_text: str, x: float, y: float, font_px: float, margin: float, strip_markup: bool = True, left_aligned: bool = False, ) -> Tuple[float, float, float, float]: visible = re.sub(r"<[^>]+>", "", label_text) if strip_markup else label_text text_len = max(len(visible), 1) if left_aligned: # RDKit's DrawString(..., orient=1) places fallback annotation text to # the right of its anchor. A one-sided estimate avoids doubling the # canvas width while retaining a conservative glyph-width allowance. text_w = font_px * 0.45 * text_len + 2 * margin else: text_w = font_px * 0.6 * text_len + 2 * margin text_h = font_px + 2 * margin if left_aligned: return x - margin, y - text_h, x + text_w, y + text_h return x - text_w, y - text_h, x + text_w, y + text_h def _format_symbol_with_subscripts(symbol: str) -> str: if "<" in symbol and ">" in symbol: return symbol return re.sub(r"(\d+)", r"\1", symbol) def _ring_annotation_screen_slots(count: int) -> List[Tuple[float, float]]: """Return stable screen-space slots for regio-uncertain ring labels.""" if count <= 0: return [] diagonal = math.sqrt(0.5) ordered = [ (-1.0, 0.0), (1.0, 0.0), (0.0, -1.0), (0.0, 1.0), (-diagonal, -diagonal), (diagonal, -diagonal), (-diagonal, diagonal), (diagonal, diagonal), ] if count == 1: return [(0.0, -1.0)] if count <= len(ordered): return ordered[:count] return [ (math.cos(2.0 * math.pi * idx / count), math.sin(2.0 * math.pi * idx / count)) for idx in range(count) ] def _format_group_label(desc: GroupDesc) -> str: if desc.symbol == Tokens.special_id and desc.script: return desc.script label = desc.symbol or "" if desc.script is not None: label += f"{desc.script}" if desc.prime is not None: label += desc.prime if desc.multiple is not None: label = "(" + label + ")" + f"{desc.multiple}" return label def _endpoint_ball_token(desc: GroupDesc) -> Optional[str]: """Return the approved ball token encoded by an atom-group description.""" if ( desc.symbol != Tokens.special_id or desc.script not in _ENDPOINT_BALL_STYLES or desc.prime is not None or desc.multiple is not None or desc.is_circle or desc.is_dummy ): return None return desc.script def _draw_endpoint_ball( drawer, center: Point2D, token: str, line_width: float, ) -> None: """Draw one deterministic flat ball from the explicit allow-list.""" fill_color, outline_color = _ENDPOINT_BALL_STYLES[token] radius = _ENDPOINT_BALL_RADIUS lower_left = Point2D(center.x - radius, center.y - radius) upper_right = Point2D(center.x + radius, center.y + radius) # Painting after the molecule covers the bond underneath the disc, so the # visible bond naturally terminates at the ball boundary. drawer.SetFillPolys(True) drawer.SetColour(fill_color) drawer.DrawEllipse(lower_left, upper_right) drawer.SetFillPolys(False) drawer.SetColour(outline_color) drawer.SetLineWidth(line_width) drawer.DrawEllipse(lower_left, upper_right) def _truncate_annotation(text: str, limit: int = 64) -> str: compact = re.sub(r"\s+", " ", text).strip() return compact if len(compact) <= limit else compact[: limit - 3] + "..." def _precompat_annotations( groups: str, rendered_sgroups: Optional[set[str]] = None, ) -> List[str]: if rendered_sgroups is None: # Preserve the original fallback text path exactly when academic # repeat overlays are not requested. annotations: List[str] = [] for match in re.finditer(r"(.*?)", groups, re.DOTALL): annotations.append( f"substructure: {_truncate_annotation(match.group(1))}" ) for match in re.finditer(r"(.*?)", groups, re.DOTALL): annotations.append(f"s-group: {_truncate_annotation(match.group(1))}") return annotations annotations: List[str] = [] for match in re.finditer(r"(.*?)", groups, re.DOTALL): annotations.append(f"substructure: {_truncate_annotation(match.group(1))}") rendered = rendered_sgroups or set() outer_groups = re.sub(r".*?", "", groups, flags=re.DOTALL) for match in re.finditer(r"(.*?)", outer_groups, re.DOTALL): body = match.group(1) if body not in rendered: annotations.append(f"s-group: {_truncate_annotation(body)}") return annotations def _sgroup_records(groups: str) -> List[Tuple[str, List[Tuple[int, int]], str]]: """Parse pre-compatible S-group repeat records for depiction. The first index in each pair is the atom inside the repeated fragment and the second is the atom outside it. The parser is intentionally strict: malformed records remain available to the fallback annotation renderer. """ records: List[Tuple[str, List[Tuple[int, int]], str]] = [] outer_groups = re.sub(r".*?", "", groups, flags=re.DOTALL) for match in re.finditer(r"(.*?)", outer_groups, re.DOTALL): body = match.group(1) repeat = re.search(r"\|Sg:([^|]+)\|", body) if repeat is None: continue prefix = body[: repeat.start()] suffix = body[repeat.end() :] ports = [ (int(inner), int(outer)) for inner, outer in re.findall(r"\[(\d+):(\d+)\]", prefix) ] leftover = re.sub(r"\[\d+:\d+\]", "", prefix).replace(":", "").strip() if ( not ports or len(ports) != len(set(ports)) or leftover or suffix.strip() ): continue count = repeat.group(1).strip() if not re.fullmatch(r"[A-Za-z0-9]+(?:-[A-Za-z0-9]+)?", count): continue records.append((body, ports, count)) return records def _single_ch2_repeat_records( mol: Mol, descriptions: List[GroupDesc], ) -> List[Tuple[int, List[Tuple[int, int]], str]]: """Return valid inline ``ATOM:CH2?COUNT`` repeat records. The source atom remains a degree-two dummy in canonical E-SMILES. For depiction it acts as an unlabeled carbon-chain vertex, while the two incident single bonds define the repeat boundaries. Keeping this recognition deliberately narrow prevents ordinary atom abbreviations from acquiring polymer parentheses. """ records: List[Tuple[int, List[Tuple[int, int]], str]] = [] seen: set[int] = set() atom_description_counts: Dict[int, int] = {} for desc in descriptions: if isinstance(desc.id, AtomIndex): atom_idx = int(desc.id) atom_description_counts[atom_idx] = ( atom_description_counts.get(atom_idx, 0) + 1 ) for desc in descriptions: if ( not isinstance(desc.id, AtomIndex) or desc.is_dummy or desc.multiple is None or desc.script is not None or desc.prime is not None ): continue atom_idx = int(desc.id) if atom_description_counts.get(atom_idx) != 1: continue visible_symbol = re.sub(r"<[^>]+>", "", desc.symbol or "") if visible_symbol != "CH2" or atom_idx in seen: continue if not 0 <= atom_idx < mol.GetNumAtoms(): continue atom = mol.GetAtomWithIdx(atom_idx) bonds = list(atom.GetBonds()) if ( atom.GetSymbol() != "*" or atom.GetDegree() != 2 or len(bonds) != 2 or any( bond.GetBondType() != Chem.BondType.SINGLE or bond.GetIsAromatic() or bond.IsInRing() for bond in bonds ) ): continue ports = sorted( (atom_idx, bond.GetOtherAtomIdx(atom_idx)) for bond in bonds ) if len({outer for _inner, outer in ports}) != 2: continue seen.add(atom_idx) records.append((atom_idx, ports, desc.multiple)) return records def _sgroup_repeat_atoms( mol: Mol, ports: List[Tuple[int, int]], ) -> Optional[List[int]]: """Infer the repeated atom component after removing its boundary bonds.""" if not ports: return None atom_count = mol.GetNumAtoms() if len(ports) != len(set(ports)): return None removed: set[Tuple[int, int]] = set() inner_atoms: set[int] = set() outer_atoms: set[int] = set() for inner, outer in ports: if ( inner == outer or not 0 <= inner < atom_count or not 0 <= outer < atom_count or mol.GetBondBetweenAtoms(inner, outer) is None ): return None inner_atoms.add(inner) outer_atoms.add(outer) removed.add(tuple(sorted((inner, outer)))) component: set[int] = set() pending = [next(iter(inner_atoms))] while pending: atom_idx = pending.pop() if atom_idx in component: continue component.add(atom_idx) atom = mol.GetAtomWithIdx(atom_idx) for bond in atom.GetBonds(): other_idx = bond.GetOtherAtomIdx(atom_idx) if tuple(sorted((atom_idx, other_idx))) in removed: continue if other_idx not in component: pending.append(other_idx) if not inner_atoms.issubset(component) or component & outer_atoms: return None if len(component) == atom_count: return None return sorted(component) def _whole_sru_endpoints(mol: Mol, groups: str) -> Optional[List[int]]: """Return the two validated terminal dummy atoms of a whole SRU. E-SMILES 2.0 identifies the endpoints explicitly with two ```` records. The original MolParser figure set also contains a legacy SRU example with only two terminal ``*`` atoms and a top-level ``|Sg:n|``. Keep that unambiguous form drawable, but never guess when the molecule contains any other dummy atom or a partial/malformed ```` annotation. """ top_level_groups = re.sub( r".*?|.*?|.*?", "", groups, flags=re.DOTALL, ) matches = re.findall(r"(\d+):", top_level_groups) if matches: if len(matches) != 2 or len(set(matches)) != 2: return None dummy_indices = {int(value) for value in matches} else: # Legacy, pre-2.0 fallback. Requiring exactly two dummy atoms in the # entire graph avoids interpreting ordinary Markush placeholders as # polymer endpoints. dummy_indices = { atom.GetIdx() for atom in mol.GetAtoms() if atom.GetAtomicNum() == 0 } if len(dummy_indices) != 2: return None for atom_idx in dummy_indices: if not 0 <= atom_idx < mol.GetNumAtoms(): return None atom = mol.GetAtomWithIdx(atom_idx) bonds = list(atom.GetBonds()) if ( atom.GetSymbol() != "*" or atom.GetDegree() != 1 or len(bonds) != 1 or bonds[0].GetBondType() != Chem.BondType.SINGLE or bonds[0].IsInRing() ): return None return sorted(dummy_indices) def _whole_sru_atoms(mol: Mol, groups: str) -> Optional[List[int]]: """Return the connected core atoms for a well-formed two-ended whole SRU.""" endpoint_indices = _whole_sru_endpoints(mol, groups) if endpoint_indices is None: return None dummy_indices = set(endpoint_indices) core = [ atom.GetIdx() for atom in mol.GetAtoms() if atom.GetIdx() not in dummy_indices ] if not core: return None allowed = set(core) seen: set[int] = set() pending = [core[0]] while pending: atom_idx = pending.pop() if atom_idx in seen: continue seen.add(atom_idx) for bond in mol.GetAtomWithIdx(atom_idx).GetBonds(): other_idx = bond.GetOtherAtomIdx(atom_idx) if other_idx in allowed and other_idx not in seen: pending.append(other_idx) return core if seen == allowed else None def _whole_sru_ports(mol: Mol, groups: str) -> Optional[List[Tuple[int, int]]]: """Return inner-core/outer-dummy boundary pairs for a valid whole SRU.""" endpoints = _whole_sru_endpoints(mol, groups) if endpoints is None or _whole_sru_atoms(mol, groups) is None: return None ports: List[Tuple[int, int]] = [] for dummy_idx in endpoints: atom = mol.GetAtomWithIdx(dummy_idx) neighbor = next(iter(atom.GetNeighbors())) ports.append((neighbor.GetIdx(), dummy_idx)) return ports def _sru_count(extension: str) -> Optional[str]: match = re.fullmatch( r"\|Sg:([A-Za-z0-9]+(?:-[A-Za-z0-9]+)?)\|", extension, ) if match is None: return None return match.group(1) def _draw_repeat_count( drawer, count: str, label_point: Point2D, font_size: float, bounds: Optional[List[float]], ) -> Optional[List[float]]: """Draw a compact lower-right repeat subscript without leaking font state.""" # Academic polymer subscripts are visibly smaller than atom labels. A # slightly smaller scale is especially important for multi-digit counts. scale = 0.72 if len(count) > 1 else 0.75 subscript_font = max(8.0, font_size * scale) previous_font = drawer.FontSize() if hasattr(drawer, "FontSize") else None if hasattr(drawer, "SetFontSize"): drawer.SetFontSize(subscript_font) try: drawer.DrawString(count, label_point, 1, True) finally: if previous_font is not None and hasattr(drawer, "SetFontSize"): drawer.SetFontSize(previous_font) label_bounds = _estimate_text_bounds( count, label_point.x, label_point.y, font_px=subscript_font, margin=4.0, ) bounds = _update_bounds(bounds, label_bounds[0], label_bounds[1]) bounds = _update_bounds(bounds, label_bounds[2], label_bounds[3]) return bounds def _parenthesis_curve( midpoint: Point2D, axis_x: float, axis_y: float, opening_x: float, opening_y: float, half_height: float, ) -> Tuple[Point2D, Point2D, Point2D, Point2D]: """Build the shared slim parenthesis used by whole and nested repeats.""" endpoint_inset = min(max(half_height * 0.06, 0.03), 0.07) outward_bulge = min(max(half_height * 0.18, 0.07), 0.16) control_height = half_height * 0.43 return ( Point2D( midpoint.x + axis_x * half_height + opening_x * endpoint_inset, midpoint.y + axis_y * half_height + opening_y * endpoint_inset, ), Point2D( midpoint.x + axis_x * control_height - opening_x * outward_bulge, midpoint.y + axis_y * control_height - opening_y * outward_bulge, ), Point2D( midpoint.x - axis_x * control_height - opening_x * outward_bulge, midpoint.y - axis_y * control_height - opening_y * outward_bulge, ), Point2D( midpoint.x - axis_x * half_height + opening_x * endpoint_inset, midpoint.y - axis_y * half_height + opening_y * endpoint_inset, ), ) def _snap_paired_bracket_axes( axes: List[Tuple[float, float]], threshold_degrees: float = 45.0, ) -> List[Tuple[float, float]]: """Snap a more-vertical-than-horizontal pair to parallel vertical axes. Snapping is deliberately all-or-nothing: a single oblique boundary keeps both parentheses perpendicular to their own bonds. This avoids mixing a typographic vertical bracket with a chemically aligned oblique partner. The threshold is inclusive; at the default 45 degrees, ``abs(axis_y) >= abs(axis_x)`` is the exact eligibility rule for each side. """ if len(axes) != 2: return list(axes) if math.isclose(threshold_degrees, 45.0, abs_tol=1e-12): eligible = all( abs(axis_y) + 1e-12 >= abs(axis_x) for axis_x, axis_y in axes ) else: eligible = all( math.degrees(math.atan2(abs(axis_x), abs(axis_y))) <= threshold_degrees + 1e-12 for axis_x, axis_y in axes ) if not eligible: return list(axes) return [ (0.0, 1.0 if axis_y >= 0.0 else -1.0) for _axis_x, axis_y in axes ] def _draw_sgroup_boundary_parentheses( drawer, mol: Mol, ports: List[Tuple[int, int]], count: str, line_width: float, font_size: float, bounds: Optional[List[float]], show_count: bool = True, bracket_style: Literal["round", "square"] = "round", ) -> Optional[List[float]]: """Draw an inward-opening bracket across each S-group boundary bond.""" conf = mol.GetConformer() draw_points: List[Point2D] = [] draw_curves: List[List[Point2D]] = [] drawer.SetColour((0.05, 0.05, 0.05)) drawer.SetLineWidth(line_width) layouts = [] for inner_idx, outer_idx in ports: inner = conf.GetAtomPosition(inner_idx) outer = conf.GetAtomPosition(outer_idx) inner_dir_x = inner.x - outer.x inner_dir_y = inner.y - outer.y length = math.hypot(inner_dir_x, inner_dir_y) if length <= 1e-6: continue inner_dir_x /= length inner_dir_y /= length perp_x = -inner_dir_y perp_y = inner_dir_x midpoint = Point2D((inner.x + outer.x) / 2.0, (inner.y + outer.y) / 2.0) layouts.append( [midpoint, inner_dir_x, inner_dir_y, perp_x, perp_y] ) snapped_axes = _snap_paired_bracket_axes( [(layout[3], layout[4]) for layout in layouts] ) for layout, (perp_x, perp_y) in zip(layouts, snapped_axes): midpoint, inner_dir_x, inner_dir_y, _old_perp_x, _old_perp_y = layout if bracket_style == "square": half_height = 0.52 cap_length = 0.19 upper = Point2D( midpoint.x + perp_x * half_height, midpoint.y + perp_y * half_height, ) lower = Point2D( midpoint.x - perp_x * half_height, midpoint.y - perp_y * half_height, ) upper_inner = Point2D( upper.x + inner_dir_x * cap_length, upper.y + inner_dir_y * cap_length, ) lower_inner = Point2D( lower.x + inner_dir_x * cap_length, lower.y + inner_dir_y * cap_length, ) drawer.DrawLine(upper, lower) drawer.DrawLine(upper, upper_inner) drawer.DrawLine(lower, lower_inner) curve_points = [upper, lower, upper_inner, lower_inner] else: upper, control_upper, control_lower, lower = _parenthesis_curve( midpoint, perp_x, perp_y, inner_dir_x, inner_dir_y, 0.52, ) curve_points = _draw_cubic_curve( drawer, upper, control_upper, control_lower, lower, segments=16, ) curve_draw_points: List[Point2D] = [] for point in curve_points: draw_point = drawer.GetDrawCoords(point) draw_points.append(draw_point) curve_draw_points.append(draw_point) bounds = _update_bounds(bounds, draw_point.x, draw_point.y) draw_curves.append(curve_draw_points) if not draw_points: return bounds if not show_count: return bounds right_curve = max( draw_curves, key=lambda points: max(point.x for point in points), ) label_point = Point2D( max(point.x for point in right_curve) + 3.0, max(point.y for point in right_curve) + font_size * 0.32, ) bounds = _draw_repeat_count( drawer, count, label_point, font_size, bounds, ) return bounds def _virtual_arcs(groups: str) -> List[Tuple[int, str, int, int]]: arcs: List[Tuple[int, str, int, int]] = [] seen_indices: set[int] = set() groups = re.sub(r".*?", "", groups, flags=re.DOTALL) pattern = r"(?P\d+):(?P[^:]*):\[(?P\d+):(?P\d+)\]" for match in re.finditer(pattern, groups, re.DOTALL): arc_idx = int(match.group("idx")) if arc_idx in seen_indices or len(arcs) >= 64: continue seen_indices.add(arc_idx) arcs.append( ( arc_idx, match.group("name"), int(match.group("start")), int(match.group("end")), ) ) return arcs def _virtual_arc_substituents(groups: str) -> Dict[int, List[str]]: substituents: Dict[int, List[str]] = {} groups = re.sub(r".*?", "", groups, flags=re.DOTALL) pattern = r"(?P\d+):(?P" for match in re.finditer(pattern, groups, re.DOTALL): idx = int(match.group("idx")) label = match.group("label") label_match = re.match( r"(?P[^\[\?\'\"]+)" r"(?P