"""The KET bridge: between the canvas engine's JSON and RDKit molecules.
KET is Ketcher's document format. Reading goes KET to a molfile to
RDKit, so wedge bonds become stereo centres and double-bond geometry becomes
E/Z exactly as a molfile reader would see them. KET coordinates have the same
orientation as a molfile's, so no axis flips. Writing goes RDKit to KET with
a bond length of 1.0, the length Ketcher draws with.
Anything the reader cannot turn into a well-defined molecule is reported,
never guessed: query atoms and bonds, R-groups, polymer brackets and
valence errors make a fragment ``unsupported`` with the reason.
"""
from __future__ import annotations
import json
import math
import re
from dataclasses import dataclass, field
from typing import Any
from vibesketch.elements import ATOMIC_NUMBER
__all__ = [
"ARROW_MODES",
"BOND_LENGTH",
"KetAbbreviation",
"KetArrow",
"KetFragment",
"KetScene",
"KetText",
"SceneStep",
"molecule_reason",
"molfile",
"placed_molecule",
"rgroup_node",
"read_scene",
"stereo_reason",
"text_node",
"write_scene",
]
#: KET bond types that are ordinary bonds; the rest are query bonds.
_PLAIN_BONDS = {1, 2, 3, 4}
#: KET bond stereo values, which are the molfile's.
_BOND_STEREO = {0: 0, 1: 1, 3: 3, 4: 4, 6: 6}
#: S-group types that change what the drawing means as a molecule.
_POLYMER_SGROUPS = {"SRU", "COP", "MUL", "GEN"}
[docs]
@dataclass(frozen=True)
class KetAbbreviation:
"""A contracted superatom (SUP) S-group, such as Ph, drawn as its label."""
label: str
#: Its atoms, as indices of the fragment's molecule.
atoms: tuple[int, ...]
[docs]
@dataclass
class KetFragment:
"""One connected molecule on the canvas."""
#: The RDKit molecule with the canvas coordinates, or ``None`` if unsupported.
mol: Any
#: The KET node it came from, for example ``mol0``.
node: str
#: Centre of its atoms, in KET coordinates.
centre: tuple[float, float]
#: Bounding box ``(xmin, ymin, xmax, ymax)``.
bounds: tuple[float, float, float, float]
#: Why the fragment cannot be used as a species, or ``None``.
unsupported: str | None = None
#: Its atoms' indices in the KET node, in the molecule's atom order.
atoms: tuple[int, ...] = ()
#: Contracted abbreviations within it; their atoms stay in ``mol``.
abbreviations: tuple[KetAbbreviation, ...] = ()
#: A molecule to draw in figures when it cannot be a species, such as a
#: repeat unit in brackets; ``None`` when it cannot be drawn either.
figure: Any = None
#: Text figures print to its left, such as ``R1 =`` before an R-group's definition.
caption: str | None = None
[docs]
@dataclass
class KetArrow:
tail: tuple[float, float]
head: tuple[float, float]
mode: str
height: float = 1.0
[docs]
@dataclass
class KetText:
text: str
centre: tuple[float, float]
[docs]
@dataclass
class KetScene:
"""What the canvas holds, in chemical terms."""
fragments: list[KetFragment] = field(default_factory=list)
arrows: list[KetArrow] = field(default_factory=list)
pluses: list[tuple[float, float]] = field(default_factory=list)
texts: list[KetText] = field(default_factory=list)
# ── Reading ─────────────────────────────────────────────────────────────
[docs]
def molfile(atoms: list[dict[str, Any]], bonds: list[dict[str, Any]]) -> str:
"""A molfile for KET atoms and bonds, in their order; callers check
:func:`molecule_reason` first."""
if len(atoms) > 999 or len(bonds) > 999 or any(a.get("mapping", 0) > 999 for a in atoms):
return _v3000(atoms, bonds)
lines = ["", " vibesketch", ""]
lines.append(f"{len(atoms):3d}{len(bonds):3d} 0 0 0 0 0 0 0 0999 V2000")
charges: list[tuple[int, int]] = []
radicals: list[tuple[int, int]] = []
isotopes: list[tuple[int, int]] = []
for index, atom in enumerate(atoms, start=1):
x, y, z = (float(v) for v in (list(atom.get("location", [0, 0, 0])) + [0, 0, 0])[:3])
valence = atom.get("explicitValence")
valence_field = 0 if valence is None or valence < 0 else (15 if valence == 0 else valence)
lines.append(
f"{x:10.4f}{y:10.4f}{z:10.4f} {atom['label']:<3} 0 0 0 0 0{valence_field:3d}"
f" 0 0 0{int(atom.get('mapping', 0)):3d} 0 0"
)
if atom.get("charge"):
charges.append((index, int(atom["charge"])))
if atom.get("radical"):
radicals.append((index, int(atom["radical"])))
if atom.get("isotope"):
isotopes.append((index, int(atom["isotope"])))
for bond in bonds:
first, second = (int(i) + 1 for i in bond["atoms"])
stereo = _BOND_STEREO.get(int(bond.get("stereo", 0)), 0)
lines.append(f"{first:3d}{second:3d}{int(bond['type']):3d}{stereo:3d}")
for tag, values in (("CHG", charges), ("RAD", radicals), ("ISO", isotopes)):
for start in range(0, len(values), 8):
chunk = values[start : start + 8]
body = "".join(f" {i:3d} {v:3d}" for i, v in chunk)
lines.append(f"M {tag}{len(chunk):3d}{body}")
lines.append("M END")
return "\n".join(lines) + "\n"
def _v3000(atoms: list[dict[str, Any]], bonds: list[dict[str, Any]]) -> str:
"""The same KET properties without V2000's three-column count limits.
MDL, CTfile Formats (December 1999), chapter 10, tables 10-1 and 10-2:
https://mdli.com/downloads/literature/ctfile.pdf
"""
lines = [
"", " vibesketch", "", " 0 0 0 0 0 999 V3000",
"M V30 BEGIN CTAB", f"M V30 COUNTS {len(atoms)} {len(bonds)} 0 0 0",
"M V30 BEGIN ATOM",
] # fmt: skip
for index, atom in enumerate(atoms, start=1):
x, y, z = (float(v) for v in (list(atom.get("location", [0, 0, 0])) + [0, 0, 0])[:3])
properties = ""
for key, tag in (("charge", "CHG"), ("radical", "RAD"), ("isotope", "MASS")):
if atom.get(key):
properties += f" {tag}={int(atom[key])}"
valence = atom.get("explicitValence")
if valence is not None and valence >= 0:
properties += f" VAL={-1 if valence == 0 else int(valence)}"
lines.append(
f"M V30 {index} {atom['label']} {x:.4f} {y:.4f} {z:.4f} "
f"{int(atom.get('mapping', 0))}{properties}"
)
lines.extend(["M V30 END ATOM", "M V30 BEGIN BOND"])
# KET uses V2000 stereo: up=1, down=6, either single=4 or double=3.
configuration = {1: 1, 6: 3, 4: 2, 3: 2}
for index, bond in enumerate(bonds, start=1):
first, second = (int(i) + 1 for i in bond["atoms"])
stereo = configuration.get(int(bond.get("stereo", 0)), 0)
properties = f" CFG={stereo}" if stereo else ""
lines.append(f"M V30 {index} {int(bond['type'])} {first} {second}{properties}")
lines.extend(["M V30 END BOND", "M V30 END CTAB", "M END"])
return "\n".join(lines) + "\n"
def _read_failure(block: str) -> str:
"""Distinguish malformed molfile data from a specific chemistry refusal."""
from rdkit import Chem
try:
unsanitized = Chem.MolFromMolBlock(block, sanitize=False, removeHs=False)
if unsanitized is not None:
Chem.SanitizeMol(unsanitized)
except (ValueError, RuntimeError) as error:
return f"RDKit refused the structure: {error}"
return "RDKit could not parse the KET molfile"
[docs]
def stereo_reason(molecule: dict[str, Any]) -> str | None:
"""Why the drawing's stereo cannot describe one absolute species, or ``None``."""
for index, atom in enumerate(molecule.get("atoms", [])):
label = atom.get("stereoLabel")
if label not in (None, "", "abs"):
return (
f"atom {index} uses relative or mixture stereochemistry ({label}); "
"choose an explicit absolute stereoisomer before calculation or export"
)
return None
[docs]
def molecule_reason(molecule: dict[str, Any]) -> str | None:
"""Why a KET molecule cannot be read as a plain structure, or ``None``."""
if reason := stereo_reason(molecule):
return reason
polymer = [g for g in molecule.get("sgroups", []) if g.get("type") in _POLYMER_SGROUPS]
ends = (
_chain_ends(list(molecule.get("atoms", [])), list(molecule.get("bonds", [])), polymer)
if polymer
else set()
)
for index, atom in enumerate(molecule.get("atoms", [])):
if index in ends:
continue # an open chain end, judged with its bracket below
mapping = atom.get("mapping", 0)
if type(mapping) is not int or not 0 <= mapping <= 999999999:
return f"atom {index} has an unsupported atom-map number"
kind = atom.get("type", "atom")
if kind != "atom":
return f"atom {index} is a query or R-group atom ({kind})"
label = atom.get("label")
if (named := attachment_label(atom)) is not None:
return (
f"atom {index} is the label {named!r}, which stands for a material: "
"a generic structure, not a species"
)
if label not in ATOMIC_NUMBER:
return f"atom {index} has the label {label!r}, which is not an element"
for index, bond in enumerate(molecule.get("bonds", [])):
if int(bond.get("type", 1)) not in _PLAIN_BONDS:
return f"bond {index} is a query bond (type {bond.get('type')})"
for sgroup in molecule.get("sgroups", []):
if sgroup.get("type") in _POLYMER_SGROUPS:
kind = sgroup.get("type")
return f"it carries a {kind} bracket, which a single structure cannot express"
return None
#: How far a bracket reaches either side of the bond it crosses, and what each
#: connectivity prints beside it (head-to-tail is the default).
_BRACKET = 0.5
_CONNECTIVITY = {"HT": None, "HH": "hh", "EU": "eu"}
#: The label after a copolymer's closing bracket, per KET subtype: the
#: connectives of "Source-based nomenclature for copolymers", IUPAC
#: Recommendations 1985, Pure Appl. Chem. 57, 1427 (doi:10.1351/pac198557101427).
_COPOLYMER = {"": "co", "ALT": "alt", "RAN": "ran", "BLO": "block"}
#: How a bracket around another is set apart: further along the crossed bond
#: (as a fraction of it) and taller, or further out around the atoms.
_NESTED_ALONG = 0.3
_NESTED_TALLER = 0.6
_NESTED_AROUND = 0.3
#: How far a bracket around a group with no bonds leaving it stands off its atoms.
_AROUND = 0.4
def _bracket_label(group: dict[str, Any]) -> tuple[dict[str, str], str | None]:
"""The RDKit ``LABEL`` and ``CONNECT`` of one KET bracket, or why it has none."""
kind = group.get("type")
props: dict[str, str] = {}
if kind == "MUL":
count = group.get("mul", 1)
if not isinstance(count, int) or isinstance(count, bool) or count < 1:
return props, f"its multiple group has the multiplier {count!r}, not a whole number"
props["LABEL"] = str(count)
return props, None
if kind == "GEN":
return props, None
if kind == "COP":
subtype = str(group.get("subtype") or "").upper()
if subtype not in _COPOLYMER:
return props, f"its copolymer has the unknown subtype {subtype!r}"
props["LABEL"] = _COPOLYMER[subtype]
else:
props["LABEL"] = str(group.get("subscript") or "n")
connectivity = str(group.get("connectivity") or "HT").upper()
if connectivity not in _CONNECTIVITY:
name = "copolymer" if kind == "COP" else "repeat unit"
return props, f"its {name} has the unknown connectivity {connectivity!r}"
if label := _CONNECTIVITY[connectivity]:
props["CONNECT"] = label
return props, None
def _chain_ends(
atoms: list[dict[str, Any]], bonds: list[dict[str, Any]], groups: list[dict[str, Any]]
) -> set[int]:
"""The ``*`` atoms that end a polymer chain: outside every bracket, one bond into one.
A repeat unit is drawn as –[CH2–CH(Ph)]n–, its bonds crossing the
brackets to open ends; molfiles and CXSMILES write those ends as ``*``.
"""
inside = {int(a) for g in groups for a in g.get("atoms", [])}
ends: set[int] = set()
for index, atom in enumerate(atoms):
if index in inside or atom.get("label") != "*" or atom.get("alias"):
continue
neighbours = [
int(b["atoms"][1] if int(b["atoms"][0]) == index else b["atoms"][0])
for b in bonds
if index in (int(b["atoms"][0]), int(b["atoms"][1]))
]
if len(neighbours) == 1 and neighbours[0] in inside:
ends.add(index)
return ends
def _brackets(molecule: dict[str, Any]) -> tuple[Any, str | None]:
"""The molecule with its polymer brackets for figures, or why it cannot be drawn.
Only a structure whose sole problem is brackets is drawn: repeat units
(SRU), copolymers (COP), multiple groups (MUL) and generic brackets (GEN).
A bracket crosses each of the two bonds leaving its group, with the label
after the closing one, as "Graphic representations (chemical formulae) of
macromolecules", IUPAC Recommendations 1994, Pure Appl. Chem. 66, 2469
(doi:10.1351/pac199466122469) draws them; a group with no bond leaving it
is bracketed on its left and right. A bracket around another stands
further out, so a copolymer's encloses its repeat units'.
"""
from rdkit import Chem, rdBase
from rdkit.Geometry import Point3D
groups = [g for g in molecule.get("sgroups", []) if g.get("type") in _POLYMER_SGROUPS]
if not groups:
return None, None
atoms, bonds = list(molecule.get("atoms", [])), list(molecule.get("bonds", []))
ends = _chain_ends(atoms, bonds, groups)
plain = {
**{k: v for k, v in molecule.items() if k != "sgroups"},
"atoms": [{**a, "label": "C"} if i in ends else a for i, a in enumerate(atoms)],
}
if molecule_reason(plain) is not None:
return None, None
quiet = rdBase.BlockLogs()
mol = Chem.MolFromMolBlock(molfile(atoms, bonds), sanitize=True, removeHs=False)
del quiet
if mol is None:
return None, None
for index in ends:
# An open chain end: the bond leaves the bracket and ends, with no label (#145).
end = mol.GetAtomWithIdx(index)
end.SetProp("polymerEnd", "1")
end.SetProp("_displayLabel", "")
end.SetProp("_displayLabelW", "")
conformer = mol.GetConformer()
every = set(range(mol.GetNumAtoms()))
members: list[frozenset[int]] = []
crossings: list[list[Any]] = []
for group in groups:
found = frozenset(int(a) for a in group.get("atoms", []))
name = "repeat unit" if group.get("type") == "SRU" else f"{group.get('type')} bracket"
if not found or not found <= every:
return None, f"its {name} names atoms the molecule does not have"
crossing = [
b
for b in mol.GetBonds()
if (b.GetBeginAtomIdx() in found) != (b.GetEndAtomIdx() in found)
]
allowed = (2,) if group.get("type") == "SRU" else (0, 2)
if len(crossing) not in allowed:
count = f"{len(crossing)} bond{'' if len(crossing) == 1 else 's'}"
needs = "two" if allowed == (2,) else "none or two"
return None, f"its {name} has {count} leaving it, and a bracket needs {needs}"
members.append(found)
crossings.append(crossing)
for i, first in enumerate(members):
for second in members[i + 1 :]:
if first == second:
return None, "two of its brackets enclose the same atoms"
if first & second and not (first <= second or second <= first):
return None, "two of its brackets overlap without one enclosing the other"
for index, group in enumerate(groups):
props, why = _bracket_label(group)
if why:
return None, why
found = members[index]
inner = [j for j, other in enumerate(members) if other < found]
bracket = Chem.CreateMolSubstanceGroup(mol, str(group["type"]))
for atom in sorted(found):
bracket.AddAtomWithIdx(atom)
for key, value in props.items():
bracket.SetProp(key, value)
for bond in crossings[index]:
bracket.AddBondWithIdx(bond.GetIdx())
inside, outside = bond.GetBeginAtomIdx(), bond.GetEndAtomIdx()
if inside not in found:
inside, outside = outside, inside
start, end = conformer.GetAtomPosition(inside), conformer.GetAtomPosition(outside)
length = math.hypot(end.x - start.x, end.y - start.y)
if length < 1e-8:
return None, "a bracket crosses a bond of zero length"
nested = sum(1 for j in inner if bond.GetIdx() in {b.GetIdx() for b in crossings[j]})
if nested > 1:
return None, "three of its brackets cross one bond, and a figure can set apart two"
along = 0.5 + _NESTED_ALONG * nested
mx, my = start.x + (end.x - start.x) * along, start.y + (end.y - start.y) * along
reach = _BRACKET * (1 + _NESTED_TALLER * nested)
px, py = -(end.y - start.y) / length * reach, (end.x - start.x) / length * reach
# RDKit's bracket is three points; the third is unused for a straight one.
first, second = Point3D(mx + px, my + py, 0), Point3D(mx - px, my - py, 0)
bracket.AddBracket((first, second, Point3D(0, 0, 0)))
if not crossings[index]:
xs = [conformer.GetAtomPosition(a).x for a in found]
ys = [conformer.GetAtomPosition(a).y for a in found]
pad = _AROUND + _NESTED_AROUND * len(inner)
low, high = min(ys) - pad, max(ys) + pad
for x in (min(xs) - pad, max(xs) + pad):
bracket.AddBracket((Point3D(x, high, 0), Point3D(x, low, 0), Point3D(0, 0, 0)))
return mol, None
#: KET's attachment point values and RDKit's ``molAttchpt`` for them: first,
#: second, or both on one atom.
_ATTACHMENTS = {1: 1, 2: 2, 3: -1}
def _r_groups(molecule: dict[str, Any], *, definition: bool = False) -> tuple[Any, str | None]:
"""The molecule with its R-group atoms labelled for figures, or why it cannot be drawn.
An R-group atom becomes a dummy atom labelled ``R1`` (``R`` with no
number); a definition's attachment points become dummy atoms that
figures draw as wavy attachment bonds. Only a structure whose sole
problem is R-groups is drawn. ``(None, None)`` means nothing here applies.
"""
from rdkit import Chem, rdBase
atoms = list(molecule.get("atoms", []))
labels: dict[int, str] = {}
for index, atom in enumerate(atoms):
if atom.get("type") != "rg-label":
continue
refs = [str(r) for r in atom.get("$refs", [])]
if len(refs) > 1:
names = ", ".join(r.replace("rg-", "R") for r in refs)
return None, f"atom {index} stands for several R-groups ({names}), one label cannot"
number = refs[0].removeprefix("rg-") if refs else ""
if number and not number.isdigit():
return None, f"atom {index} names the unknown R-group {refs[0]!r}"
labels[index] = f"R{number}"
points = {
i: _ATTACHMENTS[int(a["attachmentPoints"])]
for i, a in enumerate(atoms)
if int(a.get("attachmentPoints") or 0) in _ATTACHMENTS
}
if not labels and not (definition and points):
return None, None
if definition and labels:
return None, "an R-group inside an R-group's definition cannot be drawn yet"
if any(g.get("type") in _POLYMER_SGROUPS for g in molecule.get("sgroups", [])):
return None, None
# Read the R-group atoms as carbons to find any other problem, then as dummies.
plain = [{**a, "type": "atom", "label": "C"} if i in labels else a for i, a in enumerate(atoms)]
if molecule_reason({**molecule, "atoms": plain}) is not None:
return None, None
dummies = [{**a, "label": "R#"} if i in labels else a for i, a in enumerate(plain)]
quiet = rdBase.BlockLogs()
mol = Chem.MolFromMolBlock(
molfile(dummies, list(molecule.get("bonds", []))), sanitize=True, removeHs=False
)
del quiet
if mol is None:
return None, None
for index, label in labels.items():
mol.GetAtomWithIdx(index).SetProp("dummyLabel", label)
if definition:
for index, point in points.items():
mol.GetAtomWithIdx(index).SetIntProp("molAttchpt", point)
mol = Chem.RWMol(mol)
Chem.ExpandAttachmentPoints(mol, addAsQueries=False)
mol = mol.GetMol()
return mol, None
#: Atom labels that are queries, not names of a material (MDL CTfile and Ketcher).
_QUERY_LABELS = frozenset({"A", "AH", "Q", "QH", "X", "XH", "M", "MH", "L", "L#", "R#", "D", "T"})
#: What a labelled attachment atom may say: CNT, Au, NP, SiO2, GC electrode.
_ATTACHMENT = re.compile(r"^[A-Za-z0-9*][A-Za-z0-9 ()+._/-]{0,23}$")
def attachment_label(atom: dict[str, Any]) -> str | None:
"""The label a KET atom shows for a material it stands for, or ``None`` (#139).
That is Ketcher's free-text atom label (a pseudo-atom, such as ``CNT``),
or ``*`` or ``A`` with an alias, as a molfile writes a pseudo-atom (``Au``
for a gold surface rather than a gold atom). A bare ``*`` is an
attachment with no label.
"""
if atom.get("type", "atom") != "atom":
return None
label = str(atom.get("label", ""))
alias = str(atom.get("alias") or "").strip()
if label in ("*", "A") and alias:
return alias if _ATTACHMENT.fullmatch(alias) else None
if label == "*":
return "*"
if label in ATOMIC_NUMBER or label in _QUERY_LABELS or re.fullmatch(r"R\d*", label):
return None
return label if _ATTACHMENT.fullmatch(label) else None
def _labelled(molecule: dict[str, Any]) -> tuple[Any, str | None]:
"""A figure of a structure bonded to labelled attachment atoms (#139).
Each such atom becomes a dummy atom carrying its label (``dummyLabel``
for figures, ``molFileAlias`` for molfiles); the rest must be a plain
structure. ``(None, None)`` means nothing here applies.
"""
from rdkit import Chem, rdBase
atoms = list(molecule.get("atoms", []))
labels = {i: label for i, a in enumerate(atoms) if (label := attachment_label(a)) is not None}
if not labels:
return None, None
if any(g.get("type") in _POLYMER_SGROUPS for g in molecule.get("sgroups", [])):
return None, None
plain = [
{k: v for k, v in {**a, "label": "C"}.items() if k != "alias"} if i in labels else a
for i, a in enumerate(atoms)
]
if molecule_reason({**molecule, "atoms": plain}) is not None:
return None, None
for index in labels:
bonds = [b for b in molecule.get("bonds", []) if index in b["atoms"]]
if len(bonds) > 1 or any(int(b.get("type", 1)) != 1 for b in bonds):
return None, (
f"the label {labels[index]!r} (atom {index}) must have one single bond to the "
"structure it carries"
)
dummies = [{**a, "label": "*"} if i in labels else a for i, a in enumerate(plain)]
quiet = rdBase.BlockLogs()
mol = Chem.MolFromMolBlock(
molfile(dummies, list(molecule.get("bonds", []))), sanitize=True, removeHs=False
)
del quiet
if mol is None:
return None, None
for index, label in labels.items():
atom = mol.GetAtomWithIdx(index)
atom.SetProp("dummyLabel", label)
atom.SetProp("attachmentLabel", label)
atom.SetProp("molFileAlias", label)
# Printed as written on either side: RDKit would reverse "CNT" to "TNC"
# on a bond leaving left, as it does a formula such as OH.
shown = re.sub(r"(?<=[A-Za-z)])(\d+)", r"<sub>\1</sub>", label)
atom.SetProp("_displayLabel", shown)
atom.SetProp("_displayLabelW", shown)
return mol, None
#: One item of an R-group's occurrence range: a count, a span or a bound,
#: as in the MDL CTfile's R-group logic line.
_OCCURRENCE = re.compile(r"\s*(?:[<>=]?\s*\d+|\d+\s*-\s*\d+)\s*")
def _r_logic(name: str, logic: dict[str, Any]) -> tuple[list[str], str | None]:
"""The lines that print an R-group's conditions, or why they cannot be printed.
The wording is Ketcher's, so a figure says what the canvas shows: the
occurrence range (``R1=1-3``, a bound such as ``R1>2`` as written),
``(RestH)`` when unsubstituted positions must be hydrogen, and
``IF … THEN R2`` when the group requires another. With none of them set
nothing is printed; Ketcher's ``R1>0`` then only says the group occurs.
"""
occurrence = str(logic.get("range") or "").strip()
rest = logic.get("resth") is True
then = logic.get("ifthen") or 0
if not isinstance(then, int) or isinstance(then, bool) or then < 0:
return [], f"{name}'s if-then condition names {then!r}, not an R-group number"
if not (occurrence or rest or then):
return [], None
if occurrence and not all(_OCCURRENCE.fullmatch(item) for item in occurrence.split(",")):
return [], f"{name}'s occurrence {occurrence!r} is not a count, span or bound"
if occurrence:
occurrence = re.sub(r"\s+", "", occurrence)
occurrence = occurrence if occurrence[0] in "<>=" else f"={occurrence}"
line = f"{'IF ' if then else ''}{name}{occurrence or '>0'}{' (RestH)' if rest else ''}"
return [line, f"THEN R{then}"] if then else [line], None
def _definition(node: str, molecule: dict[str, Any]) -> list[KetFragment]:
"""An R-group's definition: its substituents, drawn after ``R1 =`` in figures."""
atoms = list(molecule.get("atoms", []))
if not atoms:
return []
logic = molecule.get("rlogic") or {}
name = f"R{logic.get('number', '')}"
reason = f"it is the definition of the R-group {name}, not one structure"
figure: Any = None
conditions, why = _r_logic(name, logic)
if why is None:
figure, why = _r_groups(molecule, definition=True)
if figure is None and why is None:
why = "its substituents are not plain structures with attachment points"
if why:
reason = f"{reason}; {why}"
if figure is not None:
conformer = figure.GetConformer()
points = [(float(p[0]), float(p[1])) for p in conformer.GetPositions()]
else:
points = [(float(a["location"][0]), float(a["location"][1])) for a in atoms]
box = _bounds(points)
centre = ((box[0] + box[2]) / 2, (box[1] + box[3]) / 2)
everything = tuple(range(len(atoms)))
return [
KetFragment(
None,
node,
centre,
box,
reason,
everything,
figure=figure,
caption="\n".join([f"{name} =", *conditions]),
)
]
def _bounds(points: list[tuple[float, float]]) -> tuple[float, float, float, float]:
xs = [p[0] for p in points] or [0.0]
ys = [p[1] for p in points] or [0.0]
return (min(xs), min(ys), max(xs), max(ys))
def _components(count: int, bonds: list[dict[str, Any]]) -> list[list[int]]:
"""The connected pieces of a KET molecule's atoms, each in atom order."""
parent = list(range(count))
def root(i: int) -> int:
while parent[i] != i:
parent[i] = parent[parent[i]]
i = parent[i]
return i
for bond in bonds:
first, second = (int(a) for a in bond["atoms"])
if not (0 <= first < count and 0 <= second < count):
return [list(range(count))] # malformed: kept whole, so its own reason stands
parent[root(first)] = root(second)
pieces: dict[int, list[int]] = {}
for atom in range(count):
pieces.setdefault(root(atom), []).append(atom)
return sorted(pieces.values())
def _part(molecule: dict[str, Any], members: list[int]) -> dict[str, Any]:
"""The KET molecule of ``members``: their atoms, bonds and the S-groups within them."""
new = {old: index for index, old in enumerate(members)}
part: dict[str, Any] = {
"type": molecule.get("type", "molecule"),
"atoms": [molecule["atoms"][i] for i in members],
"bonds": [
{**bond, "atoms": [new[int(a)] for a in bond["atoms"]]}
for bond in molecule.get("bonds", [])
if all(int(a) in new for a in bond["atoms"])
],
}
sgroups = []
for sgroup in molecule.get("sgroups", []):
atoms = [int(a) for a in sgroup.get("atoms", [])]
if atoms and all(a in new for a in atoms):
moved = {**sgroup, "atoms": [new[a] for a in atoms]}
if "attachmentPoints" in sgroup:
moved["attachmentPoints"] = [
{**point, "attachmentAtom": new[int(point["attachmentAtom"])]}
for point in sgroup["attachmentPoints"]
if int(point.get("attachmentAtom", -1)) in new
]
sgroups.append(moved)
if sgroups:
part["sgroups"] = sgroups
return part
def _fragments(node: str, molecule: dict[str, Any]) -> list[KetFragment]:
from rdkit import Chem
atoms: list[dict[str, Any]] = list(molecule.get("atoms", []))
bonds: list[dict[str, Any]] = list(molecule.get("bonds", []))
points = [(float(a["location"][0]), float(a["location"][1])) for a in atoms]
if not atoms:
return []
reason = molecule_reason(molecule)
if reason is None:
from rdkit import rdBase
_quiet = rdBase.BlockLogs() # a refused molecule is reported, not logged
block = molfile(atoms, bonds)
mol = Chem.MolFromMolBlock(block, sanitize=True, removeHs=False)
if mol is None:
reason = _read_failure(block)
else:
from vibesketch.chem.species import inert_pair_radicals
# A radical the canvas draws is kept; RDKit's guess for Pb²⁺ is not (#147).
inert_pair_radicals(mol, lambda index: bool(atoms[index].get("radical")))
del _quiet
if reason is not None and len(components := _components(len(atoms), bonds)) > 1:
# One refused piece, such as a polymer read from a MOL file beside other
# molecules, does not take the others with it: each is read on its own.
result: list[KetFragment] = []
for members in components:
for fragment in _fragments(node, _part(molecule, members)):
fragment.atoms = tuple(members[i] for i in fragment.atoms)
result.append(fragment)
return result
if reason is not None:
box = _bounds(points)
centre = ((box[0] + box[2]) / 2, (box[1] + box[3]) / 2)
figure, why = _brackets(molecule)
if figure is None and why is None:
figure, why = _r_groups(molecule)
if figure is None and why is None:
figure, why = _labelled(molecule)
if why:
reason = f"{reason}; {why}"
everything = tuple(range(len(atoms)))
return [KetFragment(None, node, centre, box, reason, everything, figure=figure)]
pieces = Chem.GetMolFrags(mol, asMols=True, sanitizeFrags=True)
# The same order as the molecules above, as RDKit documents.
indices = Chem.GetMolFrags(mol, asMols=False, sanitizeFrags=False)
result = []
for piece, members in zip(pieces, indices, strict=True):
conformer = piece.GetConformer()
coords = [
(conformer.GetAtomPosition(i).x, conformer.GetAtomPosition(i).y)
for i in range(piece.GetNumAtoms())
]
box = _bounds(coords)
centre = (sum(c[0] for c in coords) / len(coords), sum(c[1] for c in coords) / len(coords))
ket_atoms = tuple(int(i) for i in members)
abbreviations = _abbreviations(molecule, ket_atoms)
result.append(KetFragment(piece, node, centre, box, None, ket_atoms, abbreviations))
return result
def _abbreviations(
molecule: dict[str, Any], members: tuple[int, ...]
) -> tuple[KetAbbreviation, ...]:
"""The contracted SUP S-groups wholly inside the fragment of KET atoms ``members``.
A group that is expanded, unnamed, or reaches outside the fragment is left
out, so its atoms are drawn as atoms: the structure is never hidden.
"""
position = {atom: index for index, atom in enumerate(members)}
found = []
for sgroup in molecule.get("sgroups", []):
if sgroup.get("type") != "SUP" or sgroup.get("expanded") is True:
continue
label = str(sgroup.get("name") or "").strip()
atoms = [int(a) for a in sgroup.get("atoms", [])]
if label and atoms and all(a in position for a in atoms):
found.append(KetAbbreviation(label, tuple(sorted(position[a] for a in atoms))))
return tuple(found)
def _text_of(node: dict[str, Any]) -> str:
"""The text of a KET text node, in the KET 2.0 or the legacy form."""
paragraphs = node.get("paragraphs")
if isinstance(paragraphs, list):
lines = [
"".join(str(part.get("text", "")) for part in paragraph.get("parts", []))
for paragraph in paragraphs
]
return "\n".join(lines).strip()
content = node.get("data", {}).get("content")
if isinstance(content, str):
try:
blocks = json.loads(content).get("blocks", [])
return "\n".join(str(block.get("text", "")) for block in blocks).strip()
except (ValueError, AttributeError):
return content.strip()
return ""
def _text_centre(node: dict[str, Any]) -> tuple[float, float]:
box = node.get("boundingBox")
if isinstance(box, dict):
return (
float(box["x"]) + float(box.get("width", 0)) / 2,
float(box["y"]) - float(box.get("height", 0)) / 2,
)
position = node.get("data", {}).get("position") or node.get("data", {}).get("pos", [{}])[0]
return (float(position.get("x", 0)), float(position.get("y", 0)))
[docs]
def read_scene(ket: dict[str, Any]) -> KetScene:
"""The molecules, arrows, plus signs and text on a KET canvas."""
scene = KetScene()
root = ket.get("root", {})
for node in root.get("nodes", []):
if "$ref" in node:
molecule = ket.get(node["$ref"])
if isinstance(molecule, dict) and molecule.get("type", "molecule") == "molecule":
scene.fragments.extend(_fragments(node["$ref"], molecule))
elif isinstance(molecule, dict) and molecule.get("type") == "rgroup":
scene.fragments.extend(_definition(node["$ref"], molecule))
continue
kind = node.get("type")
if kind == "arrow":
pos = node["data"]["pos"]
scene.arrows.append(
KetArrow(
(float(pos[0]["x"]), float(pos[0]["y"])),
(float(pos[1]["x"]), float(pos[1]["y"])),
str(node["data"].get("mode", "open-angle")),
float(node["data"].get("height", 1.0)),
)
)
elif kind == "plus":
location = node.get("location", [0, 0])
scene.pluses.append((float(location[0]), float(location[1])))
elif kind == "text":
text = _text_of(node)
if text:
scene.texts.append(KetText(text, _text_centre(node)))
return scene
# ── Writing ─────────────────────────────────────────────────────────────
#: The bond length Ketcher draws with, in KET units.
BOND_LENGTH = 1.0
#: Horizontal gap between laid-out items, in bond lengths.
GAP = 1.5
def _prepared(mol: Any) -> Any:
"""A copy without unneeded hydrogens, with 2D coordinates, Kekulé bonds and wedges."""
from rdkit import Chem, rdBase
from vibesketch.chem.layout import compute_2d
if any(
group.GetGroupType() != Chem.StereoGroupType.STEREO_ABSOLUTE
for group in mol.GetStereoGroups()
):
raise ValueError(
"relative or mixture stereochemistry cannot become one absolute canvas species"
)
blocker = rdBase.BlockLogs() # "not removing hydrogen atom without neighbors"
copy = Chem.RemoveHs(Chem.Mol(mol), sanitize=True)
del blocker
# Layout needs the E/Z assignment before it chooses atom coordinates.
Chem.AssignStereochemistry(copy, cleanIt=True, force=True)
for centre in Chem.FindPotentialStereo(copy):
if (
centre.type == Chem.StereoType.Bond_Double
and centre.specified == Chem.StereoSpecified.Unspecified
):
# A crossed KET double bond prevents layout from inventing E/Z.
copy.GetBondWithIdx(centre.centeredOn).SetStereo(Chem.BondStereo.STEREOANY)
if copy.GetNumConformers() == 0 or copy.GetConformer().Is3D():
compute_2d(copy)
Chem.Kekulize(copy, clearAromaticFlags=True)
Chem.WedgeMolBonds(copy, copy.GetConformer())
return copy
def _scale(mol: Any) -> float:
# All positions in one call: one Point3D per atom is far slower for large molecules.
positions = mol.GetConformer().GetPositions().tolist()
lengths = [
math.dist(positions[b.GetBeginAtomIdx()][:2], positions[b.GetEndAtomIdx()][:2])
for b in mol.GetBonds()
]
mean = sum(lengths) / len(lengths) if lengths else 1.5
return BOND_LENGTH / mean if mean > 1e-6 else 1.0
#: RDKit's ``molAttchpt`` as KET's attachment point value.
_ATTACHMENTS_BACK = {1: 1, 2: 2, -1: 3}
def _r_number(atom: Any) -> int | None:
"""An R-group atom's number, 0 when it has none, or ``None`` for any other atom."""
if atom.GetAtomicNum() != 0:
return None
if atom.HasProp("_MolFileRLabel"):
return int(atom.GetIntProp("_MolFileRLabel"))
label = atom.GetProp("dummyLabel") if atom.HasProp("dummyLabel") else ""
if label == "R" or label == "R#":
return 0
return int(label[1:]) if label[:1] == "R" and label[1:].isdigit() else None
def _attachment_of(atom: Any) -> str | None:
"""The label of an RDKit dummy atom that stands for a material, or ``None`` (#139).
A molfile writes one as ``A`` or ``*`` with an alias, which RDKit keeps as
``molFileAlias``; a SMILES ``*`` has no label.
"""
if atom.GetAtomicNum() != 0:
return None
if atom.HasProp("attachmentLabel"):
return str(atom.GetProp("attachmentLabel"))
if atom.HasProp("molFileAlias"):
return attachment_label({"label": "*", "alias": str(atom.GetProp("molFileAlias"))})
label = atom.GetProp("dummyLabel") if atom.HasProp("dummyLabel") else ""
return "*" if label == "*" and not atom.HasProp("_MolFileRLabel") else None
[docs]
def rgroup_node(
number: int, logic: dict[str, Any], members: list[Any], origin: tuple[float, float]
) -> tuple[dict[str, Any], float]:
"""A KET R-group definition of ``members`` in a row from ``origin``; it and its width."""
atoms: list[dict[str, Any]] = []
bonds: list[dict[str, Any]] = []
x = origin[0]
for index, member in enumerate(members):
if index:
x += GAP
molecule, width = _ket_molecule(member, (x, origin[1]))
offset = len(atoms)
atoms.extend(molecule["atoms"])
bonds.extend(
{**bond, "atoms": [a + offset for a in bond["atoms"]]} for bond in molecule["bonds"]
)
x += width
node = {
"type": "rgroup",
"rlogic": {"number": number, **logic},
"atoms": atoms,
"bonds": bonds,
}
return node, x - origin[0]
def _ket_molecule(mol: Any, origin: tuple[float, float]) -> tuple[dict[str, Any], float]:
"""A KET molecule placed with its left edge at ``origin``; returns it and its width."""
prepared = _prepared(mol)
factor = _scale(prepared)
conformer = prepared.GetConformer()
raw = [
(conformer.GetAtomPosition(i).x * factor, conformer.GetAtomPosition(i).y * factor)
for i in range(prepared.GetNumAtoms())
]
xmin, ymin, xmax, ymax = _bounds(raw)
dx, dy = origin[0] - xmin, origin[1] - (ymin + ymax) / 2
return _ket_entries(prepared, [(x + dx, y + dy) for x, y in raw]), xmax - xmin
[docs]
def placed_molecule(mol: Any, scale: float = 1.0) -> dict[str, Any]:
"""A KET molecule where ``mol``'s 2D coordinates put it, multiplied by ``scale``.
For structures whose drawing is kept, such as those read from CDXML.
"""
prepared = _prepared(mol)
conformer = prepared.GetConformer()
points = [
(conformer.GetAtomPosition(i).x * scale, conformer.GetAtomPosition(i).y * scale)
for i in range(prepared.GetNumAtoms())
]
return _ket_entries(prepared, points)
def _ket_entries(prepared: Any, points: list[tuple[float, float]]) -> dict[str, Any]:
"""The KET atoms and bonds of a :func:`_prepared` molecule at ``points``."""
from rdkit import Chem
if not all(math.isfinite(v) for point in points for v in point):
# Never hand the canvas a NaN: Ketcher drops the whole document.
raise ValueError("2D layout produced non-finite coordinates")
atoms: list[dict[str, Any]] = []
for atom, (x, y) in zip(prepared.GetAtoms(), points, strict=True):
location = [round(x, 6), round(y, 6), 0.0]
named = _attachment_of(atom)
if named is not None:
# A material the structure is bonded to (#139): Ketcher's pseudo-atom
# label, or an alias where the label is an element's symbol (Au).
pseudo = attachment_label({"label": named}) == named
atoms.append(
{"label": named, "location": location}
if pseudo
else {"label": "A", "alias": named, "location": location}
)
continue
number = _r_number(atom)
if number is not None:
# An R-group atom, as a molfile's R# with its RGROUPS (#90).
atoms.append(
{"type": "rg-label", "location": location, "$refs": [f"rg-{number}"]}
if number
else {"type": "rg-label", "location": location}
)
continue
entry: dict[str, Any] = {"label": atom.GetSymbol(), "location": location}
if atom.GetAtomMapNum():
entry["mapping"] = atom.GetAtomMapNum()
if atom.HasProp("molAttchpt"):
point = _ATTACHMENTS_BACK.get(atom.GetIntProp("molAttchpt"))
if point:
entry["attachmentPoints"] = point
if atom.GetFormalCharge():
entry["charge"] = atom.GetFormalCharge()
if atom.GetIsotope():
entry["isotope"] = atom.GetIsotope()
from vibesketch.chem.species import unpaired
if unpaired(atom):
entry["radical"] = 1 + unpaired(atom)
atoms.append(entry)
bonds: list[dict[str, Any]] = []
for bond in prepared.GetBonds():
order = {
Chem.BondType.SINGLE: 1,
Chem.BondType.DOUBLE: 2,
Chem.BondType.TRIPLE: 3,
Chem.BondType.AROMATIC: 4,
}.get(bond.GetBondType(), 1)
entry = {"type": order, "atoms": [bond.GetBeginAtomIdx(), bond.GetEndAtomIdx()]}
direction = bond.GetBondDir()
if direction == Chem.BondDir.BEGINWEDGE:
entry["stereo"] = 1
elif direction == Chem.BondDir.BEGINDASH:
entry["stereo"] = 6
elif direction == Chem.BondDir.UNKNOWN:
entry["stereo"] = 4
elif bond.GetStereo() == Chem.BondStereo.STEREOANY:
entry["stereo"] = 3
bonds.append(entry)
molecule: dict[str, Any] = {"type": "molecule", "atoms": atoms, "bonds": bonds}
if sgroups := [*_superatoms(prepared), *_polymer_sgroups(prepared)]:
molecule["sgroups"] = sgroups
return molecule
#: A molfile copolymer's subtype (MDL ``SST``) as KET writes it, and back.
COPOLYMER_SUBTYPES = {"ALT": "ALT", "RAN": "RAN", "BLO": "BLO"}
def _polymer_sgroups(mol: Any) -> list[dict[str, Any]]:
"""A molecule's repeat-unit, copolymer and generic brackets, as the canvas's S-groups.
MOL and SDF files carry them as SRU, COP and GEN S-groups (MDL CTfile
formats, the ``STY``, ``SST``, ``SMT`` and ``SCN`` lines); the canvas
redraws the brackets itself. Verified multiple groups (MUL) arrive
contracted by the importer; foreign expanded groups remain explicit.
"""
from rdkit import Chem
found = []
for group in Chem.GetMolSubstanceGroups(mol):
kind = group.GetProp("TYPE")
atoms = sorted(int(a) for a in group.GetAtoms())
if kind not in ("SRU", "COP", "GEN", "MUL") or not atoms:
continue
if kind == "MUL" and group.GetParentAtoms():
continue # Foreign expanded records not verified by the importer.
entry: dict[str, Any] = {"type": kind, "atoms": atoms}
if kind == "MUL":
entry["mul"] = int(group.GetProp("LABEL"))
if kind in ("SRU", "COP"):
connect = group.GetProp("CONNECT") if group.HasProp("CONNECT") else "HT"
entry["connectivity"] = str(connect).upper()
if kind == "SRU":
entry["subscript"] = str(group.GetProp("LABEL")) if group.HasProp("LABEL") else "n"
if kind == "COP" and group.HasProp("SUBTYPE"):
subtype = COPOLYMER_SUBTYPES.get(str(group.GetProp("SUBTYPE")).upper())
if subtype:
entry["subtype"] = subtype
found.append(entry)
return found
def _superatoms(mol: Any) -> list[dict[str, Any]]:
"""A molecule's labelled superatom (SUP) S-groups, as the canvas's abbreviations.
A MOL, SDF or RXN file names contracted groups such as Boc this way; they
stay contracted on the canvas unless the file marks them expanded
(``SDS EXP``). The structure keeps every atom either way.
"""
from rdkit import Chem
found = []
for group in Chem.GetMolSubstanceGroups(mol):
if group.GetProp("TYPE") != "SUP" or not group.HasProp("LABEL"):
continue
label = str(group.GetProp("LABEL")).strip()
atoms = sorted(int(a) for a in group.GetAtoms())
if not label or not atoms:
continue
expanded = group.HasProp("ESTATE") and group.GetProp("ESTATE") == "E"
found.append(
{
"type": "SUP",
"atoms": atoms,
"name": label,
"expanded": expanded,
"attachmentPoints": [
{"attachmentAtom": int(point.aIdx)} for point in group.GetAttachPoints()
],
}
)
return found
[docs]
def text_node(text: str, centre: tuple[float, float]) -> dict[str, Any]:
"""A KET text centred at ``centre``, a paragraph per line."""
lines = text.split("\n")
width = max(0.4, 0.22 * max(len(line) for line in lines))
height = 0.4 * len(lines)
return {
"type": "text",
"boundingBox": {
"x": centre[0] - width / 2,
"y": centre[1] + height / 2,
"width": width,
"height": height,
},
"paragraphs": [{"parts": [{"text": line}]} for line in lines],
}
#: The KET arrow mode that draws each of the document's arrow kinds.
ARROW_MODES = {
"forward": "open-angle",
"equilibrium": "equilibrium-open-angle",
"retrosynthetic": "retrosynthetic",
"resonance": "both-ends-filled-triangle",
}
#: Vertical gap between the rows of a scheme, in bond lengths.
ROW_GAP = 2.5
[docs]
@dataclass(frozen=True)
class SceneStep:
"""One reaction row: the molecules at the arrow's tail and head, and what is at it."""
tail: list[Any]
head: list[Any]
reagents: list[Any] = field(default_factory=list)
arrow: str = "forward"
conditions: list[str] = field(default_factory=list)
def _extent(node: dict[str, Any], document: dict[str, Any]) -> tuple[float, float]:
"""The lowest and highest y of a node."""
if "$ref" in node:
ys = [a["location"][1] for a in document[node["$ref"]]["atoms"]]
return min(ys), max(ys)
if node["type"] == "text":
box = node["boundingBox"]
return box["y"] - box["height"], box["y"]
if node["type"] == "arrow":
ys = [p["y"] for p in node["data"]["pos"]]
return min(ys), max(ys)
return node["location"][1], node["location"][1]
def _shift(node: dict[str, Any], document: dict[str, Any], dy: float) -> None:
if "$ref" in node:
for atom in document[node["$ref"]]["atoms"]:
atom["location"][1] = round(atom["location"][1] + dy, 6)
elif node["type"] == "text":
node["boundingBox"]["y"] += dy
elif node["type"] == "arrow":
for point in node["data"]["pos"]:
point["y"] += dy
else:
node["location"][1] += dy
[docs]
def write_scene(
molecules: list[Any],
*,
reaction: tuple[list[Any], list[Any], list[Any]] | None = None,
conditions: list[str] | None = None,
steps: list[SceneStep] | None = None,
) -> dict[str, Any]:
"""A KET document laying ``molecules`` out in a row, or a reaction scheme.
With ``reaction=(reactants, reagents, products)`` the row is the
reactants joined by plus signs, an arrow with the reagents above it and
the ``conditions`` below, then the products. With ``steps`` each step is
such a row, the rows one below the other, a species that takes part in
several steps drawn in each, and ``molecules`` in a last row.
"""
document: dict[str, Any] = {
"ket_version": "2.0.0",
"root": {"nodes": [], "connections": [], "templates": []},
}
nodes: list[dict[str, Any]] = document["root"]["nodes"]
counter = 0
def place(row: list[dict[str, Any]], mol: Any, x: float, y: float = 0.0) -> float:
nonlocal counter
molecule, width = _ket_molecule(mol, (x, y))
name = f"mol{counter}"
counter += 1
document[name] = molecule
row.append({"$ref": name})
return x + width
def side(row: list[dict[str, Any]], mols: list[Any], x: float) -> float:
for index, mol in enumerate(mols):
if index:
row.append({"type": "plus", "location": [x + GAP, 0.0, 0.0]})
x += GAP * 2
x = place(row, mol, x)
return x
def step_row(step: SceneStep) -> list[dict[str, Any]]:
row: list[dict[str, Any]] = []
tail = side(row, step.tail, 0.0) + GAP
above_width = 0.0
above_x = tail + 0.5
for mol in step.reagents:
above_x = place(row, mol, above_x, 1.6) + GAP
above_width = above_x - tail
longest = max((len(line) for line in step.conditions), default=0) * 0.22
head = tail + max(3.0, above_width, longest + 1.0)
mode = ARROW_MODES.get(step.arrow, ARROW_MODES["forward"])
row.append(
{
"type": "arrow",
"data": {
"mode": mode,
"pos": [{"x": tail, "y": 0.0, "z": 0.0}, {"x": head, "y": 0.0, "z": 0.0}],
},
}
)
for number, line in enumerate(step.conditions):
row.append(text_node(line, ((tail + head) / 2, -0.6 - 0.5 * number)))
side(row, step.head, head + GAP)
return row
if reaction is not None:
reactants, reagents, products = reaction
steps = [SceneStep(reactants, products, reagents, "forward", list(conditions or []))]
if not steps:
row: list[dict[str, Any]] = []
x = 0.0
for index, mol in enumerate(molecules):
if index:
x += GAP * 2
x = place(row, mol, x)
nodes.extend(row)
return document
rows = [step_row(step) for step in steps]
if molecules: # what takes part in no step, in a row of its own below
loose: list[dict[str, Any]] = []
x = 0.0
for index, mol in enumerate(molecules):
if index:
x += GAP * 2
x = place(loose, mol, x)
rows.append(loose)
bottom = None
for row in rows:
if bottom is not None:
top = max(_extent(node, document)[1] for node in row)
for node in row:
_shift(node, document, bottom - ROW_GAP - top)
bottom = min(_extent(node, document)[0] for node in row)
nodes.extend(row)
return document