Source code for vibesketch.render.scene

"""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()