Spaces:
Running on Zero
Running on Zero
Download molparser/utils/drawer.py from hugging-apps/molparser-mobile-v2: direct link, hf CLI and curl.
- Browser
- Download file 90.3 kB
-
https://huggingface.co/spaces/hugging-apps/molparser-mobile-v2/resolve/main/molparser/utils/drawer.py
- Command line
-
hf download hf://spaces/hugging-apps/molparser-mobile-v2/molparser/utils/drawer.py
-
curl -L -o drawer.py https://huggingface.co/spaces/hugging-apps/molparser-mobile-v2/resolve/main/molparser/utils/drawer.py
90.3 kB
| """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" | |
| # `<id>[...]` 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 <g> 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) | |
| 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"<sub>\1</sub>", 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"<sub>{desc.script}</sub>" | |
| if desc.prime is not None: | |
| label += desc.prime | |
| if desc.multiple is not None: | |
| label = "(" + label + ")" + f"<sub>{desc.multiple}</sub>" | |
| 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"<s>(.*?)</s>", groups, re.DOTALL): | |
| annotations.append( | |
| f"substructure: {_truncate_annotation(match.group(1))}" | |
| ) | |
| for match in re.finditer(r"<g>(.*?)</g>", groups, re.DOTALL): | |
| annotations.append(f"s-group: {_truncate_annotation(match.group(1))}") | |
| return annotations | |
| annotations: List[str] = [] | |
| for match in re.finditer(r"<s>(.*?)</s>", groups, re.DOTALL): | |
| annotations.append(f"substructure: {_truncate_annotation(match.group(1))}") | |
| rendered = rendered_sgroups or set() | |
| outer_groups = re.sub(r"<s>.*?</s>", "", groups, flags=re.DOTALL) | |
| for match in re.finditer(r"<g>(.*?)</g>", 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"<s>.*?</s>", "", groups, flags=re.DOTALL) | |
| for match in re.finditer(r"<g>(.*?)</g>", 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 ``<a>ATOM:CH2?COUNT</a>`` 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 ``<d>`` | |
| 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 ``<d>`` annotation. | |
| """ | |
| top_level_groups = re.sub( | |
| r"<s>.*?</s>|<g>.*?</g>|<v>.*?</v>", | |
| "", | |
| groups, | |
| flags=re.DOTALL, | |
| ) | |
| matches = re.findall(r"<d>(\d+):<dum></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"<s>.*?</s>", "", groups, flags=re.DOTALL) | |
| pattern = r"<v>(?P<idx>\d+):(?P<name>[^:]*):\[(?P<start>\d+):(?P<end>\d+)\]</v>" | |
| 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"<s>.*?</s>", "", groups, flags=re.DOTALL) | |
| pattern = r"<r><v>(?P<idx>\d+):(?P<label>.+?)</r>" | |
| for match in re.finditer(pattern, groups, re.DOTALL): | |
| idx = int(match.group("idx")) | |
| label = match.group("label") | |
| label_match = re.match( | |
| r"(?P<symbol>[^\[\?\'\"]+)" | |
| r"(?P<script>\[[^\]]+\])?" | |
| r"(?P<prime>[\'\"]?)" | |
| r"(?P<multiple>\?(?:[a-z]|\d+|\d+-\d+)?)?$", | |
| label, | |
| ) | |
| if label_match: | |
| symbol = label_match.group("symbol") or "" | |
| script = label_match.group("script") | |
| prime = label_match.group("prime") or "" | |
| multiple = label_match.group("multiple") or "" | |
| if script: | |
| symbol += f"<sub>{script[1:-1]}</sub>" | |
| label = symbol + prime | |
| if multiple: | |
| label = f"({label})<sub>{multiple[1:]}</sub>" | |
| labels = substituents.setdefault(idx, []) | |
| if len(labels) < 16: | |
| labels.append(label) | |
| return substituents | |
| def _path_centroid( | |
| mol: Mol, | |
| conf, | |
| start_idx: int, | |
| end_idx: int, | |
| ) -> Tuple[float, float]: | |
| try: | |
| path = Chem.rdmolops.GetShortestPath(mol, start_idx, end_idx) | |
| except Exception: | |
| path = (start_idx, end_idx) | |
| if not path: | |
| path = (start_idx, end_idx) | |
| x = sum(conf.GetAtomPosition(idx).x for idx in path) / len(path) | |
| y = sum(conf.GetAtomPosition(idx).y for idx in path) / len(path) | |
| return x, y | |
| def _virtual_arc_control_point( | |
| mol: Mol, | |
| conf, | |
| start_idx: int, | |
| end_idx: int, | |
| curvature: float = 0.55, | |
| ) -> Tuple[float, float]: | |
| start_pos = conf.GetAtomPosition(start_idx) | |
| end_pos = conf.GetAtomPosition(end_idx) | |
| mid_x = (start_pos.x + end_pos.x) / 2.0 | |
| mid_y = (start_pos.y + end_pos.y) / 2.0 | |
| dx = end_pos.x - start_pos.x | |
| dy = end_pos.y - start_pos.y | |
| length = math.hypot(dx, dy) | |
| if length <= 1e-6: | |
| return mid_x, mid_y | |
| perp_x = -dy / length | |
| perp_y = dx / length | |
| centroid_x, centroid_y = _path_centroid(mol, conf, start_idx, end_idx) | |
| away_x = mid_x - centroid_x | |
| away_y = mid_y - centroid_y | |
| if away_x * perp_x + away_y * perp_y < 0: | |
| perp_x = -perp_x | |
| perp_y = -perp_y | |
| return mid_x + perp_x * length * curvature, mid_y + perp_y * length * curvature | |
| def _quadratic_bezier_point( | |
| start: Point2D, | |
| control: Point2D, | |
| end: Point2D, | |
| t: float, | |
| ) -> Point2D: | |
| one_minus_t = 1.0 - t | |
| x = one_minus_t * one_minus_t * start.x + 2 * one_minus_t * t * control.x + t * t * end.x | |
| y = one_minus_t * one_minus_t * start.y + 2 * one_minus_t * t * control.y + t * t * end.y | |
| return Point2D(x, y) | |
| def _draw_quadratic_arc( | |
| drawer, | |
| start: Point2D, | |
| control: Point2D, | |
| end: Point2D, | |
| segments: int = 18, | |
| ) -> None: | |
| prev = start | |
| for step in range(1, segments + 1): | |
| t = step / segments | |
| current = _quadratic_bezier_point(start, control, end, t) | |
| drawer.DrawLine(prev, current) | |
| prev = current | |
| def _draw_virtual_arcs_legacy( | |
| drawer, | |
| mol: Mol, | |
| virtual_arcs: List[Tuple[int, str, int, int]], | |
| arc_substituents: Dict[int, List[str]], | |
| drawing_config: DrawingConfig, | |
| bounds: Optional[List[float]], | |
| ) -> Optional[List[float]]: | |
| """Run the original named-virtualArc path without changing its SVG.""" | |
| conf = mol.GetConformer() | |
| drawer.SetLineWidth(1) | |
| drawer.SetColour((0.1, 0.1, 0.1)) | |
| for arc_idx, arc_name, start_idx, end_idx in virtual_arcs: | |
| if not ( | |
| 0 <= start_idx < mol.GetNumAtoms() | |
| and 0 <= end_idx < mol.GetNumAtoms() | |
| ): | |
| continue | |
| try: | |
| start_pos = conf.GetAtomPosition(start_idx) | |
| end_pos = conf.GetAtomPosition(end_idx) | |
| start_pt = Point2D(start_pos.x, start_pos.y) | |
| end_pt = Point2D(end_pos.x, end_pos.y) | |
| ctrl_x, ctrl_y = _virtual_arc_control_point( | |
| mol, | |
| conf, | |
| start_idx, | |
| end_idx, | |
| ) | |
| ctrl_pt = Point2D(ctrl_x, ctrl_y) | |
| _draw_quadratic_arc(drawer, start_pt, ctrl_pt, end_pt) | |
| mid_x = (start_pos.x + end_pos.x) / 2.0 | |
| mid_y = (start_pos.y + end_pos.y) / 2.0 | |
| label_x = ctrl_x * 0.12 + mid_x * 0.88 | |
| label_y = ctrl_y * 0.12 + mid_y * 0.88 | |
| label_text = arc_name | |
| label_pt = Point2D(label_x, label_y) | |
| if label_text: | |
| drawer.DrawString(label_text, label_pt, 1) | |
| layout_points = [start_pt, ctrl_pt, end_pt] | |
| if label_text: | |
| layout_points.append(label_pt) | |
| for point in layout_points: | |
| draw_point = drawer.GetDrawCoords(point) | |
| bounds = _update_bounds(bounds, draw_point.x, draw_point.y) | |
| if label_text: | |
| draw_label = drawer.GetDrawCoords(label_pt) | |
| min_x, min_y, max_x, max_y = _estimate_text_bounds( | |
| label_text, | |
| draw_label.x, | |
| draw_label.y, | |
| font_px=float(drawing_config.visual.fixedFontSize), | |
| margin=4.0, | |
| ) | |
| bounds = _update_bounds(bounds, min_x, min_y) | |
| bounds = _update_bounds(bounds, max_x, max_y) | |
| outward_x = ctrl_x - mid_x | |
| outward_y = ctrl_y - mid_y | |
| outward_len = math.hypot(outward_x, outward_y) | |
| if outward_len <= 1e-6: | |
| outward_x, outward_y = 0.0, -1.0 | |
| outward_len = 1.0 | |
| outward_x /= outward_len | |
| outward_y /= outward_len | |
| labels = arc_substituents.get(arc_idx, []) | |
| for sub_idx, sub_label in enumerate(labels): | |
| lateral = (sub_idx - (len(labels) - 1) / 2.0) * 0.35 | |
| lateral_x = -outward_y * lateral | |
| lateral_y = outward_x * lateral | |
| conn_start = Point2D( | |
| label_x + lateral_x * 0.25, | |
| label_y + lateral_y * 0.25, | |
| ) | |
| conn_end = Point2D( | |
| ctrl_x + outward_x * 0.65 + lateral_x, | |
| ctrl_y + outward_y * 0.65 + lateral_y, | |
| ) | |
| sub_label_pt = Point2D( | |
| ctrl_x + outward_x * 0.9 + lateral_x, | |
| ctrl_y + outward_y * 0.9 + lateral_y, | |
| ) | |
| drawer.DrawLine(conn_start, conn_end) | |
| drawer.DrawString(sub_label, sub_label_pt, 1) | |
| for point in (conn_start, conn_end, sub_label_pt): | |
| draw_point = drawer.GetDrawCoords(point) | |
| bounds = _update_bounds(bounds, draw_point.x, draw_point.y) | |
| draw_label = drawer.GetDrawCoords(sub_label_pt) | |
| min_x, min_y, max_x, max_y = _estimate_text_bounds( | |
| sub_label, | |
| draw_label.x, | |
| draw_label.y, | |
| font_px=float(drawing_config.visual.fixedFontSize), | |
| margin=4.0, | |
| ) | |
| bounds = _update_bounds(bounds, min_x, min_y) | |
| bounds = _update_bounds(bounds, max_x, max_y) | |
| except Exception as exc: | |
| logger.debug("Failed to draw virtualArc %s: %s", arc_idx, exc) | |
| return bounds | |
| def _cubic_bezier_point( | |
| start: Point2D, | |
| control_a: Point2D, | |
| control_b: Point2D, | |
| end: Point2D, | |
| t: float, | |
| ) -> Point2D: | |
| one_minus_t = 1.0 - t | |
| x = ( | |
| one_minus_t**3 * start.x | |
| + 3 * one_minus_t * one_minus_t * t * control_a.x | |
| + 3 * one_minus_t * t * t * control_b.x | |
| + t**3 * end.x | |
| ) | |
| y = ( | |
| one_minus_t**3 * start.y | |
| + 3 * one_minus_t * one_minus_t * t * control_a.y | |
| + 3 * one_minus_t * t * t * control_b.y | |
| + t**3 * end.y | |
| ) | |
| return Point2D(x, y) | |
| def _draw_cubic_curve( | |
| drawer, | |
| start: Point2D, | |
| control_a: Point2D, | |
| control_b: Point2D, | |
| end: Point2D, | |
| segments: int = 14, | |
| ) -> List[Point2D]: | |
| points = [start] | |
| previous = start | |
| for step in range(1, segments + 1): | |
| current = _cubic_bezier_point( | |
| start, | |
| control_a, | |
| control_b, | |
| end, | |
| step / segments, | |
| ) | |
| drawer.DrawLine(previous, current) | |
| points.append(current) | |
| previous = current | |
| return points | |
| def _trim_virtual_arc_endpoint( | |
| mol: Mol, | |
| conf, | |
| atom_idx: int, | |
| control: Point2D, | |
| ) -> Point2D: | |
| """Keep an overlaid virtual arc clear of a visible endpoint label.""" | |
| position = conf.GetAtomPosition(atom_idx) | |
| point = Point2D(position.x, position.y) | |
| atom = mol.GetAtomWithIdx(atom_idx) | |
| display_label = atom.GetProp("_displayLabel") if atom.HasProp("_displayLabel") else "" | |
| has_visible_label = atom.GetSymbol() != "C" or bool(display_label) | |
| if not has_visible_label: | |
| return point | |
| clearance = 0.32 | |
| if atom.GetSymbol() in {"N", "O"} and atom.GetTotalNumHs() > 0: | |
| clearance = 0.40 | |
| dx = control.x - point.x | |
| dy = control.y - point.y | |
| distance = math.hypot(dx, dy) | |
| if distance <= 1e-6: | |
| return point | |
| shift = min(clearance, distance * 0.35) | |
| return Point2D(point.x + dx / distance * shift, point.y + dy / distance * shift) | |
| def _virtual_arc_layout( | |
| mol: Mol, | |
| conf, | |
| start_idx: int, | |
| end_idx: int, | |
| arc_name: str, | |
| substituents: List[str], | |
| font_px: float, | |
| draw_scale: float, | |
| label_spacing_scale: Optional[float] = None, | |
| ) -> Tuple[ | |
| Point2D, | |
| Point2D, | |
| Point2D, | |
| Point2D, | |
| List[Tuple[Point2D, Point2D, Point2D]], | |
| ]: | |
| """Lay out one virtual arc, its inner name, and outward substituents.""" | |
| ctrl_x, ctrl_y = _virtual_arc_control_point(mol, conf, start_idx, end_idx) | |
| control = Point2D(ctrl_x, ctrl_y) | |
| start = _trim_virtual_arc_endpoint(mol, conf, start_idx, control) | |
| end = _trim_virtual_arc_endpoint(mol, conf, end_idx, control) | |
| mid = Point2D((start.x + end.x) / 2.0, (start.y + end.y) / 2.0) | |
| apex = _quadratic_bezier_point(start, control, end, 0.5) | |
| outward_x = control.x - mid.x | |
| outward_y = control.y - mid.y | |
| outward_len = math.hypot(outward_x, outward_y) | |
| if outward_len <= 1e-6: | |
| outward_x, outward_y, outward_len = 0.0, -1.0, 1.0 | |
| outward_x /= outward_len | |
| outward_y /= outward_len | |
| lateral_x, lateral_y = -outward_y, outward_x | |
| chord_x, chord_y = end.x - start.x, end.y - start.y | |
| if lateral_x * chord_x + lateral_y * chord_y < 0: | |
| lateral_x, lateral_y = -lateral_x, -lateral_y | |
| interior_radius = math.hypot(apex.x - mid.x, apex.y - mid.y) | |
| font_mol = font_px / max(draw_scale, 1e-6) | |
| desired_gap = min(0.65 * font_mol, 0.45 * interior_radius) | |
| name_distance = min(0.65 * interior_radius, interior_radius - desired_gap) | |
| name_distance = max(min(name_distance, 0.80 * interior_radius), 0.12) | |
| if interior_radius > 1e-6: | |
| name_distance = min(name_distance, 0.80 * interior_radius) | |
| if label_spacing_scale is not None: | |
| name_distance = max(0.12, name_distance / label_spacing_scale) | |
| name_visible = re.sub(r"<[^>]+>", "", arc_name) | |
| name_width = 0.60 * font_mol * max(len(name_visible), 1) | |
| name_height = font_mol | |
| def name_collision_penalty(candidate: Point2D) -> float: | |
| penalty = 0.0 | |
| for atom in mol.GetAtoms(): | |
| atom_idx = atom.GetIdx() | |
| atom_pos = conf.GetAtomPosition(atom_idx) | |
| display = atom.GetProp("_displayLabel") if atom.HasProp("_displayLabel") else "" | |
| if display: | |
| visible = re.sub(r"<[^>]+>", "", display) | |
| elif atom.GetSymbol() != "C": | |
| hydrogens = atom.GetTotalNumHs() | |
| visible = atom.GetSymbol() | |
| if hydrogens: | |
| visible += "H" + (str(hydrogens) if hydrogens > 1 else "") | |
| else: | |
| visible = "" | |
| if visible: | |
| atom_width = 0.60 * font_mol * max(len(visible), 1) | |
| atom_height = font_mol | |
| if atom.GetSymbol() in {"N", "O"} and atom.GetTotalNumHs() > 0: | |
| atom_width += 0.25 | |
| atom_height += 0.25 | |
| overlap_x = ( | |
| (name_width + atom_width) / 2.0 | |
| + 0.14 | |
| - abs(candidate.x - atom_pos.x) | |
| ) | |
| overlap_y = ( | |
| (name_height + atom_height) / 2.0 | |
| + 0.12 | |
| - abs(candidate.y - atom_pos.y) | |
| ) | |
| if overlap_x > 0 and overlap_y > 0: | |
| penalty += 100.0 + overlap_x * overlap_y | |
| elif math.hypot(candidate.x - atom_pos.x, candidate.y - atom_pos.y) < 0.34: | |
| penalty += 5.0 | |
| return penalty | |
| lateral_cap = max(0.20, min(0.24 * math.hypot(chord_x, chord_y), 0.85)) | |
| candidate_offsets = (0.0, -0.70 * font_mol, 0.70 * font_mol, -1.20 * font_mol, 1.20 * font_mol) | |
| candidates: List[Tuple[float, Point2D]] = [] | |
| for radial_scale in (1.0, 1.08, 0.90, 1.15, 0.82): | |
| radial = min(name_distance * radial_scale, 0.80 * interior_radius) | |
| for raw_lateral in candidate_offsets: | |
| lateral = min(max(raw_lateral, -lateral_cap), lateral_cap) | |
| candidate = Point2D( | |
| mid.x + outward_x * radial + lateral_x * lateral, | |
| mid.y + outward_y * radial + lateral_y * lateral, | |
| ) | |
| displacement = abs(radial - name_distance) + abs(lateral) * 0.2 | |
| candidates.append((name_collision_penalty(candidate) * 1000.0 + displacement, candidate)) | |
| name_point = min(candidates, key=lambda item: item[0])[1] | |
| visible_lengths = [ | |
| max(len(re.sub(r"<[^>]+>", "", label)), 1) for label in substituents | |
| ] | |
| widest_label = max(visible_lengths, default=1) | |
| label_width_mol = max( | |
| 0.5, | |
| 0.60 * font_px * widest_label / max(draw_scale, 1e-6), | |
| ) | |
| lateral_step = max(0.75, label_width_mol + 0.18) | |
| if label_spacing_scale is not None: | |
| lateral_step *= label_spacing_scale | |
| chord_length = max(math.hypot(chord_x, chord_y), 1e-6) | |
| connectors: List[Tuple[Point2D, Point2D, Point2D]] = [] | |
| for sub_idx in range(len(substituents)): | |
| lateral = (sub_idx - (len(substituents) - 1) / 2.0) * lateral_step | |
| t = min(max(0.5 + lateral / chord_length, 0.22), 0.78) | |
| anchor = _quadratic_bezier_point(start, control, end, t) | |
| connector_end = Point2D( | |
| anchor.x | |
| + outward_x | |
| * 0.70 | |
| * (label_spacing_scale if label_spacing_scale is not None else 1.0), | |
| anchor.y | |
| + outward_y | |
| * 0.70 | |
| * (label_spacing_scale if label_spacing_scale is not None else 1.0), | |
| ) | |
| label_point = Point2D( | |
| anchor.x | |
| + outward_x | |
| * (label_spacing_scale if label_spacing_scale is not None else 1.0), | |
| anchor.y | |
| + outward_y | |
| * (label_spacing_scale if label_spacing_scale is not None else 1.0), | |
| ) | |
| connectors.append((anchor, connector_end, label_point)) | |
| return start, control, end, name_point, connectors | |
| def _serialize_svg(root: ET.Element) -> str: | |
| ET.register_namespace("", _SVG_NAMESPACE) | |
| svg_text = ET.tostring(root, encoding="unicode") | |
| # ElementTree may still emit ns-prefixed tags if another registration wins. | |
| return ( | |
| svg_text.replace("<ns0:svg", "<svg") | |
| .replace("</ns0:svg>", "</svg>") | |
| .replace("ns0:", "") | |
| .replace(f'xmlns:ns0="{_SVG_NAMESPACE}"', f'xmlns="{_SVG_NAMESPACE}"') | |
| ) | |
| def _expand_svg_viewbox(svg_text: str, bounds: List[float], pad: float) -> str: | |
| try: | |
| root = ET.fromstring(svg_text) | |
| except Exception: | |
| return svg_text | |
| view_box = root.get("viewBox") | |
| if view_box: | |
| parts = view_box.split() | |
| if len(parts) == 4: | |
| try: | |
| vb_x, vb_y, vb_w, vb_h = map(float, parts) | |
| except Exception: | |
| return svg_text | |
| else: | |
| return svg_text | |
| else: | |
| width_attr = root.get("width", "") | |
| height_attr = root.get("height", "") | |
| width_match = re.search(r"[\d.]+", width_attr or "") | |
| height_match = re.search(r"[\d.]+", height_attr or "") | |
| if not width_match or not height_match: | |
| return svg_text | |
| vb_x, vb_y = 0.0, 0.0 | |
| vb_w = float(width_match.group(0)) | |
| vb_h = float(height_match.group(0)) | |
| min_x, min_y, max_x, max_y = bounds | |
| new_min_x = min(vb_x, min_x - pad) | |
| new_min_y = min(vb_y, min_y - pad) | |
| new_max_x = max(vb_x + vb_w, max_x + pad) | |
| new_max_y = max(vb_y + vb_h, max_y + pad) | |
| new_w = new_max_x - new_min_x | |
| new_h = new_max_y - new_min_y | |
| root.set("viewBox", f"{new_min_x} {new_min_y} {new_w} {new_h}") | |
| root.set("width", f"{new_w}px") | |
| root.set("height", f"{new_h}px") | |
| root.set("overflow", "visible") | |
| for child in root.iter(): | |
| tag = child.tag.rsplit("}", 1)[-1] | |
| if tag != "rect": | |
| continue | |
| style = child.get("style", "") | |
| if "fill:#FFFFFF" in style or "fill: #FFFFFF" in style: | |
| child.set("x", str(new_min_x)) | |
| child.set("y", str(new_min_y)) | |
| child.set("width", str(new_w)) | |
| child.set("height", str(new_h)) | |
| break | |
| for clip in root.iter(): | |
| tag = clip.tag.rsplit("}", 1)[-1] | |
| if tag != "clipPath": | |
| continue | |
| for rect in list(clip): | |
| rect_tag = rect.tag.rsplit("}", 1)[-1] | |
| if rect_tag == "rect": | |
| rect.set("x", str(new_min_x)) | |
| rect.set("y", str(new_min_y)) | |
| rect.set("width", str(new_w)) | |
| rect.set("height", str(new_h)) | |
| return _serialize_svg(root) | |
| class _DrawingPatterns: | |
| """More lenient regexes used only for drawing.""" | |
| grp_content = re.compile( | |
| rf"(?P<{TextType.SYMBOL.value}>[^\[\?\'\"]*?)" | |
| + rf"(?P<{TextType.SCRIPT.value}>(\[\S+\])?)" | |
| + rf"(?P<{TextType.PRIME.value}>[\'\"]?)" | |
| + rf"(?P<{TextType.MULTIPLE.value}>(\?([a-z]|\d+|\d+-\d+)$)?)" | |
| ) | |
| grp_pattern = re.compile( | |
| rf"({Tokens.atom_start}|{Tokens.circ_start}|{Tokens.dummy_start}|{Tokens.ring_start}|{Tokens.ring_start}{Tokens.circ_start}|{Tokens.ring_start}{Tokens.virtual_start})" | |
| + r"(\d+:\S*?)" | |
| + rf"({Tokens.atom_end}|{Tokens.circ_end}|{Tokens.dummy_end}|{Tokens.ring_end})" | |
| ) | |
| trail_pattern = Patterns.trail_pattern | |
| class _DrawingTranslator: | |
| """Tokenize / parse / render molecule from caption.""" | |
| def _preserve_stereochemistry(cls, original_smiles: str, mol: Mol) -> None: | |
| try: | |
| stereo_mol = chem_utils.parse_smiles(original_smiles, sanitize=False) | |
| if stereo_mol is None: | |
| return | |
| if stereo_mol.GetNumAtoms() != mol.GetNumAtoms(): | |
| return | |
| for i in range(mol.GetNumAtoms()): | |
| original_atom = stereo_mol.GetAtomWithIdx(i) | |
| target_atom = mol.GetAtomWithIdx(i) | |
| if ( | |
| target_atom.GetChiralTag() == Chem.ChiralType.CHI_UNSPECIFIED | |
| and original_atom.GetChiralTag() != Chem.ChiralType.CHI_UNSPECIFIED | |
| ): | |
| target_atom.SetChiralTag(original_atom.GetChiralTag()) | |
| for bond in original_atom.GetBonds(): | |
| begin_idx = bond.GetBeginAtomIdx() | |
| end_idx = bond.GetEndAtomIdx() | |
| target_bond = mol.GetBondBetweenAtoms(begin_idx, end_idx) | |
| if ( | |
| target_bond | |
| and target_bond.GetStereo() == Chem.BondStereo.STEREONONE | |
| and bond.GetStereo() != Chem.BondStereo.STEREONONE | |
| ): | |
| target_bond.SetStereo(bond.GetStereo()) | |
| except Exception: | |
| pass | |
| def parse_caption( | |
| cls, caption: str, return_mol: bool = False, error_msg: bool = False | |
| ) -> Optional[Tuple[Union[Mol, str], str, str]]: | |
| if Tokens.separator not in caption: | |
| if error_msg: | |
| logger.warning("No `%s` found in caption: %s", Tokens.separator, caption) | |
| return | |
| smi, trailing = caption.split(Tokens.separator, 1) | |
| if error_msg: | |
| RDLogger.EnableLog("rdApp.*") | |
| mol = chem_utils.parse_smiles(smi) | |
| RDLogger.DisableLog("rdApp.*") | |
| if mol is None: | |
| if error_msg: | |
| logger.warning("Invalid SMILES: %s", smi) | |
| return | |
| cls._preserve_stereochemistry(smi, mol) | |
| groups, ext = cls.parse_trailing(trailing) | |
| if return_mol: | |
| return mol, groups, ext | |
| return smi, groups, ext | |
| def parse_trailing(cls, trailing: str): | |
| matched = re.match(_DrawingPatterns.trail_pattern, trailing) | |
| if matched is None: | |
| return "", "" | |
| content = matched.groupdict() | |
| return content.get("groups") or "", content.get("extension") or "" | |
| def parse_groups(cls, seq: str) -> List[GroupDesc]: | |
| if seq == "": | |
| return [] | |
| seq = re.sub( | |
| rf"{Tokens.substruct_start}.*?{Tokens.substruct_end}|" | |
| rf"{Tokens.sgroup_start}.*?{Tokens.sgroup_end}|" | |
| rf"{Tokens.virtual_start}.*?{Tokens.virtual_end}", | |
| "", | |
| seq, | |
| flags=re.DOTALL, | |
| ) | |
| descriptions: List[GroupDesc] = [] | |
| for grp_start, grp_content, _ in re.findall(_DrawingPatterns.grp_pattern, seq): | |
| parsed = cls.parse_group(grp_content) | |
| if parsed is None: | |
| continue | |
| idx, grp_text = parsed | |
| if grp_start in (Tokens.atom_start, Tokens.dummy_start): | |
| grp_desc = GroupDesc(id=AtomIndex(idx)) | |
| if len(grp_text) == 0 or grp_start == Tokens.dummy_start: | |
| grp_desc.is_dummy = True | |
| elif grp_start == Tokens.circ_start: | |
| grp_desc = GroupDesc(id=AtomIndex(idx), is_circle=True) | |
| elif grp_start in ( | |
| f"{Tokens.ring_start}{Tokens.circ_start}", | |
| f"{Tokens.ring_start}{Tokens.virtual_start}", | |
| ): | |
| grp_desc = GroupDesc(id=RingIndex(idx, virtual=True)) | |
| elif grp_start == Tokens.ring_start: | |
| grp_desc = GroupDesc(id=RingIndex(idx)) | |
| else: | |
| continue | |
| symbol = grp_text.get(TextType.SYMBOL) | |
| if symbol: | |
| symbol = _format_symbol_with_subscripts(symbol) | |
| grp_desc.symbol = symbol | |
| grp_desc.script = grp_text.get(TextType.SCRIPT) | |
| grp_desc.prime = grp_text.get(TextType.PRIME) | |
| grp_desc.multiple = grp_text.get(TextType.MULTIPLE) | |
| descriptions.append(grp_desc) | |
| return descriptions | |
| def parse_group(cls, group: str) -> Optional[Tuple[int, Dict[TextType, str]]]: | |
| items = group.split(":") | |
| if len(items) != 2: | |
| return | |
| idx, content = items | |
| if not idx.isdigit(): | |
| return | |
| idx = int(idx) | |
| if content == Tokens.dummy or content == "": | |
| return idx, {} | |
| grp_text = cls.get_group_texts(content) | |
| if len(grp_text) == 0: | |
| return | |
| return idx, grp_text | |
| def get_group_texts(cls, content: str) -> Dict[TextType, str]: | |
| texts: Dict[TextType, str] = {} | |
| bracket_matches = list(re.finditer(r"\[([^\]]+)\]", content)) | |
| if bracket_matches: | |
| first_bracket_start = bracket_matches[0].start() | |
| last_bracket_end = bracket_matches[-1].end() | |
| symbol_part = content[:first_bracket_start] + content[last_bracket_end:] | |
| script_text = "" | |
| for i, match in enumerate(bracket_matches): | |
| script_text += match.group(1) | |
| if i < len(bracket_matches) - 1: | |
| next_start = bracket_matches[i + 1].start() | |
| script_text += content[match.end():next_start] | |
| texts[TextType.SCRIPT] = script_text | |
| else: | |
| symbol_part = content | |
| multiple_match = re.search(r"\?([a-z]|\d+|\d+-\d+)$", symbol_part) | |
| if multiple_match: | |
| texts[TextType.MULTIPLE] = multiple_match.group(1) | |
| symbol_part = re.sub(r"\?([a-z]|\d+|\d+-\d+)$", "", symbol_part) | |
| prime_match = re.search(r"[\'\"]$", symbol_part) | |
| if prime_match: | |
| texts[TextType.PRIME] = prime_match.group() | |
| symbol_part = symbol_part[:-1] | |
| if symbol_part: | |
| texts[TextType.SYMBOL] = symbol_part | |
| return texts | |
| def reconstruct_mol( | |
| cls, | |
| mol: Union[Mol, str], | |
| groups: Optional[str] = None, | |
| write_to: Optional[str] = None, | |
| drawer_type: Literal["SVG", "PNG"] = "SVG", | |
| config: Optional[Union[Dict[str, Any], DrawingConfig]] = None, | |
| ) -> Optional[Union[str, bytes]]: | |
| if isinstance(config, DrawingConfig): | |
| drawing_config = config | |
| else: | |
| drawing_config = _validate_config(config) | |
| if drawer_type not in ("SVG", "PNG"): | |
| logger.error("Invalid drawer type `%s`", drawer_type) | |
| return | |
| extension = "" | |
| if isinstance(mol, Mol): | |
| assert groups is not None, "Empty group information" | |
| elif isinstance(mol, str): | |
| assert groups is None, "Excessive group information" | |
| parsed = cls.parse_caption(caption=mol, return_mol=True) | |
| if parsed is None: | |
| return | |
| mol, groups, extension = parsed | |
| else: | |
| raise TypeError(f"Invalid argument type: `{mol.__class__.__name__}`") | |
| grp_descriptions = cls.parse_groups(groups) | |
| candidate_single_ch2_repeats = _single_ch2_repeat_records( | |
| mol, | |
| grp_descriptions, | |
| ) | |
| candidate_sgroup_records = _sgroup_records(groups) | |
| candidate_whole_sru_count = _sru_count(extension) | |
| repeat_setting = drawing_config.features.repeat_units | |
| repeat_units = ( | |
| bool( | |
| candidate_single_ch2_repeats | |
| or candidate_sgroup_records | |
| or candidate_whole_sru_count is not None | |
| ) | |
| if repeat_setting is None | |
| else repeat_setting | |
| ) | |
| single_ch2_repeats = ( | |
| candidate_single_ch2_repeats if repeat_units else [] | |
| ) | |
| single_ch2_indices = {record[0] for record in single_ch2_repeats} | |
| whole_sru_count = candidate_whole_sru_count if repeat_units else None | |
| virtual_arcs = _virtual_arcs(groups) | |
| academic_arc_setting = drawing_config.features.academic_virtual_arcs | |
| academic_virtual_arcs = ( | |
| len(virtual_arcs) > 1 or any(not name for _idx, name, _start, _end in virtual_arcs) | |
| if academic_arc_setting is None | |
| else academic_arc_setting | |
| ) | |
| endpoint_balls_enabled = drawing_config.features.endpoint_balls is not False | |
| whole_sru_atoms = ( | |
| _whole_sru_atoms(mol, groups) | |
| if whole_sru_count is not None | |
| else None | |
| ) | |
| whole_sru_ports = ( | |
| _whole_sru_ports(mol, groups) | |
| if whole_sru_atoms is not None | |
| else None | |
| ) | |
| whole_sru_endpoints = set( | |
| _whole_sru_endpoints(mol, groups) or [] | |
| ) if whole_sru_atoms is not None else set() | |
| circ_indices = [ | |
| int(desc.id) | |
| for desc in grp_descriptions | |
| if desc.is_circle and desc.symbol is not None | |
| ] | |
| ring_captions: List[str] = [] | |
| ring_annotations: List[Tuple[int, str]] = [] | |
| virtual_ring_annotations: List[Tuple[int, str]] = [] | |
| endpoint_balls: Dict[int, str] = {} | |
| for desc in grp_descriptions: | |
| i = int(desc.id) | |
| label = None | |
| if isinstance(desc.id, AtomIndex): | |
| if not 0 <= i < mol.GetNumAtoms(): | |
| continue | |
| atom = mol.GetAtomWithIdx(i) | |
| ball_token = None | |
| if atom.GetSymbol() == "*": | |
| if i in single_ch2_indices: | |
| label = "" | |
| else: | |
| ball_token = ( | |
| _endpoint_ball_token(desc) | |
| if endpoint_balls_enabled | |
| else None | |
| ) | |
| if ball_token is not None: | |
| label = "" | |
| endpoint_balls[i] = ball_token | |
| elif desc.is_dummy: | |
| label = "" if drawing_config.styling.use_modern_symbols else "dum" | |
| elif desc.symbol is None: | |
| continue | |
| elif desc.is_circle: | |
| label = ( | |
| desc.symbol | |
| if drawing_config.styling.use_modern_symbols | |
| else f"c{desc.symbol}" | |
| ) | |
| else: | |
| label = _format_group_label(desc) | |
| elif desc.symbol == Tokens.special_id: | |
| # A non-ball special id is an opaque graphical group label, | |
| # independent of the underlying atom element. | |
| label = _format_group_label(desc) | |
| elif atom.GetSymbol() in ("C", "O"): | |
| if desc.symbol is None and desc.multiple is not None: | |
| label = f"({atom.GetSymbol()})<sub>{desc.multiple}</sub>" | |
| if label is not None: | |
| atom.SetProp("_displayLabel", label) | |
| if ball_token is None: | |
| endpoint_balls.pop(i, None) | |
| elif isinstance(desc.id, RingIndex): | |
| if ( | |
| (not desc.id.virtual) | |
| and (not 0 <= i < mol.GetRingInfo().NumRings()) | |
| or (desc.id.virtual) | |
| and (i not in circ_indices) | |
| or desc.symbol is None | |
| ): | |
| continue | |
| if desc.symbol is not None: | |
| if desc.multiple: | |
| ring_label_text = "(" | |
| ring_label_text += desc.symbol | |
| if desc.script: | |
| ring_label_text += "<sub>" + desc.script + "</sub>" | |
| if desc.prime: | |
| ring_label_text += desc.prime | |
| ring_label_text += ")" | |
| ring_label_text += "<sub>" + desc.multiple + "</sub>" | |
| else: | |
| ring_label_text = desc.symbol | |
| if desc.script: | |
| ring_label_text += "<sub>" + desc.script + "</sub>" | |
| if desc.prime: | |
| ring_label_text += desc.prime | |
| if desc.id.virtual: | |
| virtual_ring_annotations.append((i, ring_label_text)) | |
| else: | |
| ring_annotations.append((i, ring_label_text)) | |
| # A legacy whole-SRU has no <d> records to suppress the two terminal | |
| # dummy labels. Once its endpoints have passed the deliberately | |
| # strict, unambiguous inference above, depict them as ordinary | |
| # continuation bonds crossing the parentheses, just like explicit | |
| # E-SMILES 2.0 endpoints. | |
| if whole_sru_count is not None: | |
| for atom_idx in whole_sru_endpoints: | |
| mol.GetAtomWithIdx(atom_idx).SetProp("_displayLabel", "") | |
| mol.RemoveAllConformers() | |
| params = Chem.rdCoordGen.CoordGenParams() | |
| params.minimizerPrecision = params.sketcherBestPrecision | |
| Chem.rdCoordGen.AddCoords(mol, params) | |
| Chem.rdDepictor.StraightenDepiction(mol, 0) | |
| dummy_info: List[Tuple[int, int]] = [] | |
| if drawing_config.features.dummy_atoms: | |
| for desc in grp_descriptions: | |
| if isinstance(desc.id, AtomIndex) and desc.is_dummy: | |
| atom_idx = int(desc.id) | |
| # A valid whole-SRU uses its terminal dummy atoms only as | |
| # machine-readable endpoints. The ordinary terminal | |
| # bonds already cross the repeat parentheses, so adding | |
| # wavy connection glyphs would be visually redundant. | |
| if atom_idx in whole_sru_endpoints: | |
| continue | |
| if atom_idx < mol.GetNumAtoms(): | |
| atom = mol.GetAtomWithIdx(atom_idx) | |
| connected_atoms = [ | |
| bond.GetOtherAtomIdx(atom_idx) for bond in atom.GetBonds() | |
| ] | |
| if connected_atoms: | |
| dummy_info.append((atom_idx, connected_atoms[0])) | |
| palette_setting = drawing_config.styling.palette | |
| if isinstance(palette_setting, str): | |
| use_bw_palette = palette_setting == "bw" | |
| else: | |
| bw_prob = min(max(palette_setting, 0.0), 1.0) | |
| use_bw_palette = random.random() < bw_prob | |
| highlight_atoms: List[int] = [] | |
| highlight_colors: Dict[int, Tuple[float, float, float]] = {} | |
| highlight_atom_radii: Dict[int, float] = {} | |
| highlight_bonds: List[int] = [] | |
| highlight_bond_colors: Dict[int, Tuple[float, float, float]] = {} | |
| if drawing_config.styling.enhanced_contrast and not use_bw_palette: | |
| use_atom_hl = _resolve_probability(drawing_config.styling.highlight_atoms) | |
| use_bond_hl = _resolve_probability(drawing_config.styling.highlight_bonds) | |
| custom_colors_active = _resolve_probability(drawing_config.styling.custom_colors) | |
| if custom_colors_active: | |
| dummy_bond_color = _random_color() | |
| rgroup_bond_color = _random_color() | |
| rgroup_atom_color = _random_color() | |
| else: | |
| dummy_bond_color = (0.5, 0.7, 1.0) | |
| rgroup_bond_color = (0.5, 0.8, 0.5) | |
| rgroup_atom_color = (0.2, 0.7, 0.2) | |
| for desc in grp_descriptions: | |
| if isinstance(desc.id, AtomIndex): | |
| atom_idx = int(desc.id) | |
| if atom_idx < mol.GetNumAtoms(): | |
| atom = mol.GetAtomWithIdx(atom_idx) | |
| if ( | |
| use_atom_hl | |
| and desc.symbol is not None | |
| and not desc.is_circle | |
| and not desc.is_dummy | |
| and atom_idx not in endpoint_balls | |
| ): | |
| highlight_atoms.append(atom_idx) | |
| highlight_colors[atom_idx] = rgroup_atom_color | |
| if use_bond_hl: | |
| for bond in atom.GetBonds(): | |
| other_idx = bond.GetOtherAtomIdx(atom_idx) | |
| bond_idx = mol.GetBondBetweenAtoms(atom_idx, other_idx).GetIdx() | |
| if bond_idx in highlight_bonds: | |
| continue | |
| highlight_bonds.append(bond_idx) | |
| if desc.is_dummy: | |
| highlight_bond_colors[bond_idx] = dummy_bond_color | |
| elif desc.symbol is not None: | |
| highlight_bond_colors[bond_idx] = rgroup_bond_color | |
| # RDKit sizes its dynamic canvas before the custom ball overlay is | |
| # painted. A white, same-radius highlight reserves the required bounds | |
| # in both SVG and Cairo and is completely covered by the final ball. | |
| for atom_idx in endpoint_balls: | |
| if atom_idx not in highlight_atoms: | |
| highlight_atoms.append(atom_idx) | |
| highlight_colors[atom_idx] = (1.0, 1.0, 1.0) | |
| highlight_atom_radii[atom_idx] = _ENDPOINT_BALL_RADIUS | |
| if write_to is not None: | |
| drawer_type = "PNG" | |
| if drawer_type == "SVG": | |
| drawer = rdMolDraw2D.MolDraw2DSVG(width=-1, height=-1) | |
| else: | |
| drawer = rdMolDraw2D.MolDraw2DCairo(width=-1, height=-1) | |
| dopts = rdMolDraw2D.MolDrawOptions() | |
| if use_bw_palette: | |
| dopts.useBWAtomPalette() | |
| else: | |
| dopts.useCDKAtomPalette() | |
| visual_options = drawing_config.visual.model_dump() | |
| # This is consumed only by the custom virtual-arc overlay below. | |
| visual_options.pop("virtualArcLabelSpacingScale", None) | |
| # Assigning None to RDKit's string-valued fontFile option raises, and | |
| # omitting it is what preserves the historical built-in-font default. | |
| if visual_options.get("fontFile") is None: | |
| visual_options.pop("fontFile", None) | |
| visual_options["addAtomIndices"] = _resolve_probability( | |
| visual_options.get("addAtomIndices", False) | |
| ) | |
| for k, v in visual_options.items(): | |
| setattr(dopts, k, v) | |
| # Keep RDKit's close-contact diagnostic enabled. The production | |
| # renderer treats any resulting red collision glyph as a rejected | |
| # image-caption pair; it must never be hidden by disabling this warning. | |
| dopts.flagCloseContactsDist = 3 | |
| drawer.SetDrawOptions(dopts) | |
| rdMolDraw2D.PrepareAndDrawMolecule( | |
| drawer, | |
| mol, | |
| highlightAtoms=highlight_atoms, | |
| highlightAtomColors=highlight_colors, | |
| highlightAtomRadii=highlight_atom_radii, | |
| highlightBonds=highlight_bonds, | |
| highlightBondColors=highlight_bond_colors, | |
| legend=" | ".join(ring_captions), | |
| ) | |
| ring_bounds: Optional[List[float]] = None | |
| if endpoint_balls: | |
| conf = mol.GetConformer() | |
| for atom_idx, token in endpoint_balls.items(): | |
| try: | |
| atom_pos = conf.GetAtomPosition(atom_idx) | |
| center = Point2D(atom_pos.x, atom_pos.y) | |
| _draw_endpoint_ball( | |
| drawer, | |
| center, | |
| token, | |
| drawing_config.visual.bondLineWidth, | |
| ) | |
| if drawer_type == "SVG": | |
| for corner in ( | |
| Point2D( | |
| center.x - _ENDPOINT_BALL_RADIUS, | |
| center.y - _ENDPOINT_BALL_RADIUS, | |
| ), | |
| Point2D( | |
| center.x + _ENDPOINT_BALL_RADIUS, | |
| center.y + _ENDPOINT_BALL_RADIUS, | |
| ), | |
| ): | |
| draw_corner = drawer.GetDrawCoords(corner) | |
| ring_bounds = _update_bounds( | |
| ring_bounds, | |
| draw_corner.x, | |
| draw_corner.y, | |
| ) | |
| except Exception as e: | |
| logger.debug( | |
| "Failed to draw endpoint ball `%s` at atom %s: %s", | |
| token, | |
| atom_idx, | |
| e, | |
| ) | |
| if drawing_config.features.dummy_atoms and dummy_info and drawer_type == "SVG": | |
| conf = mol.GetConformer() | |
| drawer.SetLineWidth(drawing_config.visual.bondLineWidth) | |
| for dummy_idx, connected_idx in dummy_info: | |
| try: | |
| dummy_pos = conf.GetAtomPosition(dummy_idx) | |
| connected_pos = conf.GetAtomPosition(connected_idx) | |
| dx = connected_pos.x - dummy_pos.x | |
| dy = connected_pos.y - dummy_pos.y | |
| length = math.sqrt(dx * dx + dy * dy) | |
| if length > 0: | |
| dx /= length | |
| dy /= length | |
| perp_x = dy | |
| perp_y = -dx | |
| extension_length = 0.35 | |
| start_x = dummy_pos.x - perp_x * extension_length | |
| start_y = dummy_pos.y - perp_y * extension_length | |
| end_x = dummy_pos.x + perp_x * extension_length | |
| end_y = dummy_pos.y + perp_y * extension_length | |
| wavy_color = (0.0, 0.0, 0.0) | |
| drawer.DrawWavyLine( | |
| Point2D(start_x, start_y), | |
| Point2D(end_x, end_y), | |
| wavy_color, | |
| wavy_color, | |
| nSegments=8, | |
| vertOffset=0.08, | |
| ) | |
| except Exception as e: | |
| logger.debug("Failed to draw wavy line for dummy atom %s: %s", dummy_idx, e) | |
| if drawing_config.features.circled_r_groups and drawer_type == "SVG": | |
| conf = mol.GetConformer() | |
| for desc in grp_descriptions: | |
| if isinstance(desc.id, AtomIndex) and desc.is_circle: | |
| atom_idx = int(desc.id) | |
| if atom_idx < mol.GetNumAtoms(): | |
| try: | |
| atom_pos = conf.GetAtomPosition(atom_idx) | |
| r = 0.5 | |
| drawer.SetColour((0, 0, 0)) | |
| drawer.SetFillPolys(False) | |
| drawer.SetLineWidth(drawing_config.visual.bondLineWidth) | |
| drawer.DrawEllipse( | |
| Point2D(atom_pos.x - r, atom_pos.y - r), | |
| Point2D(atom_pos.x + r, atom_pos.y + r), | |
| ) | |
| except Exception as e: | |
| logger.debug( | |
| "Failed to draw circle for circled R-group at atom %s: %s", | |
| atom_idx, | |
| e, | |
| ) | |
| if ( | |
| drawing_config.features.ring_annotations | |
| and virtual_ring_annotations | |
| and drawer_type == "SVG" | |
| ): | |
| conf = mol.GetConformer() | |
| by_atom: Dict[int, List[str]] = {} | |
| for atom_idx, label_text in virtual_ring_annotations: | |
| by_atom.setdefault(atom_idx, []).append(label_text) | |
| for atom_idx, labels in by_atom.items(): | |
| if not 0 <= atom_idx < mol.GetNumAtoms(): | |
| continue | |
| try: | |
| atom_pos = conf.GetAtomPosition(atom_idx) | |
| # Spread labels around the abstract ring placeholder. | |
| start_angle = -math.pi / 2 | |
| if len(labels) > 1: | |
| start_angle -= (len(labels) - 1) * math.pi / 8 | |
| for label_idx, label_text in enumerate(labels): | |
| angle = start_angle + label_idx * math.pi / 4 | |
| dir_x = math.cos(angle) | |
| dir_y = math.sin(angle) | |
| line_start = Point2D( | |
| atom_pos.x + dir_x * 0.45, | |
| atom_pos.y + dir_y * 0.45, | |
| ) | |
| line_end = Point2D( | |
| atom_pos.x + dir_x * 0.9, | |
| atom_pos.y + dir_y * 0.9, | |
| ) | |
| label_pt = Point2D( | |
| atom_pos.x + dir_x * 1.15, | |
| atom_pos.y + dir_y * 1.15, | |
| ) | |
| drawer.SetColour((0, 0, 0)) | |
| drawer.SetLineWidth(1) | |
| drawer.DrawLine(line_start, line_end) | |
| drawer.DrawString(label_text, label_pt, 1) | |
| for pt in (line_start, line_end, label_pt): | |
| draw_pt = drawer.GetDrawCoords(pt) | |
| ring_bounds = _update_bounds(ring_bounds, draw_pt.x, draw_pt.y) | |
| draw_label = drawer.GetDrawCoords(label_pt) | |
| min_x, min_y, max_x, max_y = _estimate_text_bounds( | |
| label_text, | |
| draw_label.x, | |
| draw_label.y, | |
| font_px=float(drawing_config.visual.fixedFontSize), | |
| margin=4.0, | |
| ) | |
| ring_bounds = _update_bounds(ring_bounds, min_x, min_y) | |
| ring_bounds = _update_bounds(ring_bounds, max_x, max_y) | |
| except Exception as e: | |
| logger.debug("Failed to draw virtual ring annotations at atom %s: %s", atom_idx, e) | |
| if ( | |
| drawing_config.features.ring_annotations | |
| and ring_annotations | |
| and drawer_type == "SVG" | |
| ): | |
| conf = mol.GetConformer() | |
| ring_info = mol.GetRingInfo() | |
| atom_rings = ring_info.AtomRings() | |
| bond_rings = ring_info.BondRings() | |
| drawer.SetLineWidth(1) | |
| ring_annotations_by_idx: Dict[int, List[str]] = {} | |
| for ring_idx, label_text in ring_annotations: | |
| ring_annotations_by_idx.setdefault(ring_idx, []).append(label_text) | |
| for ring_idx, label_texts in ring_annotations_by_idx.items(): | |
| if not (0 <= ring_idx < len(atom_rings)): | |
| continue | |
| ring_atoms = atom_rings[ring_idx] | |
| if not ring_atoms: | |
| continue | |
| center_x = sum(conf.GetAtomPosition(idx).x for idx in ring_atoms) / len(ring_atoms) | |
| center_y = sum(conf.GetAtomPosition(idx).y for idx in ring_atoms) / len(ring_atoms) | |
| direction_x = direction_y = None | |
| ring_radius = None | |
| midpoint_distance = None | |
| bond_mid_data: List[Tuple[float, float, float]] = [] | |
| bond_ids = bond_rings[ring_idx] if 0 <= ring_idx < len(bond_rings) else [] | |
| for bond_idx in bond_ids: | |
| bond = mol.GetBondWithIdx(bond_idx) | |
| pos1 = conf.GetAtomPosition(bond.GetBeginAtomIdx()) | |
| pos2 = conf.GetAtomPosition(bond.GetEndAtomIdx()) | |
| mid_x = (pos1.x + pos2.x) / 2.0 | |
| mid_y = (pos1.y + pos2.y) / 2.0 | |
| dx = mid_x - center_x | |
| dy = mid_y - center_y | |
| dist = math.hypot(dx, dy) | |
| if dist > 1e-6: | |
| bond_mid_data.append((dist, dx / dist, dy / dist)) | |
| if bond_mid_data: | |
| midpoint_distance, direction_x, direction_y = max( | |
| bond_mid_data, key=lambda item: item[0] | |
| ) | |
| ring_radius = midpoint_distance | |
| else: | |
| for atom_idx in ring_atoms: | |
| pos = conf.GetAtomPosition(atom_idx) | |
| dx = pos.x - center_x | |
| dy = pos.y - center_y | |
| dist = math.hypot(dx, dy) | |
| if dist > 1e-6: | |
| direction_x = dx / dist | |
| direction_y = dy / dist | |
| ring_radius = dist | |
| midpoint_distance = dist | |
| break | |
| if ring_radius is None or direction_x is None: | |
| continue | |
| if midpoint_distance is None: | |
| midpoint_distance = ring_radius | |
| extension = drawing_config.styling.ring_connector_extension | |
| total_length = midpoint_distance + extension | |
| base_angle = math.atan2(direction_y, direction_x) | |
| spread = math.radians(26) | |
| label_offset = 0.25 | |
| for label_idx, label_text in enumerate(label_texts): | |
| angle_offset = (label_idx - (len(label_texts) - 1) / 2.0) * spread | |
| angle = base_angle + angle_offset | |
| label_dir_x = math.cos(angle) | |
| label_dir_y = math.sin(angle) | |
| drawer.SetColour((0, 0, 0)) | |
| if drawing_config.styling.ring_connector_style == "solid": | |
| drawer.DrawLine( | |
| Point2D(center_x, center_y), | |
| Point2D( | |
| center_x + label_dir_x * total_length, | |
| center_y + label_dir_y * total_length, | |
| ), | |
| ) | |
| else: | |
| dash_length = 0.15 | |
| gap_length = 0.1 | |
| offset = 0.0 | |
| while offset < total_length: | |
| end_offset = min(offset + dash_length, total_length) | |
| if end_offset > offset: | |
| drawer.DrawLine( | |
| Point2D( | |
| center_x + label_dir_x * offset, | |
| center_y + label_dir_y * offset, | |
| ), | |
| Point2D( | |
| center_x + label_dir_x * end_offset, | |
| center_y + label_dir_y * end_offset, | |
| ), | |
| ) | |
| offset = end_offset + gap_length | |
| label_pt = Point2D( | |
| center_x + label_dir_x * (total_length + label_offset), | |
| center_y + label_dir_y * (total_length + label_offset), | |
| ) | |
| drawer.SetColour((0, 0, 0)) | |
| drawer.DrawString(label_text, label_pt, 1) | |
| draw_center = drawer.GetDrawCoords(Point2D(center_x, center_y)) | |
| draw_label = drawer.GetDrawCoords(label_pt) | |
| ring_bounds = _update_bounds(ring_bounds, draw_center.x, draw_center.y) | |
| ring_bounds = _update_bounds(ring_bounds, draw_label.x, draw_label.y) | |
| mol_end = Point2D( | |
| center_x + label_dir_x * total_length, | |
| center_y + label_dir_y * total_length, | |
| ) | |
| draw_end = drawer.GetDrawCoords(mol_end) | |
| ring_bounds = _update_bounds(ring_bounds, draw_end.x, draw_end.y) | |
| font_px = float(drawing_config.visual.fixedFontSize) | |
| min_x, min_y, max_x, max_y = _estimate_text_bounds( | |
| label_text, | |
| draw_label.x, | |
| draw_label.y, | |
| font_px=font_px, | |
| margin=4.0, | |
| ) | |
| ring_bounds = _update_bounds(ring_bounds, min_x, min_y) | |
| ring_bounds = _update_bounds(ring_bounds, max_x, max_y) | |
| rendered_sgroups: Optional[set[str]] = None | |
| if repeat_units: | |
| rendered_sgroups = set() | |
| for body, ports, count in candidate_sgroup_records: | |
| repeat_atoms = _sgroup_repeat_atoms(mol, ports) | |
| if repeat_atoms is None: | |
| continue | |
| ring_bounds = _draw_sgroup_boundary_parentheses( | |
| drawer, | |
| mol, | |
| ports, | |
| count, | |
| drawing_config.visual.bondLineWidth, | |
| float(drawing_config.visual.fixedFontSize), | |
| ring_bounds, | |
| count != "n" or drawing_config.styling.show_default_sru_count, | |
| drawing_config.styling.sgroup_bracket_style, | |
| ) | |
| rendered_sgroups.add(body) | |
| for _atom_idx, ports, count in single_ch2_repeats: | |
| ring_bounds = _draw_sgroup_boundary_parentheses( | |
| drawer, | |
| mol, | |
| ports, | |
| count, | |
| drawing_config.visual.bondLineWidth, | |
| float(drawing_config.visual.fixedFontSize), | |
| ring_bounds, | |
| True, | |
| "round", | |
| ) | |
| if whole_sru_count is not None and whole_sru_ports is not None: | |
| ring_bounds = _draw_sgroup_boundary_parentheses( | |
| drawer, | |
| mol, | |
| whole_sru_ports, | |
| whole_sru_count, | |
| drawing_config.visual.bondLineWidth, | |
| float(drawing_config.visual.fixedFontSize), | |
| ring_bounds, | |
| whole_sru_count != "n" | |
| or drawing_config.styling.show_default_sru_count, | |
| "round", | |
| ) | |
| if virtual_arcs and not academic_virtual_arcs: | |
| ring_bounds = _draw_virtual_arcs_legacy( | |
| drawer, | |
| mol, | |
| virtual_arcs, | |
| _virtual_arc_substituents(groups), | |
| drawing_config, | |
| ring_bounds, | |
| ) | |
| if virtual_arcs and academic_virtual_arcs: | |
| conf = mol.GetConformer() | |
| arc_substituents = _virtual_arc_substituents(groups) | |
| drawer.SetLineWidth(drawing_config.visual.bondLineWidth) | |
| drawer.SetColour((0.1, 0.1, 0.1)) | |
| draw_origin = drawer.GetDrawCoords(Point2D(0.0, 0.0)) | |
| draw_unit = drawer.GetDrawCoords(Point2D(1.0, 0.0)) | |
| draw_scale = max( | |
| math.hypot(draw_unit.x - draw_origin.x, draw_unit.y - draw_origin.y), | |
| 1e-6, | |
| ) | |
| for arc_idx, arc_name, start_idx, end_idx in virtual_arcs: | |
| if not (0 <= start_idx < mol.GetNumAtoms() and 0 <= end_idx < mol.GetNumAtoms()): | |
| continue | |
| try: | |
| substituents = arc_substituents.get(arc_idx, []) | |
| start_pt, ctrl_pt, end_pt, label_pt, connector_layout = ( | |
| _virtual_arc_layout( | |
| mol, | |
| conf, | |
| start_idx, | |
| end_idx, | |
| arc_name, | |
| substituents, | |
| float(drawing_config.visual.fixedFontSize), | |
| draw_scale, | |
| drawing_config.visual.virtualArcLabelSpacingScale, | |
| ) | |
| ) | |
| _draw_quadratic_arc(drawer, start_pt, ctrl_pt, end_pt) | |
| label_text = arc_name | |
| if label_text: | |
| drawer.DrawString(label_text, label_pt, 1) | |
| layout_points = [start_pt, ctrl_pt, end_pt] | |
| if label_text: | |
| layout_points.append(label_pt) | |
| for pt in layout_points: | |
| draw_pt = drawer.GetDrawCoords(pt) | |
| ring_bounds = _update_bounds(ring_bounds, draw_pt.x, draw_pt.y) | |
| if label_text: | |
| draw_label = drawer.GetDrawCoords(label_pt) | |
| min_x, min_y, max_x, max_y = _estimate_text_bounds( | |
| label_text, | |
| draw_label.x, | |
| draw_label.y, | |
| font_px=float(drawing_config.visual.fixedFontSize), | |
| margin=4.0, | |
| ) | |
| ring_bounds = _update_bounds(ring_bounds, min_x, min_y) | |
| ring_bounds = _update_bounds(ring_bounds, max_x, max_y) | |
| for sub_label, (conn_start, conn_end, sub_label_pt) in zip( | |
| substituents, | |
| connector_layout, | |
| ): | |
| drawer.DrawLine(conn_start, conn_end) | |
| drawer.DrawString(sub_label, sub_label_pt, 1) | |
| for pt in (conn_start, conn_end, sub_label_pt): | |
| draw_pt = drawer.GetDrawCoords(pt) | |
| ring_bounds = _update_bounds(ring_bounds, draw_pt.x, draw_pt.y) | |
| draw_label = drawer.GetDrawCoords(sub_label_pt) | |
| min_x, min_y, max_x, max_y = _estimate_text_bounds( | |
| sub_label, | |
| draw_label.x, | |
| draw_label.y, | |
| font_px=float(drawing_config.visual.fixedFontSize), | |
| margin=4.0, | |
| ) | |
| ring_bounds = _update_bounds(ring_bounds, min_x, min_y) | |
| ring_bounds = _update_bounds(ring_bounds, max_x, max_y) | |
| except Exception as e: | |
| logger.debug("Failed to draw virtualArc %s: %s", arc_idx, e) | |
| precompat_notes = _precompat_annotations(groups, rendered_sgroups) | |
| if precompat_notes: | |
| conf = mol.GetConformer() | |
| xs = [conf.GetAtomPosition(i).x for i in range(mol.GetNumAtoms())] | |
| ys = [conf.GetAtomPosition(i).y for i in range(mol.GetNumAtoms())] | |
| note_x = min(xs) if xs else 0.0 | |
| note_y = (max(ys) if ys else 0.0) + 0.8 | |
| drawer.SetColour((0.1, 0.1, 0.1)) | |
| for idx, note in enumerate(precompat_notes): | |
| note_pt = Point2D(note_x, note_y + idx * 0.45) | |
| drawer.DrawString(note, note_pt, 1) | |
| draw_note = drawer.GetDrawCoords(note_pt) | |
| ring_bounds = _update_bounds(ring_bounds, draw_note.x, draw_note.y) | |
| min_x, min_y, max_x, max_y = _estimate_text_bounds( | |
| note, | |
| draw_note.x, | |
| draw_note.y, | |
| font_px=float(drawing_config.visual.fixedFontSize), | |
| margin=4.0, | |
| ) | |
| ring_bounds = _update_bounds(ring_bounds, min_x, min_y) | |
| ring_bounds = _update_bounds(ring_bounds, max_x, max_y) | |
| drawer.FinishDrawing() | |
| if write_to is not None: | |
| Path(write_to).parent.mkdir(parents=True, exist_ok=True) | |
| drawer.WriteDrawingText(write_to) | |
| return | |
| svg_text = drawer.GetDrawingText() | |
| if ring_bounds is not None: | |
| svg_text = _expand_svg_viewbox(svg_text, ring_bounds, pad=6.0) | |
| return svg_text | |
| def _needs_svg_overlay(smi: str, config: DrawingConfig) -> bool: | |
| """Return whether PNG must pass through the completed SVG overlay.""" | |
| repeat_syntax = bool( | |
| "<g>" in smi | |
| or "|Sg:" in smi | |
| or re.search(r"<a>\d+:CH2\?(?:[a-z]|\d+|\d+-\d+)</a>", smi) | |
| ) | |
| repeat_setting = config.features.repeat_units | |
| repeat_overlay = repeat_setting is True or ( | |
| repeat_setting is None and repeat_syntax | |
| ) | |
| ball_pattern = "|".join(re.escape(token) for token in _ENDPOINT_BALL_STYLES) | |
| ball_syntax = re.search(rf"<id>\[(?:{ball_pattern})\]", smi) is not None | |
| ball_setting = config.features.endpoint_balls | |
| ball_overlay = ball_setting is True or ( | |
| ball_setting is None and ball_syntax | |
| ) | |
| arc_names = re.findall( | |
| r"<v>\d+:([^:]*):\[\d+:\d+\]</v>", | |
| smi, | |
| flags=re.DOTALL, | |
| ) | |
| academic_arc_syntax = len(arc_names) > 1 or any(not name for name in arc_names) | |
| arc_setting = config.features.academic_virtual_arcs | |
| arc_overlay = arc_setting is True or ( | |
| arc_setting is None and academic_arc_syntax | |
| ) | |
| return repeat_overlay or ball_overlay or arc_overlay | |
| def draw( | |
| smi: str, | |
| config: Optional[Union[Dict[str, Any], DrawingConfig]] = None, | |
| output_format: Literal["svg", "png"] = "svg", | |
| ) -> Union[str, bytes]: | |
| """Draw a SMILES / E-SMILES string and return SVG text or PNG bytes.""" | |
| drawing_config = ( | |
| config if isinstance(config, DrawingConfig) else _validate_config(config) | |
| ) | |
| requested_format = output_format.lower() | |
| if requested_format not in {"svg", "png"}: | |
| raise ValueError("output_format must be 'svg' or 'png'") | |
| svg_to_png = None | |
| if requested_format == "png": | |
| try: | |
| from cairosvg import svg2png as svg_to_png | |
| except (ImportError, OSError) as exc: | |
| raise RuntimeError( | |
| "CairoSVG is required for PNG output because PNG is rendered " | |
| "from the completed SVG to preserve its viewBox and margins" | |
| ) from exc | |
| # Always build the final SVG first. The same viewBox, overlays, margins, | |
| # labels, and stereobonds therefore define both public output formats. | |
| drawer_type: Literal["SVG", "PNG"] = "SVG" | |
| if Tokens.separator in smi: | |
| drawing = _DrawingTranslator.reconstruct_mol( | |
| mol=smi, | |
| drawer_type=drawer_type, | |
| config=drawing_config, | |
| ) | |
| else: | |
| mol = chem_utils.parse_smiles(smi) | |
| if mol is None: | |
| raise ValueError(f"Invalid SMILES: {smi}") | |
| drawing = _DrawingTranslator.reconstruct_mol( | |
| mol=mol, | |
| groups="", | |
| drawer_type=drawer_type, | |
| config=drawing_config, | |
| ) | |
| if drawing is None: | |
| raise RuntimeError(f"Failed to draw molecule {smi}") | |
| if svg_to_png is not None: | |
| if not isinstance(drawing, str): | |
| raise RuntimeError("SVG renderer returned non-text output") | |
| return svg_to_png(bytestring=drawing.encode("utf-8")) | |
| return drawing | |