Source code for vibesketch.render.chemfig

"""chemfig source for LaTeX: structures as a walk with angles, schemes with arrows.

chemfig (Christian Tellechea, CTAN) draws a molecule from a walk over its
atoms: every bond carries an absolute angle and a length coefficient,
branches are parenthesised, and a bond that closes a ring is a hook,
``?[a]`` at the first atom and ``?[a,{=}]`` at the second. This module
reproduces the drawing rather than idealising it: a spanning tree by
depth-first search from the leftmost terminal atom, the bond angles and
relative lengths read off the 2D coordinates, and the remaining bonds as
hooks. Fused and bridged rings therefore come out exactly as drawn, which is
why chemfig's ring macros (``*6(...)``) are not used.

Atom labels follow chemfig's rule that a new atom starts at every capital
letter: carbon is implicit, a heteroatom carries its hydrogens on the side
away from its bonds (``HO-`` and ``-OH``) and its charge as a superscript,
and every bond names the atom it leaves and arrives at (``-[:30,,2,1]``) so a
label such as ``H_2N`` attaches at the nitrogen. A heteroatom with bonds on
both sides draws its hydrogens as atoms of their own in the free direction.
Stereo bonds are chemfig's Cram bonds, ``>`` and ``>:`` widening towards the
atom in front, with the direction taken from the walk. Aromatic rings are
written in a Kekulé form.

Reactions become ``\\schemestart ... \\schemestop`` with ``\\+`` between
species, reagents above and conditions and the yield below the arrow, and
compound numbers under their structures with ``\\chemname``. The arrow kinds
map to ``->``, ``<=>`` and ``<->``; a retrosynthetic arrow is drawn with
TikZ's double ``-Implies`` tip, which chemfig loads. The result is a
fragment for ``\\input`` in a document that loads the ``chemfig`` package.

A drawing with shapes is not a scheme but a picture: :func:`chemfig_drawing`
writes it as one ``tikzpicture`` with every object where it is drawn.
"""

from __future__ import annotations

import math
import re
from collections.abc import Iterable
from dataclasses import dataclass, field
from typing import Any

from vibesketch.document.model import Document, Reaction

__all__ = ["ChemfigRefused", "chemfig_drawing", "chemfig_molecule", "chemfig_source"]


