"""SVG from a KET scene, retaining each occurrence and its drawing coordinates.
One KET bond-length unit is the preset bond length in print points. KET's y axis
points up; SVG's points down. The transform is shared by molecules and
annotations, independent of the normalized reaction/species catalogue.
"""
from __future__ import annotations
import math
import re
from dataclasses import dataclass, field
from typing import Any
from xml.sax.saxutils import escape, quoteattr
from vibesketch.chem._rdkit import untyped
from vibesketch.document.ket import KetArrow, KetFragment, read_scene
from vibesketch.render import RenderRefused, _draw2d
from vibesketch.render.styles import PRESETS, Preset, finish_svg, prepare
_MARGIN = 6.0
Point = tuple[float, float]
def _point(point: Point, preset: Preset = PRESETS["acs-1996"]) -> Point:
if not all(math.isfinite(v) for v in point):
raise RenderRefused("the drawing contains non-finite coordinates")
return point[0] * preset.bond, -point[1] * preset.bond
def _body(svg: str) -> str:
match = re.search(r"<svg\b[^>]*>", svg)
if match is None: # pragma: no cover - RDKit's SVG header contract
raise RenderRefused("could not read RDKit's SVG drawing")
return svg[match.end() : svg.rfind("</svg>")].replace("<!-- END OF HEADER -->", "")
@dataclass
class _Drawing:
parts: list[str] = field(default_factory=list)
points: list[Point] = field(default_factory=list)
def add(self, svg: str, *points: Point) -> None:
if not all(math.isfinite(v) for point in points for v in point):
raise RenderRefused("the drawing contains non-finite coordinates")
self.parts.append(svg)
self.points.extend(points)
def svg(self) -> bytes:
if not self.points:
raise RenderRefused("the document has nothing to render")
left = min(p[0] for p in self.points) - _MARGIN
top = min(p[1] for p in self.points) - _MARGIN
width = max(p[0] for p in self.points) + _MARGIN - left
height = max(p[1] for p in self.points) + _MARGIN - top
return (
"<?xml version='1.0' encoding='UTF-8'?>\n"
"<svg xmlns='http://www.w3.org/2000/svg' version='1.1' "
f"width='{width:.4f}pt' height='{height:.4f}pt' "
f"viewBox='{left:.4f} {top:.4f} {width:.4f} {height:.4f}'>"
+ "".join(self.parts)
+ "</svg>\n"
).encode("utf-8")
def _molecule(drawing: _Drawing, fragment: KetFragment, index: int, preset: Preset) -> None:
"""Measure with RDKit, then draw at the scene's exact common scale.
RDKit's automatic integer-sized canvas can shrink a molecule slightly.
Reusing that fitted image would misalign arrows with atoms and scale each
occurrence differently. SetScale fixes the mapping without scaling fonts.
https://www.rdkit.org/docs/source/rdkit.Chem.Draw.rdMolDraw2D.html
"""
from rdkit.Geometry import Point2D
mol, wedges = prepare(fragment.mol, preset)
draw2d = _draw2d()
probe = draw2d.MolDraw2DSVG(-1, -1)
preset.configure(probe.drawOptions())
if preset.name != "acs-1996":
untyped(probe.drawOptions()).prepareMolsBeforeDrawing = False
probe.DrawMolecule(mol)
origin = probe.GetDrawCoords(Point2D(0, 0))
unit = probe.GetDrawCoords(Point2D(1, 0))
scale = unit.x - origin.x
if not math.isfinite(scale) or scale <= 0:
raise RenderRefused("could not determine the molecule's drawing scale")
# Retain RDKit's label margins, plus a point for stroke/rounding safety.
left = -origin.x / scale * preset.bond - 1
top = -origin.y / scale * preset.bond - 1
width = math.ceil(probe.Width() / scale * preset.bond + 2)
height = math.ceil(probe.Height() / scale * preset.bond + 2)
drawer = draw2d.MolDraw2DSVG(width, height)
options = untyped(drawer.drawOptions())
preset.configure(options)
if preset.name != "acs-1996":
options.prepareMolsBeforeDrawing = False
options.padding = 0
options.clearBackground = False
drawer.SetScale(
width,
height,
Point2D(left / preset.bond, -(top + height) / preset.bond),
Point2D((left + width) / preset.bond, -top / preset.bond),
)
drawer.DrawMolecule(mol)
drawer.FinishDrawing()
drawing.add(
f"<g class='molecule' data-occurrence='{index}' data-node={quoteattr(fragment.node)} "
f"transform='translate({left:.6f},{top:.6f})'>"
+ _body(finish_svg(str(drawer.GetDrawingText()), drawer, mol, wedges, preset))
+ "</g>",
(left, top),
(left + width, top + height),
)
def _arrow(drawing: _Drawing, arrow: KetArrow, preset: Preset) -> None:
tail, head = _point(arrow.tail, preset), _point(arrow.head, preset)
dx, dy = head[0] - tail[0], head[1] - tail[1]
length = math.hypot(dx, dy)
if length < 1e-8:
raise RenderRefused("cannot render an arrow with coincident endpoints")
cosine, sine = dx / length, dy / length
mode = arrow.mode
paths: list[str] = []
points: list[Point] = []
def world(x: float, y: float) -> Point:
return tail[0] + cosine * x - sine * y, tail[1] + sine * x + cosine * y
def path(coords: list[Point], *, fill: bool = False, dash: bool = False) -> None:
points.extend(world(x, y) for x, y in coords)
data = "M " + " L ".join(f"{x:.4f},{y:.4f}" for x, y in coords)
if fill:
data += " Z"
paths.append(
f"<path d='{data}' fill='{'#000' if fill else 'none'}'"
+ (" stroke-dasharray='3 2'" if dash else "")
+ "/>"
)
def tip(x: float, y: float, angle: float, kind: str, *, wide: bool = False) -> None:
size = min(5.0, length / 3)
spread = size * (0.65 if wide else 0.45)
c, s = math.cos(angle), math.sin(angle)
def rotated(a: float, b: float) -> Point:
return x + a * c - b * s, y + a * s + b * c
end = (x, y)
upper = rotated(-size, -spread)
lower = rotated(-size, spread)
if kind == "open":
path([upper, end, lower])
elif kind == "half-open":
path([upper, end])
elif kind in ("bow", "half-bow"):
vertices = [end, upper, rotated(-size * 0.6, 0)]
if kind == "bow":
vertices.append(lower)
path(vertices, fill=True)
elif kind == "half-triangle":
path([end, upper, rotated(-size, 0)], fill=True)
else:
path([end, upper, lower], fill=True)
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",
}
if mode in curved:
height = arrow.height * preset.bond
if not math.isfinite(height):
raise RenderRefused("the drawing contains a non-finite arrow height")
if abs(height) < 1e-8:
path([(0, 0), (length, 0)])
tip(length, 0, 0, curved[mode])
else:
# Ketcher 3.18 KET convention: half an ellipse with signed height.
# https://github.com/epam/ketcher/tree/v3.18.0/packages/ketcher-core/src/application/render/renderers/RxnArrowPathRenderer
paths.append(
f"<path class='electron-arc' d='M 0,0 A {length / 2:.4f},{abs(height):.4f} "
f"0 0 {int(height > 0)} {length:.4f},0' fill='none'/>"
)
# Extrema of the rotated half ellipse, including endpoints.
angles = [0.0, math.pi]
for a, b in (
(-cosine * length / 2, sine * height),
(-sine * length / 2, -cosine * height),
):
angle = math.atan2(b, a) % (2 * math.pi)
angles.extend(
t for t in (angle, (angle + math.pi) % (2 * math.pi)) if 0 <= t <= math.pi
)
points.extend(
world(length / 2 * (1 - math.cos(t)), -height * math.sin(t)) for t in angles
)
tip(length, 0, math.copysign(math.pi / 2, height), curved[mode])
elif mode in straight:
path([(0, 0), (length, 0)], dash=mode == "dashed-open-angle")
tip(length, 0, 0, straight[mode])
if mode == "failed":
size = min(4.0, length / 4)
path([(length / 2 - size, -size), (length / 2 + size, size)])
path([(length / 2 - size, size), (length / 2 + size, -size)])
elif mode == "both-ends-filled-triangle":
path([(0, 0), (length, 0)])
tip(0, 0, math.pi, "triangle")
tip(length, 0, 0, "triangle")
elif mode == "retrosynthetic":
size = min(5.0, length / 3)
path([(0, -1.5), (length - size / 2, -1.5)])
path([(0, 1.5), (length - size / 2, 1.5)])
path([(length - size, -3), (length, 0), (length - size, 3)])
elif mode in equilibrium:
inset = min(5.0, length / 4) if mode.startswith("unbalanced-") else 0.0
path([(0, -2), (length, -2)])
tip(length, -2, 0, equilibrium[mode], wide="large" in mode)
path([(length - inset, 2), (inset, 2)])
tip(inset, 2, math.pi, equilibrium[mode], wide="large" in mode)
else:
raise RenderRefused(f"cannot render KET arrow mode {mode!r}")
drawing.add(
f"<g class='arrow' data-mode={quoteattr(mode)} "
f"transform='translate({tail[0]:.6f},{tail[1]:.6f}) "
f"rotate({math.degrees(math.atan2(dy, dx)):.6f})' "
f"stroke='#000' stroke-width='{preset.line}' "
"stroke-linejoin='round' stroke-linecap='round'>" + "".join(paths) + "</g>",
*points,
)
def _check_nodes(canvas: dict[str, Any]) -> None:
"""Refuse objects the scene reader would otherwise silently omit."""
for node in canvas.get("root", {}).get("nodes", []):
if "$ref" in node:
molecule = canvas.get(node["$ref"])
if not isinstance(molecule, dict):
raise RenderRefused("the drawing contains a missing molecule reference")
if molecule.get("type", "molecule") != "molecule":
raise RenderRefused("cannot render a non-molecule KET reference")
for atom in molecule.get("atoms", []):
_point((float(atom["location"][0]), float(atom["location"][1])))
elif node.get("type") not in ("arrow", "plus", "text"):
raise RenderRefused(f"cannot render KET object {node.get('type')!r}")
[docs]
def scene_svg(canvas: dict[str, Any], preset: Preset = PRESETS["acs-1996"]) -> bytes | None:
"""Render a drawing, or return ``None`` for an empty canvas.
Unsupported chemistry or arrow modes are refused rather than omitted.
Text is escaped plain text in the publication font, with line breaks kept.
"""
try:
_check_nodes(canvas)
scene = read_scene(canvas)
except (AttributeError, KeyError, IndexError, TypeError, ValueError, RuntimeError) as error:
if isinstance(error, RenderRefused):
raise
raise RenderRefused("cannot read the drawing's KET scene") from error
if not (scene.fragments or scene.arrows or scene.pluses or scene.texts):
return None
drawing = _Drawing()
for index, fragment in enumerate(scene.fragments):
if fragment.unsupported:
raise RenderRefused(f"cannot render {fragment.node}: {fragment.unsupported}")
_molecule(drawing, fragment, index, preset)
for arrow in scene.arrows:
_arrow(drawing, arrow, preset)
for point in scene.pluses:
x, y = _point(point, preset)
drawing.add(
f"<path class='plus' d='M {x - 3:.4f},{y:.4f} h 6 "
f"M {x:.4f},{y - 3:.4f} v 6' stroke='#000' stroke-width='{preset.line}'/>",
(x - 3, y - 3),
(x + 3, y + 3),
)
for text in scene.texts:
x, y = _point(text.centre, preset)
lines = text.text.split("\n")
line_height = preset.size * 1.3
top = y - len(lines) * line_height / 2
spans = "".join(
f"<tspan x='{x:.4f}' y='{top + preset.size + i * line_height:.4f}'>"
f"{escape(line)}</tspan>"
for i, line in enumerate(lines)
)
# A full em per code point safely includes wide glyphs and fallback fonts.
half_width = max(map(len, lines)) * preset.size / 2
drawing.add(
f"<text class='annotation' text-anchor='middle' xml:space='preserve' "
f"font-family='{preset.font}' font-size='{preset.size}'>{spans}</text>",
(x - half_width, top),
(x + half_width, top + len(lines) * line_height),
)
return drawing.svg()