"""CDXML, ChemDraw's XML format: the whole drawing written and read (#24).
The private fixture writer (not a supported export API) writes CDXML drawings.
Each connected
molecule is a ``fragment`` of nodes and bonds at its drawn coordinates, with
labels on its heteroatoms and a single charge as a symbol beside its atom.
Arrows are ``arrow`` objects with their head and tail; equilibrium and
retrosynthetic arrows are old-style ``graphic`` lines of those types, which
every reader knows. Plus signs are ``graphic`` symbols, and each line of
text is a ``t``.
A ``scheme`` holds a ``step`` per reaction derive reads, naming its
reactants, products and arrow, and the reagents and texts above and below
the arrow; a retrosynthetic step names the precursors as its reactants. One
KET bond length is the style preset's bond length in points (14.4 for ACS
1996), y points down as CDXML's does, and the document settings (bond
length and spacing, line widths, label font and size) are the preset's.
Import reads the same objects back. Molecules go through RDKit's CDXML
reader, which expands nicknames and takes stereochemistry from wedges and
double-bond geometry; their coordinates are kept. Arrows, plus signs and
texts are read from the XML. The reactions are then derived from the
drawing, as for any drawing, and a file's ``scheme`` is a check: a step the
drawing does not read the same way, and every object left out, is reported
as a warning.
Sources: CambridgeSoft, ChemDraw SDK, the CDX/CDXML format specification
(objects, properties and their values), mirrored at
https://chemapps.stolaf.edu/iupac/cdx/sdk/. The ``arrow`` object is newer
than that specification; its attributes follow the files ChemDraw writes,
as EPAM Indigo's CDXML writer (Apache-2.0) reproduces them.
"""
from __future__ import annotations
import math
import re
import xml.etree.ElementTree as ET
from dataclasses import dataclass, field
from typing import Any
from vibesketch.document.ket import KetArrow, KetScene, read_scene
__all__ = ["CdxmlUnreadable", "read_cdxml"]
class CdxmlRefused(ValueError):
"""The drawing cannot be written as CDXML."""
[docs]
class CdxmlUnreadable(ValueError):
"""The data is not CDXML this reader can use."""
_Point = tuple[float, float]
#: Room around the drawing on the page, in points: ChemDraw's default page margin.
_MARGIN = 36.0
#: The label and caption font's id in the font table.
_FONT = 3
#: CDXML font faces (CDXFontStyle bits): formula subscripts a label's digits.
_PLAIN, _SUPERSCRIPT, _FORMULA = 0, 64, 96
#: From a label's baseline up to the middle of its capitals, in ems.
_CAP = 0.35
#: Line height of stacked text lines, in ems, as the SVG renderer spaces them.
_LINE = 1.3
#: US Letter's printable area per page, in points, for the page count.
_PAGE = (540.0, 720.0)
#: KET radical values, which are the molfile's, and CDXML's names for them.
_RADICALS = {1: "Singlet", 2: "Doublet", 3: "Triplet"}
#: KET bond stereo (the molfile's): wedge, hash, either; a wavy double bond is CDXML's
#: double-either bond.
_DISPLAY = {1: "WedgeBegin", 6: "WedgedHashBegin", 4: "Wavy", 3: "Wavy"}
_ORDERS = {1: None, 2: "2", 3: "3", 4: "1.5"}
#: Arrowhead sizes as ChemDraw writes its default arrow, in hundredths of the line width.
_HEAD = {"HeadSize": "1000", "ArrowheadCenterSize": "875", "ArrowheadWidth": "250"}
_TRIANGLE = {**_HEAD, "ArrowheadCenterSize": "1000"}
_OPEN = {"ArrowheadType": "Angle", **_HEAD}
_SOLID = {"ArrowheadType": "Solid"}
#: CDXML ``arrow`` attributes for each KET arrow mode written as one; equilibrium and
#: retrosynthetic arrows are lines (:func:`_legacy`).
_ARROWS: dict[str, dict[str, str]] = {
"open-angle": {"ArrowheadHead": "Full", **_OPEN},
"dashed-open-angle": {"ArrowheadHead": "Full", **_OPEN, "LineType": "Dashed"},
"filled-triangle": {"ArrowheadHead": "Full", **_SOLID, **_TRIANGLE},
"filled-bow": {"ArrowheadHead": "Full", **_SOLID, **_HEAD},
"failed": {"ArrowheadHead": "Full", **_OPEN, "NoGo": "Cross"},
"both-ends-filled-triangle": {
"ArrowheadHead": "Full", "ArrowheadTail": "Full", **_SOLID, **_TRIANGLE
},
"elliptical-arc-arrow-filled-triangle": {"ArrowheadHead": "Full", **_SOLID, **_TRIANGLE},
"elliptical-arc-arrow-filled-bow": {"ArrowheadHead": "Full", **_SOLID, **_HEAD},
"elliptical-arc-arrow-open-angle": {"ArrowheadHead": "Full", **_OPEN},
"elliptical-arc-arrow-open-half-angle": {"ArrowheadHead": "HalfLeft", **_OPEN},
} # fmt: skip
def _number(value: float) -> str:
text = f"{value:.2f}"
return "0.00" if text == "-0.00" else text
def _points(*values: float) -> str:
return " ".join(_number(v) for v in values)
# ── Writing ─────────────────────────────────────────────────────────────
class _Writer:
"""KET coordinates to page points, and ids for the objects written."""
def __init__(self, bond: float, bounds: tuple[float, float, float, float]) -> None:
self.bond = bond
self.left, self.top = bounds[0], bounds[3]
self.next = 1
def new_id(self) -> str:
self.next += 1
return str(self.next)
def point(self, point: _Point) -> _Point:
return (
_MARGIN + (point[0] - self.left) * self.bond,
_MARGIN + (self.top - point[1]) * self.bond,
)
def _scene_bounds(scene: KetScene) -> tuple[float, float, float, float]:
points: list[_Point] = []
for fragment in scene.fragments:
points.extend([fragment.bounds[:2], fragment.bounds[2:]])
for arrow in scene.arrows:
points.extend([arrow.tail, arrow.head])
points.extend(scene.pluses)
points.extend(text.centre for text in scene.texts)
xs, ys = [p[0] for p in points], [p[1] for p in points]
return min(xs), min(ys), max(xs), max(ys)
def _label(atom: Any, ket_atom: dict[str, Any], degree: int) -> list[tuple[str, int]] | None:
"""An atom's label as runs of (text, face), or ``None`` for a carbon drawn without one.
A single charge is not in the label but a symbol of its own (:func:`_charge`).
"""
symbol = atom.GetSymbol()
charge = int(ket_atom.get("charge") or 0)
isotope = int(ket_atom.get("isotope") or 0)
if symbol == "C" and degree and not (abs(charge) > 1 or isotope or ket_atom.get("radical")):
return None
runs: list[tuple[str, int]] = []
if isotope:
runs.append((str(isotope), _SUPERSCRIPT))
hydrogens = atom.GetTotalNumHs()
count = str(hydrogens) if hydrogens > 1 else ""
runs.append((symbol + ("H" + count if hydrogens else ""), _FORMULA))
if abs(charge) > 1:
runs.append((f"{abs(charge)}{'+' if charge > 0 else '-'}", _SUPERSCRIPT))
return runs
#: How far a charge symbol sits from its atom's centre, in bond lengths, and its half size
#: in points.
_CHARGE_REACH, _CHARGE_HALF = 0.5, 2.5
def _charge(
page: ET.Element,
writer: _Writer,
node: str,
at: _Point,
around: list[_Point],
charge: int,
label_end: float | None,
) -> None:
"""A single charge as ChemDraw's charge tool draws it: a plus or minus symbol by the atom
that represents the node's charge, where its bonds leave the most room, clear of the
label that ends at ``label_end``."""
angles = [math.atan2(p[1] - at[1], p[0] - at[0]) for p in around]
def room(angle: float) -> float:
return min(
(abs((angle - a + math.pi) % (2 * math.pi) - math.pi) for a in angles), default=math.pi
)
# Upper right first, as ChemDraw puts it; y points down on the page.
candidates = [-math.pi / 4, -3 * math.pi / 4, math.pi / 4, 3 * math.pi / 4, -math.pi / 2]
angle = max(candidates, key=room)
reach = _CHARGE_REACH * writer.bond
cx, cy = at[0] + reach * math.cos(angle), at[1] + reach * math.sin(angle)
if label_end is not None and math.cos(angle) > 0:
cx = max(cx, label_end + _CHARGE_HALF)
half = _CHARGE_HALF
symbol = ET.SubElement(
page,
"graphic",
id=writer.new_id(),
BoundingBox=_points(cx - half, cy - half, cx + half, cy + half),
GraphicType="Symbol",
SymbolType="Plus" if charge > 0 else "Minus",
)
ET.SubElement(symbol, "represent", attribute="Charge", object=node)
def _runs(parent: ET.Element, runs: list[tuple[str, int]], size: int) -> None:
for text, face in runs:
run = ET.SubElement(parent, "s", font=str(_FONT), size=str(size), face=str(face))
run.text = text
def _fragment(
page: ET.Element, writer: _Writer, canvas: dict[str, Any], fragment: Any, size: int
) -> str:
"""One connected molecule as a ``fragment``; returns its id."""
from vibesketch.render.fonts import advance
molecule = canvas[fragment.node]
ket_atoms: list[dict[str, Any]] = molecule.get("atoms", [])
positions = {index: writer.point(ket_atoms[index]["location"][:2]) for index in fragment.atoms}
xs, ys = [p[0] for p in positions.values()], [p[1] for p in positions.values()]
box = _points(min(xs), min(ys), max(xs), max(ys))
element = ET.SubElement(page, "fragment", id=writer.new_id(), BoundingBox=box)
members = set(fragment.atoms)
bonds = [b for b in molecule.get("bonds", []) if set(b["atoms"]) <= members]
degree = {index: 0 for index in fragment.atoms}
for bond in bonds:
for index in bond["atoms"]:
degree[index] += 1
nodes: dict[int, str] = {}
charges: list[tuple[int, int]] = []
label_ends: dict[int, float] = {}
for atom, index in zip(fragment.mol.GetAtoms(), fragment.atoms, strict=True):
ket_atom = ket_atoms[index]
x, y = positions[index]
node = ET.SubElement(element, "n", id=writer.new_id(), p=_points(x, y))
nodes[index] = node.get("id", "")
if atom.GetAtomicNum() != 6:
node.set("Element", str(atom.GetAtomicNum()))
if ket_atom.get("charge"):
node.set("Charge", str(int(ket_atom["charge"])))
if abs(int(ket_atom["charge"])) == 1:
charges.append((index, int(ket_atom["charge"])))
if ket_atom.get("isotope"):
node.set("Isotope", str(int(ket_atom["isotope"])))
if ket_atom.get("radical"):
node.set("Radical", _RADICALS[int(ket_atom["radical"])])
runs = _label(atom, ket_atom, degree[index])
if runs is None:
continue
node.set("NumHydrogens", str(atom.GetTotalNumHs()))
# A labelled carbon says so: readers take a carbon node's label as an alias.
node.set("Element", str(atom.GetAtomicNum()))
# The first letter's middle on the atom, as ChemDraw places a left-justified label.
first = runs[0][0] if runs[0][1] == _FORMULA else runs[1][0]
half = advance(first[:1]) * size / 2
label = ET.SubElement(
node, "t", p=_points(x - half, y + _CAP * size), LabelJustification="Left"
)
_runs(label, runs, size)
label_ends[index] = x - half + advance("".join(text for text, _ in runs)) * size
for bond in bonds:
begin, end = (int(i) for i in bond["atoms"])
written = ET.SubElement(element, "b", id=writer.new_id(), B=nodes[begin], E=nodes[end])
order = _ORDERS[int(bond.get("type", 1))]
if order is not None:
written.set("Order", order)
display = _DISPLAY.get(int(bond.get("stereo", 0)))
if display is not None:
written.set("Display", display)
for index, charge in charges:
neighbours = [positions[i] for b in bonds if index in b["atoms"] for i in b["atoms"]]
around = [p for p in neighbours if p != positions[index]]
_charge(page, writer, nodes[index], positions[index], around, charge, label_ends.get(index))
return element.get("id", "")
def _arc(writer: _Writer, arrow: KetArrow) -> dict[str, str]:
"""A curved arrow's half ellipse: its centre, axis ends and angle, as ChemDraw needs them."""
(tx, ty), (hx, hy) = arrow.tail, arrow.head
length = math.hypot(hx - tx, hy - ty)
centre = ((tx + hx) / 2, (ty + hy) / 2)
# KET's height bulges to the left of tail to head.
left = (-(hy - ty) / length, (hx - tx) / length)
minor = (centre[0] + left[0] * arrow.height, centre[1] + left[1] * arrow.height)
return {
"AngularSize": "180" if arrow.height >= 0 else "-180",
"Center3D": _points(*writer.point(centre), 0.0),
"MajorAxisEnd3D": _points(*writer.point(arrow.head), 0.0),
"MinorAxisEnd3D": _points(*writer.point(minor), 0.0),
}
def _legacy(mode: str) -> str | None:
"""The ``ArrowType`` of the old-style ``graphic`` line an arrow of this kind is written as.
ChemDraw reads these lines, and older readers (Indigo's among them) know
these kinds only from them; written with the newer ``arrow`` as well, they
draw twice there. CDXML has no unbalanced equilibrium: one is written as an
equilibrium, which derive reads the same.
"""
if "equilibrium" in mode:
return "Equilibrium"
return "RetroSynthetic" if mode == "retrosynthetic" else None
def _arrow(page: ET.Element, writer: _Writer, arrow: KetArrow) -> str:
"""An ``arrow`` object, or for the kinds :func:`_legacy` names a ``graphic`` line;
returns its id."""
if math.dist(arrow.tail, arrow.head) < 1e-6:
raise CdxmlRefused("an arrow's ends coincide")
tail, head = writer.point(arrow.tail), writer.point(arrow.head)
legacy = _legacy(arrow.mode)
if legacy is not None:
line = ET.SubElement(
page,
"graphic",
id=writer.new_id(),
BoundingBox=_points(*head, *tail), # a line's box runs from its head to its tail
GraphicType="Line",
ArrowType=legacy,
HeadSize=_HEAD["HeadSize"],
)
return line.get("id", "")
attributes = _ARROWS.get(arrow.mode)
if attributes is None:
raise CdxmlRefused(f"CDXML has no arrow for the KET arrow mode {arrow.mode!r}")
reach = 3.0 # half the arrowhead's width, in points, for the bounding box
box = (
min(tail[0], head[0]) - reach,
min(tail[1], head[1]) - reach,
max(tail[0], head[0]) + reach,
max(tail[1], head[1]) + reach,
)
element = ET.SubElement(page, "arrow", id=writer.new_id(), BoundingBox=_points(*box))
element.set("FillType", "None")
for key, value in attributes.items():
element.set(key, value)
element.set("Head3D", _points(*head, 0.0))
element.set("Tail3D", _points(*tail, 0.0))
if arrow.mode.startswith("elliptical-arc"):
for key, value in _arc(writer, arrow).items():
element.set(key, value)
return element.get("id", "")
def _texts(page: ET.Element, writer: _Writer, text: str, centre: _Point, size: int) -> list[str]:
"""A ``t`` per line, centred on the text's centre; returns their ids."""
from vibesketch.render.fonts import advance
lines = text.split("\n")
x, y = writer.point(centre)
top = y - len(lines) * _LINE * size / 2
identifiers = []
for number, line in enumerate(lines):
middle = top + (number + 0.5) * _LINE * size
half = advance(line) * size / 2
element = ET.SubElement(
page,
"t",
id=writer.new_id(),
p=_points(x, middle + _CAP * size),
BoundingBox=_points(x - half, middle - size / 2, x + half, middle + size / 2),
Justification="Center",
InterpretChemically="no",
)
_runs(element, [(line, _PLAIN)], size)
identifiers.append(element.get("id", ""))
return identifiers
def _drawing(document: Any) -> dict[str, Any]:
"""The document's drawing, or for one without, its reactions laid out in rows."""
from vibesketch.document.formats import species_mols
from vibesketch.document.ket import SceneStep, write_scene
if document.canvas.data and document.canvas.data.get("root", {}).get("nodes"):
canvas: dict[str, Any] = document.canvas.data
return canvas
mols = species_mols(document)
if not mols:
raise CdxmlRefused("the document has nothing to export")
used = {sid for r in document.reactions for sid in (*r.reactants, *r.reagents, *r.products)}
steps = [
SceneStep(
[mols[i] for i in r.reactants],
[mols[i] for i in r.products],
[mols[i] for i in r.reagents],
r.arrow,
list(r.conditions),
)
for r in document.reactions
]
loose = [mol for sid, mol in mols.items() if sid not in used]
return write_scene(loose, steps=steps)
def _cdxml_source(document: Any, *, style: str | None = None) -> bytes:
"""``document``'s drawing as a CDXML file; raises :class:`CdxmlRefused`.
``style`` names the preset whose bond length and text size are used; by
default the document's own.
"""
from vibesketch import __version__
from vibesketch.document.derive import _condition_texts, _labels, _roles
from vibesketch.render.styles import PRESETS
preset = PRESETS.get(style if style is not None else document.style.preset)
if preset is None:
raise CdxmlRefused(f"no style preset {style!r}; available: {', '.join(PRESETS)}")
canvas = _drawing(document)
for node in canvas.get("root", {}).get("nodes", []):
if "$ref" not in node and node.get("type") not in ("arrow", "plus", "text"):
raise CdxmlRefused(f"CDXML export cannot write the KET object {node.get('type')!r}")
scene = read_scene(canvas)
if not (scene.fragments or scene.arrows or scene.pluses or scene.texts):
raise CdxmlRefused("the drawing is empty")
for fragment in scene.fragments:
if fragment.unsupported:
raise CdxmlRefused(f"cannot write {fragment.node} as CDXML: {fragment.unsupported}")
bounds = _scene_bounds(scene)
writer = _Writer(preset.bond, bounds)
width = (bounds[2] - bounds[0]) * preset.bond + 2 * _MARGIN
height = (bounds[3] - bounds[1]) * preset.bond + 2 * _MARGIN
root = ET.Element(
"CDXML",
CreationProgram=f"vibe-sketch {__version__}",
BoundingBox=_points(0, 0, width, height),
BondLength=_number(preset.bond),
BondSpacing=str(round(preset.spacing * 100)),
LineWidth=_number(preset.line),
BoldWidth=_number(preset.bold),
HashSpacing=_number(preset.hash_spacing),
MarginWidth=_number(preset.margin),
LabelFont=str(_FONT),
LabelSize=str(preset.size),
LabelFace=str(_FORMULA),
CaptionFont=str(_FONT),
CaptionSize=str(preset.size),
)
fonts = ET.SubElement(root, "fonttable")
ET.SubElement(fonts, "font", id=str(_FONT), charset="utf-8", name="Arial")
page = ET.SubElement(
root,
"page",
id="1",
BoundingBox=_points(0, 0, width, height),
WidthPages=str(max(1, math.ceil(width / _PAGE[0]))),
HeightPages=str(max(1, math.ceil(height / _PAGE[1]))),
)
fragments = [_fragment(page, writer, canvas, f, preset.size) for f in scene.fragments]
arrows = {id(a): _arrow(page, writer, a) for a in scene.arrows}
for x, y in scene.pluses:
cx, cy = writer.point((x, y))
half = preset.size * 0.3
ET.SubElement(
page,
"graphic",
id=writer.new_id(),
BoundingBox=_points(cx, cy - half, cx, cy + half),
GraphicType="Symbol",
SymbolType="Plus",
)
texts = [_texts(page, writer, t.text, t.centre, preset.size) for t in scene.texts]
_, used = _labels(scene)
steps: list[dict[str, str]] = []
for arrow, tails, reagents, heads, _ in _roles(scene):
if not tails or not heads:
continue
beside = [(f, scene.fragments[f].centre, [fragments[f]]) for f in reagents]
beside += [
(t, scene.texts[t].centre, texts[t]) for t in _condition_texts(scene, arrow, used)
]
above: list[str] = []
below: list[str] = []
for _, centre, identifiers in beside:
# Above an arrow is to its left, looking from tail to head.
cross = (arrow.head[0] - arrow.tail[0]) * (centre[1] - arrow.tail[1]) - (
arrow.head[1] - arrow.tail[1]
) * (centre[0] - arrow.tail[0])
(above if cross >= 0 else below).extend(identifiers)
reactants, products = [fragments[f] for f in tails], [fragments[f] for f in heads]
if "retrosynthetic" in arrow.mode:
reactants, products = products, reactants
step = {
"ReactionStepReactants": " ".join(reactants),
"ReactionStepProducts": " ".join(products),
"ReactionStepArrows": arrows[id(arrow)],
}
if above:
step["ReactionStepObjectsAboveArrow"] = " ".join(above)
if below:
step["ReactionStepObjectsBelowArrow"] = " ".join(below)
steps.append(step)
if steps:
scheme = ET.SubElement(page, "scheme", id=writer.new_id())
for step in steps:
ET.SubElement(scheme, "step", {"id": writer.new_id(), **step})
ET.indent(root)
for text in root.iter("t"): # no whitespace inside a text: it would be read as text
text.text = None
for run in text:
run.tail = None
body = ET.tostring(root, encoding="unicode")
header = (
'<?xml version="1.0" encoding="UTF-8" ?>\n'
'<!DOCTYPE CDXML SYSTEM "http://www.cambridgesoft.com/xml/cdxml.dtd" >\n'
)
return (header + body + "\n").encode("utf-8")
# ── Reading ─────────────────────────────────────────────────────────────
#: Objects that only describe others (a charge's symbol, a tag), left out without a warning.
_DESCRIPTIVE = {"objecttag", "represent", "chemicalproperty", "fonttable", "colortable"}
@dataclass
class _Read:
"""What the import found, in KET units: y up, one bond length 1."""
nodes: list[dict[str, Any]] = field(default_factory=list)
molecules: dict[str, dict[str, Any]] = field(default_factory=dict)
#: CDXML fragment id to the KET molecule it became, and to its RDKit molecule.
fragments: dict[str, str] = field(default_factory=dict)
mols: dict[str, Any] = field(default_factory=dict)
#: CDXML text id to its text.
texts: dict[str, str] = field(default_factory=dict)
#: CDXML arrow id to its KET mode.
arrows: dict[str, str] = field(default_factory=dict)
steps: list[dict[str, list[str]]] = field(default_factory=list)
left_out: dict[str, int] = field(default_factory=dict)
def _floats(text: str | None) -> list[float]:
if not text:
return []
try:
values = [float(v) for v in text.split()]
except ValueError:
raise CdxmlUnreadable(f"{text!r} is not a list of numbers") from None
if not all(math.isfinite(v) for v in values):
raise CdxmlUnreadable(f"{text!r} holds a non-finite number")
return values
def _bond_length(root: ET.Element) -> float:
"""The document's bond length in points; a file without one has its median bond measured."""
stated = _floats(root.get("BondLength"))
if stated and stated[0] > 0:
return stated[0]
lengths: list[float] = []
for fragment in root.iter("fragment"):
where = {n.get("id"): _floats(n.get("p")) for n in fragment.findall("n")}
for bond in fragment.findall("b"):
begin, end = where.get(bond.get("B")), where.get(bond.get("E"))
if begin and end and len(begin) >= 2 and len(end) >= 2:
lengths.append(math.dist(begin[:2], end[:2]))
lengths = sorted(v for v in lengths if v > 0)
return lengths[len(lengths) // 2] if lengths else 14.4
def _text_of(element: ET.Element) -> str:
runs = element.findall("s")
raw = "".join(run.text or "" for run in runs) if runs else (element.text or "")
lines = [line.strip() for line in re.split(r"\r\n|\r|\n", raw)]
return "\n".join(line for line in lines if line)
def _text_centre(element: ET.Element) -> _Point | None:
"""A text's centre in points: its box's, or from its baseline and justification."""
from vibesketch.render.fonts import advance
box = _floats(element.get("BoundingBox"))
if len(box) == 4:
return (box[0] + box[2]) / 2, (box[1] + box[3]) / 2
origin = _floats(element.get("p"))
if len(origin) < 2:
return None
sizes = [_floats(run.get("size")) for run in element.findall("s")]
size = next((s[0] for s in sizes if s), 10.0)
lines = _text_of(element).split("\n")
width = max(advance(line) for line in lines) * size
shift = {"Center": 0.0, "Right": -width / 2}.get(element.get("Justification", ""), width / 2)
return origin[0] + shift, origin[1] - _CAP * size + (len(lines) - 1) * _LINE * size / 2
def _bow(element: ET.Element) -> bool:
"""Whether a solid arrowhead is notched: its centre shorter than its sides."""
centre, head = _floats(element.get("ArrowheadCenterSize")), _floats(element.get("HeadSize"))
return bool(centre and head and centre[0] < head[0])
def _mode(element: ET.Element) -> str | None:
"""The KET arrow mode of an ``arrow``, or ``None`` for a line without heads."""
head = element.get("ArrowheadHead", "None")
tail = element.get("ArrowheadTail", "None")
open_head = element.get("ArrowheadType", "Solid") == "Angle"
if head == "None" and tail == "None":
return None
if element.get("NoGo"):
return "failed"
if element.get("AngularSize") is not None:
if head.startswith("Half"):
return "elliptical-arc-arrow-open-half-angle"
if open_head:
return "elliptical-arc-arrow-open-angle"
bow = _bow(element)
return f"elliptical-arc-arrow-filled-{'bow' if bow else 'triangle'}"
if head.startswith("Half") and tail.startswith("Half"):
if open_head:
return "equilibrium-open-angle"
return "equilibrium-filled-half-bow" if _bow(element) else "equilibrium-filled-triangle"
if head != "None" and tail != "None":
return "both-ends-filled-triangle"
spacing = _floats(element.get("ArrowShaftSpacing"))
if spacing and spacing[0] > 0:
return "retrosynthetic"
if open_head:
return "dashed-open-angle" if element.get("LineType") == "Dashed" else "open-angle"
return "filled-bow" if _bow(element) else "filled-triangle"
#: Old-style ``graphic`` arrows, by the ArrowType bits they carry, most specific first.
_ARROW_TYPES = (
("NoGo", "failed"),
("RetroSynthetic", "retrosynthetic"),
("Equilibrium", "equilibrium-filled-half-bow"),
("Resonance", "both-ends-filled-triangle"),
("FullHead", "filled-triangle"),
("HalfHead", "filled-triangle"),
("Hollow", "filled-triangle"),
)
def _height(element: ET.Element, tail: _Point, head: _Point, bond: float) -> float:
"""A curved arrow's signed bulge, in KET units: its half ellipse's minor axis when it has
one, else the sagitta of a circular arc through its ends."""
length = math.dist(tail, head)
if length < 1e-9:
return 0.0
left = (-(head[1] - tail[1]) / length, (head[0] - tail[0]) / length)
middle = ((tail[0] + head[0]) / 2, (tail[1] + head[1]) / 2)
def ket(values: list[float]) -> _Point:
return values[0] / bond, -values[1] / bond
centre = _floats(element.get("Center3D"))
minor = _floats(element.get("MinorAxisEnd3D"))
if len(centre) >= 2 and len(minor) >= 2 and math.dist(ket(centre), middle) < 1e-3 * length:
c, m = ket(centre), ket(minor)
return (m[0] - c[0]) * left[0] + (m[1] - c[1]) * left[1]
stated = (_floats(element.get("AngularSize")) or [180.0])[0]
angle = math.radians(min(abs(stated), 359.0))
sagitta = length / 2 * math.tan(angle / 4)
side = 1.0 if stated >= 0 else -1.0
if len(centre) >= 2:
c = ket(centre)
offset = (c[0] - middle[0]) * left[0] + (c[1] - middle[1]) * left[1]
if abs(offset) > 1e-3 * length:
# A minor arc bulges away from its circle's centre, a major one towards it.
side = -math.copysign(1.0, offset) if angle < math.pi else math.copysign(1.0, offset)
return side * sagitta
def _arrow_node(tail: _Point, head: _Point, mode: str, height: float = 0.0) -> dict[str, Any]:
data: dict[str, Any] = {
"mode": mode,
"pos": [{"x": tail[0], "y": tail[1], "z": 0.0}, {"x": head[0], "y": head[1], "z": 0.0}],
}
if mode.startswith("elliptical-arc"):
data["height"] = height
return {"type": "arrow", "data": data}
def _molecules(text: str, bond: float, found: _Read) -> None:
"""The file's fragments through RDKit's reader, at their drawn coordinates."""
from rdkit import Chem, rdBase
from vibesketch.document.formats import ImportFailed, _sanitized
from vibesketch.document.ket import placed_molecule
blocker = rdBase.BlockLogs()
try:
mols = list(Chem.MolsFromCDXML(text, sanitize=False, removeHs=False))
except (RuntimeError, ValueError) as error:
raise CdxmlUnreadable(f"RDKit could not read the CDXML structures: {error}") from None
finally:
del blocker
for mol in mols:
try:
mol = _sanitized(mol, "CDXML structure")
except ImportFailed as error:
raise CdxmlUnreadable(str(error)) from None
if mol.GetNumConformers() == 0:
raise CdxmlUnreadable("a CDXML structure has no coordinates")
name = f"mol{len(found.molecules)}"
# RDKit draws one bond length as 1.5 and flips y, as KET does.
found.molecules[name] = placed_molecule(mol, 1 / 1.5)
found.nodes.append({"$ref": name})
if mol.HasProp("CDX_FRAG_ID"):
found.fragments[str(mol.GetProp("CDX_FRAG_ID"))] = name
found.mols[str(mol.GetProp("CDX_FRAG_ID"))] = mol
def _singlets(root: ET.Element, bond: float, found: _Read) -> None:
"""Mark singlet radicals, which RDKit reads as triplets, by the position of their node."""
marked = [
_floats(n.get("p")) for n in root.iter("n") if n.get("Radical") == "Singlet" and n.get("p")
]
for p in marked:
target = (p[0] / bond, -p[1] / bond)
for molecule in found.molecules.values():
for atom in molecule["atoms"]:
if math.dist(atom["location"][:2], target) < 1e-3 and atom.get("radical"):
atom["radical"] = 1
def _objects(parent: ET.Element, bond: float, found: _Read) -> None:
"""Arrows, plus signs and texts on a page or in a group, in KET units."""
def ket(values: list[float]) -> _Point:
return values[0] / bond, -values[1] / bond
for element in parent:
tag = element.tag
if tag in ("fragment",) or tag in _DESCRIPTIVE:
continue
if tag == "group":
_objects(element, bond, found)
elif tag == "scheme":
for step in element.iter("step"):
found.steps.append(
{
key: (step.get(f"ReactionStep{key}") or "").split()
for key in (
"Reactants",
"Products",
"ObjectsAboveArrow",
"ObjectsBelowArrow",
"Arrows",
)
} # fmt: skip
)
elif tag == "t":
content = _text_of(element)
centre = _text_centre(element)
if not content or centre is None:
continue
if content == "+":
found.nodes.append({"type": "plus", "location": [*ket(list(centre)), 0.0]})
continue
from vibesketch.document.ket import text_node
found.nodes.append(text_node(content, ket(list(centre))))
found.texts[element.get("id", "")] = content
elif tag == "arrow":
mode = _mode(element)
head, tail = _floats(element.get("Head3D")), _floats(element.get("Tail3D"))
if mode is None or len(head) < 2 or len(tail) < 2:
found.left_out["line"] = found.left_out.get("line", 0) + 1
continue
start, end = ket(tail), ket(head)
height = _height(element, start, end, bond) if mode.startswith("elliptical") else 0.0
found.nodes.append(_arrow_node(start, end, mode, height))
found.arrows[element.get("id", "")] = mode
elif tag == "graphic":
if element.get("SupersededBy") or element.find("represent") is not None:
continue # drawn by the newer object it names, or a property's symbol
kind = element.get("GraphicType")
box = _floats(element.get("BoundingBox"))
if kind == "Symbol" and element.get("SymbolType") == "Plus" and len(box) == 4:
centre = ket([(box[0] + box[2]) / 2, (box[1] + box[3]) / 2])
found.nodes.append({"type": "plus", "location": [*centre, 0.0]})
continue
types = (element.get("ArrowType") or "").split()
mode = next((m for t, m in _ARROW_TYPES if t in types), None)
if kind == "Line" and mode is not None and len(box) == 4:
# A line's box runs from its head to its tail.
found.nodes.append(_arrow_node(ket(box[2:]), ket(box[:2]), mode))
found.arrows[element.get("id", "")] = mode
continue
name = f"{(kind or 'graphic').lower()} graphic"
found.left_out[name] = found.left_out.get(name, 0) + 1
else:
found.left_out[tag] = found.left_out.get(tag, 0) + 1
def _normalised(found: _Read) -> dict[str, Any]:
"""The KET document, its top left corner at the origin."""
from vibesketch.document.formats import _points as points
from vibesketch.document.formats import _shifted
ket: dict[str, Any] = {
"ket_version": "2.0.0",
"root": {"nodes": found.nodes, "connections": [], "templates": []},
**found.molecules,
}
where = points(ket)
if not where:
return ket
return _shifted(ket, -min(p[0] for p in where), -max(p[1] for p in where))
def _check_steps(ket: dict[str, Any], found: _Read) -> list[str]:
"""Each step the file declares that the drawing does not read the same way."""
from vibesketch.chem.species import identity
from vibesketch.document import new_document
from vibesketch.document.derive import derive
from vibesketch.document.model import Canvas
if not found.steps:
return []
scene = read_scene(ket)
keys: dict[str, set[str]] = {}
smiles: dict[str, str] = {}
for fragment in scene.fragments:
if fragment.mol is not None:
found_identity = identity(fragment.mol)
keys.setdefault(fragment.node, set()).add(found_identity.key)
smiles[found_identity.key] = found_identity.smiles
document = derive(new_document().model_copy(update={"canvas": Canvas(format="ket", data=ket)}))
key_of = {s.id: s.inchikey or s.smiles or s.id for s in document.document.species}
def side(ids: list[str]) -> frozenset[str]:
return frozenset(k for i in ids for k in keys.get(found.fragments.get(i, ""), set()))
def shown(side_keys: frozenset[str]) -> str:
return ".".join(sorted(smiles.get(k, k) for k in side_keys)) or "nothing"
read = [
(
frozenset(key_of[i] for i in r.reactants),
frozenset(key_of[i] for i in r.reagents),
frozenset(key_of[i] for i in r.products),
r,
)
for r in document.document.reactions
]
warnings: list[str] = []
matched: set[int] = set()
for number, step in enumerate(found.steps, start=1):
reactants, products = side(step["Reactants"]), side(step["Products"])
beside = step["ObjectsAboveArrow"] + step["ObjectsBelowArrow"]
reagents = side([i for i in beside if i in found.fragments])
texts = sorted(
line for i in beside if i in found.texts for line in found.texts[i].split("\n")
)
match = next(
(
index
for index, (r, _, p, reaction) in enumerate(read)
if index not in matched
and (
(r, p) == (reactants, products)
or (reaction.arrow == "retrosynthetic" and (p, r) == (reactants, products))
)
),
None,
)
described = f"step {number} of the file's scheme ({shown(reactants)}>>{shown(products)})"
if match is None:
warnings.append(
f"{described} is not how the drawing reads; check the molecules at its arrow"
)
continue
matched.add(match)
_, agents, _, reaction = read[match]
if agents != reagents or sorted(reaction.conditions) != texts:
warnings.append(
f"{described} lists {shown(reagents)} and {texts} beside its arrow; the drawing "
f"reads {shown(agents)} and {sorted(reaction.conditions)}"
)
for index, (r, _, p, _) in enumerate(read):
if index not in matched:
warnings.append(
f"the drawing reads a reaction the file's scheme does not list: "
f"{shown(r)}>>{shown(p)}"
)
return warnings
def _rows(found: _Read) -> dict[str, Any]:
"""The declared steps laid out in rows, for a file that does not draw them."""
from vibesketch.document.ket import SceneStep, write_scene
steps: list[SceneStep] = []
inside: set[str] = set()
for step in found.steps:
beside = step["ObjectsAboveArrow"] + step["ObjectsBelowArrow"]
inside.update(step["Reactants"], step["Products"], beside)
def mols(ids: list[str]) -> list[Any]:
return [found.mols[i] for i in ids if i in found.mols]
conditions = [
line for i in beside if i in found.texts for line in found.texts[i].split("\n")
]
steps.append(
SceneStep(mols(step["Reactants"]), mols(step["Products"]), mols(beside), "forward",
conditions)
) # fmt: skip
loose = [mol for i, mol in found.mols.items() if i not in inside]
return write_scene(loose, steps=steps)
[docs]
def read_cdxml(data: str | bytes) -> tuple[dict[str, Any], list[str]]:
"""A CDXML file as a KET drawing, and warnings about what did not carry over.
Raises :class:`CdxmlUnreadable`.
"""
try:
root = ET.fromstring(data.encode("utf-8") if isinstance(data, str) else data)
except ET.ParseError as error:
raise CdxmlUnreadable(f"not well-formed XML: {error}") from None
if root.tag != "CDXML":
raise CdxmlUnreadable(f"the root element is <{root.tag}>, not <CDXML>")
bond = _bond_length(root)
# RDKit scales by the stated bond length; a file without one gets the measured one.
root.set("BondLength", _number(bond))
found = _Read()
_molecules(ET.tostring(root, encoding="unicode"), bond, found)
_singlets(root, bond, found)
for page in root.iter("page"):
_objects(page, bond, found)
if not found.molecules and not found.nodes:
raise CdxmlUnreadable("the CDXML file holds no structures, arrows or text")
if found.steps and not found.arrows:
ket, warnings = (
_rows(found),
["the file's reactions are not drawn with arrows; they are laid out in rows"],
)
else:
ket = _normalised(found)
warnings = _check_steps(ket, found)
if found.left_out:
kinds = ", ".join(f"{count} {kind}" for kind, count in sorted(found.left_out.items()))
warnings.append(f"left out what vibe-sketch does not draw: {kinds}")
return ket, warnings