[docs] class ChemfigRefused(ValueError): """The structure or scheme cannot be expressed in chemfig."""
#: chemfig arrow codes per reaction arrow; the retrosynthetic one is TikZ's double arrow. _ARROWS = { "forward": "->", "equilibrium": "<=>", "resonance": "<->", "retrosynthetic": "->[][][][double,-Implies]", } _BOND_CHARS = {1: "-", 2: "=", 3: "~"} _LATEX_SPECIAL = { "\\": r"\textbackslash{}", "{": r"\{", "}": r"\}", "%": r"\%", "&": r"\&", "#": r"\#", "_": r"\_", "$": r"\$", "^": r"\^{}", "~": r"\~{}", # OT1, LaTeX's default text encoding, prints < and > as inverted ! and ?. "<": r"\textless{}", ">": r"\textgreater{}", # Superscript signs and digits, as in 2e⁻ or Pb²⁺ (#141). **{ sup: f"\\textsuperscript{{{base}}}" for sup, base in zip("⁻⁺⁰¹²³⁴⁵⁶⁷⁸⁹", "-+0123456789", strict=True) }, # fmt: skip "−": r"\textminus{}", } #: A bond this far from the median length, as a fraction, gets a length coefficient. _LENGTH_TOLERANCE = 0.03 def _escape(text: str) -> str: """``text`` safe in LaTeX text mode; a solid's formula with its counts as subscripts.""" formatted = _formula_text(text) if formatted is not None: return formatted return "".join(_LATEX_SPECIAL.get(char, char) for char in text) def _formula_text(text: str, keep_state: bool = True) -> str | None: """A solid's formula in LaTeX text mode, counts in ``\\textsubscript``; plain text otherwise.""" import re from vibesketch.document.solids import solid_runs runs = solid_runs(text) if runs is None: return None if not keep_state and runs: last, low = runs[-1] runs[-1] = (re.sub(r"\(\s*(s|l|g|aq|ads)\s*\)\s*$", "", last), low) def plain(part: str) -> str: return "".join(_LATEX_SPECIAL.get(char, char) for char in part) return "".join(f"\\textsubscript{{{plain(p)}}}" if low else plain(p) for p, low in runs) @dataclass(frozen=True) class _Label: """An atom's chemfig label and where its element sits among chemfig's atoms.""" text: str #: The element's number among the label's chemfig atoms; 0 for an empty label. element: int #: How many chemfig atoms the label holds. atoms: int #: Hydrogens to draw as atoms of their own, with their directions in degrees. explicit_hydrogens: tuple[float, ...] = () @property def attaches(self) -> str: """The atom number a bond attaches at, or empty when chemfig can tell.""" return str(self.element) if self.atoms > 1 else "" def _superscript(charge: int, radicals: int) -> str: parts = [] if charge: magnitude = "" if abs(charge) == 1 else str(abs(charge)) parts.append(f"{magnitude}{'+' if charge > 0 else '-'}") if radicals: parts.append(r"\bullet" * radicals) return f"^{{{''.join(parts)}}}" if parts else "" def _hydrogens(count: int) -> str: if count <= 0: return "" return "H" if count == 1 else f"H_{{{count}}}" def _free_directions(angles: Iterable[float], count: int) -> tuple[float, ...]: """``count`` directions, in degrees, inside the widest gap between ``angles``.""" taken = sorted(angle % 360.0 for angle in angles) if not taken: return tuple(90.0 + 180.0 * index for index in range(count)) gaps = [ ((taken[(index + 1) % len(taken)] - angle) % 360.0 or 360.0, angle) for index, angle in enumerate(taken) ] width, start = max(gaps) return tuple((start + width * (index + 1) / (count + 1)) % 360.0 for index in range(count)) #: An abbreviation's label in chemfig: letters, digits and signs, with sub- and superscripts. _ABBREVIATION = re.compile(r"(?:[A-Za-z0-9+\-'.]|[_^]\{[a-z0-9+\-]+\})+") def _abbreviation(atom: Any, angles: list[float]) -> _Label: """The label of a contracted group, such as OTf, reversed (TfO) when its bond leaves right. chemfig starts an atom at every capital letter, and characters before the first one are an atom of their own, so the bond names the atom it meets: the first capital of a label reading east, the last atom of one reading west. """ rightward = any(math.cos(math.radians(a)) > 0.05 for a in angles) leftward = any(math.cos(math.radians(a)) < -0.05 for a in angles) west = rightward and not leftward markup = str(atom.GetProp("_displayLabelW" if west else "_displayLabel")) return _written(markup, west, "abbreviation") def _written(markup: str, west: bool, what: str) -> _Label: """A label in RDKit's markup, the bond meeting its first atom, or its last ``west``.""" text = re.sub(r"</(?:sub|sup)>", "}", markup.replace("<sub>", "_{").replace("<sup>", "^{")) if not _ABBREVIATION.fullmatch(text): raise ChemfigRefused(f"cannot write the {what} label {markup!r} in chemfig") leading = 0 if text[0].isupper() else 1 count = max(1, sum(char.isupper() for char in text) + leading) element = count if west else min(1 + leading, count) return _Label(text, element, count) def _attachment(label: str) -> _Label: """A labelled attachment atom (CNT, SiO2, Au) as one chemfig atom, in braces (#139). chemfig sets a group in braces as a single atom; digits after a letter are subscripts and spaces are kept. """ text = re.sub(r"(?<=[A-Za-z)])(\d+)", r"_{\1}", label) text = text.replace(" ", r"\ ").replace("*", r"\ast ") return _Label(f"{{{text}}}", 1, 1) def _label(atom: Any, angles: list[float]) -> _Label: """The chemfig label of ``atom``, whose bonds leave at ``angles`` (degrees).""" if atom.HasProp("polymerEnd"): return _Label("", 0, 0) # an open chain end beyond a repeat unit's bracket (#145) if atom.HasProp("attachmentLabel"): if atom.HasProp("_displayLabel") and not atom.GetProp("_displayLabel"): return _Label("", 0, 0) # joined to a nanostructure picture: the bond ends there return _attachment(str(atom.GetProp("attachmentLabel"))) if atom.HasProp("_displayLabel"): return _abbreviation(atom, angles) if atom.HasProp("_fromAttchpt"): # an R-group's attachment point: a bond and a wavy line return _Label("", 0, 0) if atom.HasProp("dummyLabel"): # an R-group atom, such as R1 return _written(str(atom.GetProp("dummyLabel")), False, "R-group") symbol = str(atom.GetSymbol()) hydrogens = int(atom.GetTotalNumHs()) charge = int(atom.GetFormalCharge()) from vibesketch.chem.species import unpaired radicals = unpaired(atom) isotope = int(atom.GetIsotope()) if symbol == "C" and not (charge or radicals or isotope) and angles: return _Label("", 0, 0) superscript = _superscript(charge, radicals) mass = f"{{}}^{{{isotope}}}" if isotope else "" rightward = any(math.cos(math.radians(a)) > 0.05 for a in angles) leftward = any(math.cos(math.radians(a)) < -0.05 for a in angles) if hydrogens and rightward and leftward: # Bonds on both sides: the hydrogens are atoms of their own. return _Label( f"{mass}{symbol}{superscript}", 2 if mass else 1, 2 if mass else 1, _free_directions(angles, hydrogens), ) if hydrogens and (rightward or (not angles and symbol in ("O", "S", "F", "Cl", "Br", "I"))): # Hydrogens on the left, away from the bonds: HO-, H_2N-. return _Label(f"{_hydrogens(hydrogens)}{mass}{symbol}{superscript}", 2, 2) text = f"{mass}{symbol}{_hydrogens(hydrogens)}{superscript}" element = 2 if mass else 1 return _Label(text, element, element + (1 if hydrogens else 0)) def _angle(origin: tuple[float, float], target: tuple[float, float]) -> float: return math.degrees(math.atan2(target[1] - origin[1], target[0] - origin[0])) % 360.0 def _number(value: float) -> str: """An angle in whole degrees: a tenth of a degree is invisible on paper.""" return str(int(round(value)) % 360) @dataclass class _Walk: """The spanning tree of a molecule and the hooks that close its rings.""" children: dict[int, list[int]] = field(default_factory=dict) parent: dict[int, int | None] = field(default_factory=dict) #: Hook name per ring-closing bond, keyed by the bond index. hooks: dict[int, str] = field(default_factory=dict) #: Hooks opened at an atom, in order; closed at an atom, as (name, bond index). opened: dict[int, list[str]] = field(default_factory=dict) closed: dict[int, list[tuple[str, int]]] = field(default_factory=dict) def _hook_name(index: int) -> str: letters = "" index += 1 while index: index, remainder = divmod(index - 1, 26) letters = chr(ord("a") + remainder) + letters return letters def _walk(mol: Any, positions: list[tuple[float, float]], stereo: dict[int, Any]) -> _Walk: """Depth-first from the leftmost terminal atom; stereo bonds become tree edges first.""" atoms = range(mol.GetNumAtoms()) start = min(atoms, key=lambda i: (mol.GetAtomWithIdx(i).GetDegree() != 1, positions[i])) walk = _Walk(parent={start: None}) order = 0 def neighbours(index: int) -> list[tuple[int, int]]: pairs = [] for bond in mol.GetAtomWithIdx(index).GetBonds(): other = bond.GetOtherAtomIdx(index) pairs.append((bond.GetIdx(), other)) return sorted( pairs, key=lambda pair: (pair[0] not in stereo, _angle(positions[index], positions[pair[1]])), ) stack: list[tuple[int, list[tuple[int, int]]]] = [(start, neighbours(start))] seen_bonds: set[int] = set() while stack: index, pending = stack[-1] if not pending: stack.pop() continue bond_index, other = pending.pop(0) if bond_index in seen_bonds: continue seen_bonds.add(bond_index) if other in walk.parent: name = _hook_name(order) order += 1 walk.hooks[bond_index] = name walk.opened.setdefault(other, []).append(name) walk.closed.setdefault(index, []).append((name, bond_index)) continue walk.parent[other] = index walk.children.setdefault(index, []).append(other) stack.append((other, neighbours(other))) return walk def _bond_char(bond: Any, stereo: dict[int, Any], from_atom: int) -> str: """chemfig's character for ``bond`` walked from ``from_atom``.""" from rdkit import Chem order = bond.GetBondType() if order == Chem.BondType.AROMATIC: raise ChemfigRefused("an aromatic bond survived kekulisation") if bond.GetIdx() in stereo: hashed = stereo[bond.GetIdx()] == Chem.BondDir.BEGINDASH forward = bond.GetBeginAtomIdx() == from_atom return (">" if forward else "<") + (":" if hashed else "") try: return _BOND_CHARS[int(order)] except KeyError: raise ChemfigRefused(f"chemfig cannot draw a {order} bond") from None def _prepared(mol: Any) -> tuple[Any, list[tuple[float, float]], dict[int, Any], float]: """A Kekulé copy with wedges assigned, its 2D positions, stereo bonds and bond length.""" from rdkit import Chem copy = Chem.Mol(mol) if copy.GetNumConformers() == 0: raise ChemfigRefused("the structure has no 2D coordinates") try: Chem.Kekulize(copy, clearAromaticFlags=True) except Exception as error: # RDKit's KekulizeException raise ChemfigRefused(f"the structure cannot be written in a Kekulé form: {error}") from None Chem.WedgeMolBonds(copy, copy.GetConformer()) conformer = copy.GetConformer() positions = [ (float(conformer.GetAtomPosition(i).x), float(conformer.GetAtomPosition(i).y)) for i in range(copy.GetNumAtoms()) ] stereo = { bond.GetIdx(): bond.GetBondDir() for bond in copy.GetBonds() if bond.GetBondDir() in (Chem.BondDir.BEGINWEDGE, Chem.BondDir.BEGINDASH) } lengths = sorted( math.dist(positions[b.GetBeginAtomIdx()], positions[b.GetEndAtomIdx()]) for b in copy.GetBonds() ) unit = lengths[len(lengths) // 2] if lengths else 1.0 return copy, positions, stereo, unit if unit > 1e-9 else 1.0
[docs] def chemfig_molecule(mol: Any) -> str: """``\\chemfig{...}`` for an RDKit molecule with 2D coordinates. Hydrogens are implicit unless the molecule keeps them as atoms. Raises :class:`ChemfigRefused` for a bond chemfig cannot draw or a structure with no coordinates. """ return f"\\chemfig{{{_figure(mol).walk}}}"
@dataclass(frozen=True) class _Figure: """A molecule's chemfig walk and where it starts in the drawing.""" walk: str #: The 2D position of the atom the walk starts at. start: tuple[float, float] #: The bond length that chemfig draws as one ``atom sep``. unit: float #: The ``to atom`` field of a bond arriving at the start atom's label. attaches: str def _figure(mol: Any) -> _Figure: if mol.GetNumAtoms() == 0: raise ChemfigRefused("the structure has no atoms") prepared, positions, stereo, unit = _prepared(mol) angles = { i: [ _angle(positions[i], positions[bond.GetOtherAtomIdx(i)]) for bond in prepared.GetAtomWithIdx(i).GetBonds() ] for i in range(prepared.GetNumAtoms()) } labels = {i: _label(prepared.GetAtomWithIdx(i), angles[i]) for i in angles} if prepared.GetNumAtoms() == 1: return _Figure(labels[0].text, positions[0], unit, labels[0].attaches) walk = _walk(prepared, positions, stereo) def bond_text(bond_index: int, from_atom: int, to_atom: int) -> str: bond = prepared.GetBondWithIdx(bond_index) char = _bond_char(bond, stereo, from_atom) angle = _number(_angle(positions[from_atom], positions[to_atom])) ratio = math.dist(positions[from_atom], positions[to_atom]) / unit coefficient = f"{ratio:.2f}" if abs(ratio - 1.0) > _LENGTH_TOLERANCE else "" fields = [f":{angle}", coefficient, labels[from_atom].attaches, labels[to_atom].attaches] while fields and not fields[-1]: fields.pop() return f"{char}[{','.join(fields)}]" def hook_text(name: str, bond_index: int, at_atom: int) -> str: bond = prepared.GetBondWithIdx(bond_index) char = _bond_char(bond, stereo, at_atom) return f"?[{name}]" if char == "-" else f"?[{name},{{{char}}}]" def emit(index: int) -> str: label = labels[index] parts = [label.text] parts.extend(f"?[{name}]" for name in walk.opened.get(index, [])) parts.extend(hook_text(name, bond, index) for name, bond in walk.closed.get(index, [])) for direction in label.explicit_hydrogens: attach = f",,{label.attaches}" if label.attaches else "" parts.append(f"(-[:{_number(direction)}{attach}]H)") children = walk.children.get(index, []) for position, child in enumerate(children): bond = prepared.GetBondBetweenAtoms(index, child).GetIdx() branch = bond_text(bond, index, child) + emit(child) parts.append(branch if position == len(children) - 1 else f"({branch})") return "".join(parts) start = next(atom for atom, parent in walk.parent.items() if parent is None) return _Figure(emit(start), positions[start], unit, labels[start].attaches) def _named(figure: str, label: str | None) -> str: return f"\\chemname{{{figure}}}{{\\textbf{{{_escape(label)}}}}}" if label else figure def _arrow(reaction: Reaction, figures: dict[str, str]) -> str: """``\\arrow{...}`` with the reagents above and the conditions and yield below.""" above = ", ".join(figures[sid] for sid in reaction.reagents if sid in figures) below_parts = [*reaction.conditions] if reaction.yield_ is not None: below_parts.append(reaction.yield_) below = _escape(", ".join(part for part in below_parts if part)) code = _ARROWS[reaction.arrow] if "[" in code: # the retrosynthetic arrow carries its TikZ options already return f"\\arrow{{{code.replace('[][]', f'[{above}][{below}]', 1)}}}" if below: return f"\\arrow{{{code}[{above}][{below}]}}" if above: return f"\\arrow{{{code}[{above}]}}" return f"\\arrow{{{code}}}" def _drawn_fragments(canvas: dict[str, Any]) -> list[Any]: from vibesketch.document.ket import read_scene return list(read_scene(canvas).fragments) if canvas else [] def _contracted(document: Document, fragments: list[Any]) -> dict[str, Any]: """Each species drawn with contracted abbreviations, with them as labelled atoms. The species is matched to its first drawn fragment as :func:`~vibesketch.document.formats.species_mols` matches it. """ from vibesketch.chem.species import identity, without_hydrogens from vibesketch.document.abbreviations import condensed by_key: dict[str, Any] = {} for fragment in fragments: if fragment.mol is not None: by_key.setdefault(identity(fragment.mol).key, fragment) result = {} for species in document.species: fragment = by_key.get(species.inchikey or species.smiles or species.id) if fragment is not None and fragment.abbreviations: mol = condensed(fragment.mol, fragment.abbreviations) result[species.id] = without_hydrogens(mol) return result
[docs] def chemfig_source(document: Document) -> str: """The document's structures and reactions as a chemfig fragment. Every reaction is a scheme; species in no reaction follow as structures of their own, with contracted abbreviations as their labels. A drawing with shapes, or with a polymer repeat unit or other drawing that is no species, is written as a TikZ picture of the whole drawing instead (:func:`chemfig_drawing`), which refuses what it cannot draw. Raises :class:`ChemfigRefused` when there is nothing to draw or a species a reaction needs has no structure. """ from vibesketch.document.formats import ExportRefused, species_mols canvas: dict[str, Any] = document.canvas.data nodes: list[Any] = canvas.get("root", {}).get("nodes", []) if canvas else [] made = {s.id: s for s in document.species if s.nanostructure is not None} if any(isinstance(n, dict) and n.get("type") in ("simpleObject", "image") for n in nodes): return chemfig_drawing(canvas, made) fragments = _drawn_fragments(canvas) if any(fragment.figure is not None for fragment in fragments): # A drawing no species holds: repeat units are drawn, the rest refused, never dropped. return chemfig_drawing(canvas, made) try: mols = species_mols(document) except ExportRefused as error: raise ChemfigRefused(str(error)) from None if not mols: raise ChemfigRefused("the document has no species to export") mols.update(_contracted(document, fragments)) figures = {sid: chemfig_molecule(mol) for sid, mol in mols.items()} for species in document.species: if species.solid is not None: # a solid as its formula, its state written by the reaction written = _formula_text(species.solid.text, keep_state=False) figures[species.id] = written if written is not None else _escape(species.solid.text) labels = {species.id: species.label for species in document.species} lines = [ "% chemfig source written by vibe-sketch, for \\input in a document that", "% loads \\usepackage{chemfig}. Angles and bond lengths follow the drawing.", ] in_reactions: set[str] = set() for reaction in document.reactions: for sid in (*reaction.reactants, *reaction.reagents, *reaction.products): if sid not in figures: raise ChemfigRefused( f"reaction {reaction.id} needs species {sid}, which has no structure" ) in_reactions.update(reaction.reactants, reaction.reagents, reaction.products) lines.append("") lines.append(f"% reaction {reaction.id}") lines.append("\\schemestart") for side in ("reactants", "products"): if side == "products": lines.append(_arrow(reaction, figures)) for index, sid in enumerate(getattr(reaction, side)): if index: lines.append("\\+") lines.append(_participant(reaction, sid, _named(figures[sid], labels.get(sid)))) if reaction.electrons is not None and reaction.electrons.side == side: count = reaction.electrons.count lines.append("\\+") lines.append(f"{'' if count == 1 else count}e\\textsuperscript{{-}}") lines.append("\\schemestop") loose = [species.id for species in document.species if species.id in figures] loose = [sid for sid in loose if sid not in in_reactions] if loose: lines.append("") lines.append("% structures") lines.extend(_named(figures[sid], labels.get(sid)) for sid in loose) return "\n".join(lines) + "\n"
def _participant(reaction: Any, species: str, figure: str) -> str: """A participant with its drawn coefficient before it and its state after it (#141).""" coefficient = reaction.coefficient(species) state = next((s.state for s in reaction.states if s.species == species), None) before = "" if coefficient == 1 else f"{coefficient:g}\\," after = "" if state is None else f"\\,({state})" return f"{before}{figure}{after}" # ── A drawing with shapes, as a TikZ picture ────────────────────────────── #: chemfig's default ``atom sep`` in em, which one median bond of the drawing becomes. _ATOM_SEP = 3.0 #: The length of the invisible bond that leads into each walk, in ``atom sep``. _LEAD_IN = 0.01 #: Room left around a molecule's atoms for its labels, in bond lengths. _LABEL_ROOM = 0.5 #: TikZ arrows.meta tips (chemfig loads the library) for the scene renderer's tip kinds. _TIPS = { "open": "Straight Barb", "triangle": "Triangle", "bow": "Stealth", "half-open": "Straight Barb[left]", "half-bow": "Stealth[left]", "half-triangle": "Triangle[left]", } _STRAIGHT = { "open-angle": "open", "filled-triangle": "triangle", "filled-bow": "bow", "dashed-open-angle": "open", "failed": "open", } _EQUILIBRIUM = { "equilibrium-filled-triangle": "triangle", "equilibrium-filled-half-bow": "half-bow", "equilibrium-open-angle": "open", "unbalanced-equilibrium-filled-half-bow": "half-bow", "unbalanced-equilibrium-open-half-angle": "half-open", "unbalanced-equilibrium-large-filled-half-bow": "half-bow", "unbalanced-equilibrium-filled-half-triangle": "half-triangle", } _CURVED = { "elliptical-arc-arrow-filled-triangle": "triangle", "elliptical-arc-arrow-filled-bow": "bow", "elliptical-arc-arrow-open-angle": "open", "elliptical-arc-arrow-open-half-angle": "half-open", } def _at(value: float) -> str: """A coordinate to a thousandth of a bond, without trailing zeros.""" text = f"{value:.3f}".rstrip("0").rstrip(".") return "0" if text in ("", "-0") else text def _point(point: tuple[float, float]) -> str: return f"({_at(point[0])},{_at(point[1])})" def _tikz_arrow(arrow: Any) -> list[str]: """TikZ for a KET arrow, drawn along its own axis like the SVG scene.""" (tx, ty), (hx, hy) = arrow.tail, arrow.head length = math.hypot(hx - tx, hy - ty) if not (math.isfinite(length) and length > 1e-8): raise ChemfigRefused("cannot write an arrow with coincident endpoints") angle = math.degrees(math.atan2(hy - ty, hx - tx)) frame = f"shift={{{_point(arrow.tail)}}},rotate={_at(angle)}" full = _at(length) mode = arrow.mode if mode in _STRAIGHT: dash = ",dashed" if mode == "dashed-open-angle" else "" lines = [f"\\draw[{frame},-{{{_TIPS[_STRAIGHT[mode]]}}}{dash}] (0,0) -- ({full},0);"] if mode == "failed": middle = f"({_at(length / 2)},0)" lines.append( f"\\draw[{frame}] {middle} ++(-3pt,-3pt) -- ++(6pt,6pt) " f"{middle} ++(-3pt,3pt) -- ++(6pt,-6pt);" ) return lines if mode == "both-ends-filled-triangle": return [f"\\draw[{frame},{{Triangle}}-{{Triangle}}] (0,0) -- ({full},0);"] if mode == "retrosynthetic": return [f"\\draw[{frame},double,double distance=3pt,-{{Implies}}] (0,0) -- ({full},0);"] if mode in _EQUILIBRIUM: tip = _TIPS[_EQUILIBRIUM[mode]] if "large" in mode: tip = tip.replace("[left]", "[left,scale=1.4]") # The unbalanced arrow's short return is 5 pt shorter at each end. inset = ",shorten >=5pt,shorten <=5pt" if mode.startswith("unbalanced-") else "" return [ f"\\draw[{frame},-{{{tip}}}] (0,0) ++(0pt,2pt) -- ++({full},0);", f"\\draw[{frame},-{{{tip}}}{inset}] ({full},0) ++(0pt,-2pt) -- ++(-{full},0);", ] if mode in _CURVED: tip = _TIPS[_CURVED[mode]] height = float(arrow.height) if not math.isfinite(height): raise ChemfigRefused("the drawing has a non-finite arrow height") if abs(height) < 1e-8: return [f"\\draw[{frame},-{{{tip}}}] (0,0) -- ({full},0);"] # Ketcher 3.18 KET convention: half an ellipse whose signed height is # to the left of the arrow's direction, as the SVG scene draws it. end = 0 if height > 0 else 360 return [ f"\\draw[{frame},-{{{tip}}}] (0,0) arc[start angle=180,end angle={end}," f"x radius={_at(length / 2)},y radius={_at(abs(height))}];" ] raise ChemfigRefused(f"cannot write KET arrow mode {mode!r}") def _tikz_shape(node: dict[str, Any]) -> str: from vibesketch.render import RenderRefused from vibesketch.render.scene import _shape_points try: mode, points = _shape_points(node) except RenderRefused as error: raise ChemfigRefused(str(error)) from None if mode == "polyline": return f"\\draw[line join=round] {' -- '.join(_point(point) for point in points)};" first, second = points if mode == "rectangle": return f"\\draw {_point(first)} rectangle {_point(second)};" if mode == "ellipse": centre = ((first[0] + second[0]) / 2, (first[1] + second[1]) / 2) return ( f"\\draw {_point(centre)} ellipse[x radius={_at(abs(second[0] - first[0]) / 2)}," f"y radius={_at(abs(second[1] - first[1]) / 2)}];" ) return f"\\draw {_point(first)} -- {_point(second)};" #: A bracket's hooks, as a fraction of the bracket's length, as RDKit draws them. _HOOK = 0.1 #: The bracketed S-groups figures draw: repeat units, copolymers, multiple and generic groups. _BRACKETED = frozenset({"SRU", "COP", "MUL", "GEN"}) def _brackets(mol: Any) -> list[tuple[Any, list[tuple[tuple[float, float], ...]]]]: """Each bracketed S-group of ``mol`` with its brackets' end points.""" from rdkit import Chem return [ (group, [((b[0].x, b[0].y), (b[1].x, b[1].y)) for b in group.GetBrackets()]) for group in Chem.GetMolSubstanceGroups(mol) if group.GetProp("TYPE") in _BRACKETED ] def _drawable(fragment: Any) -> bool: """Whether ``fragment``'s figure is one chemfig draws: brackets or R-groups.""" from rdkit import Chem if fragment.figure is None: return False types = {group.GetProp("TYPE") for group in Chem.GetMolSubstanceGroups(fragment.figure)} return types <= _BRACKETED #: An attachment point's wavy line across the end of its bond: its length in #: bond lengths, its amplitude, and its waves, about as RDKit draws it. _SQUIGGLE = (0.6, 0.05, 4) #: The gap between a caption, such as ``R1 =``, and its structure, and its line #: height in em, as the SVG figure has them. _CAPTION_GAP = 0.5 _CAPTION_LINE = 1.3 def _pieces(mol: Any) -> list[Any]: """The connected pieces of ``mol``, each a walk of its own; an R-group's definition holds one piece per substituent.""" from rdkit import Chem if len(Chem.GetMolFrags(mol)) == 1: return [mol] return list(Chem.GetMolFrags(mol, asMols=True, sanitizeFrags=False)) def _tikz_attachments(mol: Any, unit: float) -> list[str]: """A wavy line across the end of each R-group attachment bond, as figures draw it. The wave is TikZ's own ``sin`` and ``cos`` path operations, so the document needs no library beyond what chemfig loads. """ length, amplitude, waves = _SQUIGGLE conformer = mol.GetConformer() lines = [] for atom in mol.GetAtoms(): if not atom.HasProp("_fromAttchpt") or atom.GetDegree() != 1: continue (other,) = atom.GetNeighbors() end, start = ( conformer.GetAtomPosition(atom.GetIdx()), conformer.GetAtomPosition(other.GetIdx()), ) across = math.degrees(math.atan2(end.y - start.y, end.x - start.x)) + 90 step = length * unit / (4 * waves) x, height = -length * unit / 2, amplitude * unit path = [f"({_at(x)},0)"] for _ in range(waves): for operation, y in (("sin", height), ("cos", 0.0), ("sin", -height), ("cos", 0.0)): x += step path.append(f"{operation} ({_at(x)},{_at(y)})") lines.append( f"\\draw[gray,shift={{{_point((end.x, end.y))}}},rotate={_at(across)}] " + " ".join(path) + ";" ) return lines def _extent(mol: Any) -> tuple[float, float, float, float]: """The box around the atoms and the brackets.""" conformer = mol.GetConformer() points = [ (conformer.GetAtomPosition(i).x, conformer.GetAtomPosition(i).y) for i in range(mol.GetNumAtoms()) ] for _, ends in _brackets(mol): for end in ends: points.extend(end) xs, ys = [p[0] for p in points], [p[1] for p in points] return (min(xs), min(ys), max(xs), max(ys)) def _tikz_brackets(mol: Any) -> list[str]: """TikZ for each bracketed group's brackets and the label after the closing one. Each bracket is a line with hooks pointing in towards the group's atoms, where the SVG figure draws it: across a bond leaving the group, or beside a group with no bond leaving it. The label (a repeat unit's subscript, in italics, a copolymer's co, alt, ran or block, a multiplier) sits below the closing bracket, the rightmost, and the connectivity (hh, eu) above it. """ lines = [] conformer = mol.GetConformer() for group, ends in _brackets(mol): atoms = list(group.GetAtoms()) cx = sum(conformer.GetAtomPosition(a).x for a in atoms) / len(atoms) cy = sum(conformer.GetAtomPosition(a).y for a in atoms) / len(atoms) for first, second in ends: dx, dy = second[0] - first[0], second[1] - first[1] hook = (-dy * _HOOK, dx * _HOOK) # perpendicular, a tenth of the bracket long middle = ((first[0] + second[0]) / 2, (first[1] + second[1]) / 2) if hook[0] * (cx - middle[0]) + hook[1] * (cy - middle[1]) < 0: hook = (-hook[0], -hook[1]) lines.append( f"\\draw {_point((first[0] + hook[0], first[1] + hook[1]))} -- {_point(first)}" f" -- {_point(second)} -- {_point((second[0] + hook[0], second[1] + hook[1]))};" ) closing = max(ends, key=lambda end: ((end[0][0] + end[1][0]) / 2, end[0][1] + end[1][1])) low, high = sorted(closing, key=lambda point: point[1]) if group.HasProp("LABEL"): label = _escape(group.GetProp("LABEL")) if group.GetProp("TYPE") == "SRU": label = f"\\textit{{{label}}}" lines.append(f"\\node[anchor=north west,inner sep=1pt] at {_point(low)} {{{label}}};") if group.HasProp("CONNECT"): connect = _escape(group.GetProp("CONNECT")) lines.append( f"\\node[anchor=south west,inner sep=1pt] at {_point(high)} {{{connect}}};" ) return lines def _tikz_pictures(canvas: dict[str, Any], nanostructures: dict[str, Any]) -> list[str]: """The drawing's nanostructure pictures, drawn afresh from their species (#138).""" from vibesketch.render.nanostructures import ( DepictionRefused, depiction_of, image_box, picture, picture_tikz, ) from vibesketch.render.surfaces import surface_of, surface_picture lines: list[str] = [] for node in canvas.get("root", {}).get("nodes", []): try: surface = surface_of(node) if surface is not None: box = image_box(node) lines.extend( picture_tikz(surface_picture(surface, nanostructures, box[2] / box[3]), box) ) continue except DepictionRefused as error: raise ChemfigRefused(str(error)) from None found = depiction_of(node) if node.get("type") == "image" else None if found is None: continue depiction = found[0] species = nanostructures.get(depiction.species) if species is None: raise ChemfigRefused( f"a nanostructure picture names species {depiction.species}, which the " "document does not have" ) try: lines.append(f"% {depiction.species}: {depiction.style}") lines.extend(picture_tikz(picture(species, depiction), image_box(node))) except DepictionRefused as error: raise ChemfigRefused(str(error)) from None return lines
[docs] def chemfig_drawing(canvas: dict[str, Any], nanostructures: dict[str, Any] | None = None) -> str: """The whole drawing as one ``tikzpicture``, everything where it is drawn. The figure the SVG export prints, in TeX: every molecule is a ``\\chemfig`` walk placed so that the atom it starts at sits at that atom's drawing coordinates, and rectangles, ellipses, lines, arrows, plus signs and text are TikZ paths and nodes in the same coordinates, one median bond of the drawing to chemfig's default ``atom sep`` of 3 em. chemfig puts a walk's first atom at the origin of its own picture, but the box TeX places is the whole molecule's. The walk's picture therefore resets its bounding box to its origin, so the box is a point that can be placed exactly, and an invisible bond of a hundredth of a bond leads into the first atom so that it is the element of a label such as ``HO``, not the label's baseline, that lands on the point. The molecules' extent is added back as an invisible path. Contracted abbreviations are written as their labels and a polymer repeat unit in brackets, as the SVG figure draws them. Objects the drawing form cannot express are refused, as the SVG renderer refuses them. """ from vibesketch.document.ket import read_scene from vibesketch.render import RenderRefused from vibesketch.render.scene import _check_nodes try: _check_nodes(canvas) scene = read_scene(canvas) except (AttributeError, KeyError, IndexError, TypeError, ValueError, RuntimeError) as error: if isinstance(error, RenderRefused): raise ChemfigRefused(str(error)) from None raise ChemfigRefused("cannot read the drawing's KET scene") from error from vibesketch.render.nanostructures import DepictionRefused, anchor_points from vibesketch.render.scene import _joined pictures = [n for n in canvas.get("root", {}).get("nodes", []) if n.get("type") == "image"] try: anchors = [(x, y) for p in pictures for x, y, _, _ in anchor_points(p).values()] except DepictionRefused as error: raise ChemfigRefused(str(error)) from None figures = [] for fragment in scene.fragments: _joined(fragment, anchors) if fragment.mol is not None and not fragment.unsupported: mol = fragment.mol if fragment.abbreviations: from vibesketch.document.abbreviations import condensed mol = condensed(mol, fragment.abbreviations) elif _drawable(fragment): mol = fragment.figure else: raise ChemfigRefused(f"cannot write {fragment.node}: {fragment.unsupported}") figures.append((fragment, mol, [(piece, _figure(piece)) for piece in _pieces(mol)])) # A lone atom has no bond to measure, so it does not vote on the unit. units = sorted( figure.unit for _, _, walks in figures for piece, figure in walks if piece.GetNumBonds() ) drawing_unit = units[len(units) // 2] if units else 1.0 scale = f"{_ATOM_SEP / drawing_unit:.4f}".rstrip("0").rstrip(".") lines = [ "% chemfig and TikZ source written by vibe-sketch, for \\input in a document", "% that loads \\usepackage{chemfig}. Everything is placed as it is drawn.", f"\\begin{{tikzpicture}}[x={scale}em,y={scale}em,", " vsk walk/.style={execute at end picture={\\pgfresetboundingbox\\path (0,0);}}]", ] for fragment, mol, walks in figures: left, bottom, right, top = _extent(mol) lines.append(f"% {fragment.node}") lines.append( f"\\path ({_at(left - _LABEL_ROOM * drawing_unit)}," f"{_at(bottom - _LABEL_ROOM * drawing_unit)}) rectangle " f"({_at(right + _LABEL_ROOM * drawing_unit)},{_at(top + _LABEL_ROOM * drawing_unit)});" ) for piece, figure in walks: x, y = figure.start unit = figure.unit if piece.GetNumBonds() else drawing_unit sep = _at(_ATOM_SEP * unit / drawing_unit) lead = f"-[:0,{_LEAD_IN},,{figure.attaches},draw=none]" lines.append( "\\node[inner sep=0pt,outer sep=0pt,anchor=base west] at " f"{_point((x - _LEAD_IN * unit, y))} " f"{{\\chemfig[atom sep={sep}em,chemfig style={{vsk walk}}]" f"{{{lead}{figure.walk}}}}};" ) lines.extend(_tikz_brackets(mol)) lines.extend(_tikz_attachments(mol, drawing_unit)) if fragment.caption: # The first line beside the structure, conditions below, all ending at one edge. left_edge = _point((fragment.bounds[0] - _CAPTION_GAP, fragment.centre[1])) for row, line in enumerate(fragment.caption.split("\n")): shift = f",yshift=-{_at(_CAPTION_LINE * row)}em" if row else "" lines.append(f"\\node[anchor=east{shift}] at {left_edge} {{{_escape(line)}}};") shapes = [ node for node in canvas.get("root", {}).get("nodes", []) if node.get("type") == "simpleObject" ] if shapes: lines.append("% shapes") lines.extend(_tikz_shape(node) for node in shapes) lines.extend(_tikz_pictures(canvas, nanostructures or {})) if scene.arrows: lines.append("% arrows") for arrow in scene.arrows: lines.extend(_tikz_arrow(arrow)) for point in scene.pluses: lines.append(f"\\node at {_point(point)} {{$+$}};") for text in scene.texts: body = "\\\\".join(_escape(line) for line in text.text.split("\n")) lines.append(f"\\node[align=center] at {_point(text.centre)} {{{body}}};") lines.append("\\end{tikzpicture}") return "\n".join(lines) + "\n"