"""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 collections.abc import Callable
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.document.sequences import Printed
from vibesketch.document.solids import solid_runs
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")
#: Highlights of a drawn molecule: atom indices and a palette colour, per group.
Marks = list[tuple[list[int], str]]
[docs]
def highlight_options(mol: Any, marks: Marks) -> dict[str, Any]:
"""RDKit's DrawMolecule highlight arguments: the atoms, and bonds within one group."""
from vibesketch.document.highlights import tint
atoms: dict[int, tuple[float, float, float]] = {}
bonds: dict[int, tuple[float, float, float]] = {}
for group, colour in marks:
fill = tint(colour)
members = set(group)
for atom in group:
atoms[atom] = fill
for bond in mol.GetBonds():
if {bond.GetBeginAtomIdx(), bond.GetEndAtomIdx()} <= members:
bonds[bond.GetIdx()] = fill
if not atoms:
return {}
return {
"highlightAtoms": list(atoms),
"highlightAtomColors": atoms,
"highlightBonds": list(bonds),
"highlightBondColors": bonds,
}
def _molecule(
drawing: _Drawing, fragment: KetFragment, index: int, preset: Preset, marks: Marks
) -> 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 if fragment.mol is not None else fragment.figure,
preset,
fragment.abbreviations,
)
if fragment.abbreviations:
# Marks follow their atoms; those inside a contracted group are not drawn.
kept = {
a.GetIntProp("vibesketch_index"): a.GetIdx()
for a in mol.GetAtoms()
if a.HasProp("vibesketch_index")
}
marks = [([kept[i] for i in atoms if i in kept], colour) for atoms, colour in marks]
highlights = highlight_options(mol, marks)
# An R-group definition's attachment points are drawn as wavy bonds.
attachments = any(a.HasProp("_fromAttchpt") for a in mol.GetAtoms())
draw2d = _draw2d()
probe = draw2d.MolDraw2DSVG(-1, -1)
preset.configure(probe.drawOptions())
untyped(probe.drawOptions()).dummiesAreAttachments = attachments
if preset.name != "acs-1996":
untyped(probe.drawOptions()).prepareMolsBeforeDrawing = False
probe.DrawMolecule(mol, **highlights)
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)
options.dummiesAreAttachments = attachments
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, **highlights)
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),
)
#: A disulfide bridge above a printed sequence: where it starts above the
#: baseline and how much higher each bridge it must clear lifts it, in ems.
_BRIDGE_FOOT = 0.8
_BRIDGE_STEP = 0.5
#: The distance from one chain's baseline to the next one's, in ems, before
#: the room the lower chain's own bridges take.
_CHAIN_GAP = 2.4
#: How far below a chain's baseline a disulfide to the next chain starts, in ems.
_LINK_DROP = 0.35
def _bridge_levels(bridges: tuple[tuple[int, int], ...]) -> dict[tuple[int, int], int]:
"""Each bridge's level: one above every shorter bridge it overlaps."""
levels: dict[tuple[int, int], int] = {}
for bridge in sorted(bridges, key=lambda b: b[1] - b[0]):
below = [
level
for other, level in levels.items()
if other[0] <= bridge[1] and bridge[0] <= other[1]
]
levels[bridge] = 1 + max(below, default=0)
return levels
def _in_place(
drawing: _Drawing, fragment: KetFragment, index: int, printed: Printed, preset: Preset
) -> None:
"""``printed`` centred where ``fragment``'s structure is drawn, its bridges above it.
The text is placed from its left edge so each residue's position follows
from the publication font's advance widths; a bridge rises from the
middle of each cysteine, one level above every shorter bridge it overlaps.
A peptide of several chains is a line per chain, all starting at one
edge, with a line from each cysteine down to its partner in the next chain.
"""
from vibesketch.render.fonts import advance
x, y = _point(fragment.centre, preset)
size = preset.size
lines = [printed, *printed.chains]
levels = [_bridge_levels(line.bridges) for line in lines]
left = x - max(advance(line.text) for line in lines) * size / 2
baselines = [0.0]
for found in levels[1:]:
room = max(found.values(), default=0) * _BRIDGE_STEP
baselines.append(baselines[-1] + (_CHAIN_GAP + room) * size)
# The block is centred on the structure's centre, as one line is.
shift = y + size * 0.35 - baselines[-1] / 2
baselines = [baseline + shift for baseline in baselines]
def middle(line: Printed, residue: int) -> float:
start, end = line.spans[residue]
text = line.text
return left + (advance(text[:start]) + advance(text[start:end]) / 2) * size
for number, (line, baseline) in enumerate(zip(lines, baselines, strict=True)):
chain = f" data-chain='{number}'" if len(lines) > 1 else ""
drawing.add(
f"<text class='sequence' data-occurrence='{index}' "
f"data-node={quoteattr(fragment.node)}{chain} text-anchor='start' "
f"xml:space='preserve' font-family='{preset.font}' font-size='{size}' "
f"x='{left:.4f}' y='{baseline:.4f}'>{escape(line.text)}</text>",
(left, baseline - size * 0.85),
(left + advance(line.text) * size, baseline + size * 0.15),
)
foot = baseline - _BRIDGE_FOOT * size
for (first, second), level in levels[number].items():
top = foot - level * _BRIDGE_STEP * size
a, b = middle(line, first), middle(line, second)
drawing.add(
f"<path class='disulfide' d='M {a:.4f},{foot:.4f} V {top:.4f} H {b:.4f} "
f"V {foot:.4f}' fill='none' stroke='#000' stroke-width='{preset.line}'/>",
(a, top),
(b, foot),
)
for (upper, first), (lower, second) in printed.links:
a = middle(lines[upper], first)
b = middle(lines[lower], second)
start = baselines[upper] + _LINK_DROP * size
end = baselines[lower] - _BRIDGE_FOOT * size
drawing.add(
f"<path class='disulfide' d='M {a:.4f},{start:.4f} L {b:.4f},{end:.4f}' "
f"fill='none' stroke='#000' stroke-width='{preset.line}'/>",
(min(a, b), start),
(max(a, b), end),
)
#: The gap between a caption and its structure, in bond lengths.
_CAPTION_GAP = 0.5
def _caption(drawing: _Drawing, fragment: KetFragment, preset: Preset) -> None:
"""``fragment``'s caption, such as ``R1 =``, to the left of its structure.
The first line is centred beside the structure; further lines, such as
an R-group's conditions, follow below it, ending at the same edge.
"""
x, y = _point((fragment.bounds[0] - _CAPTION_GAP, fragment.centre[1]), preset)
lines = (fragment.caption or "").split("\n")
line_height = preset.size * 1.3
spans = "".join(
f"<tspan x='{x:.4f}' y='{y + preset.size * 0.35 + 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.
width = max(map(len, lines)) * preset.size
drawing.add(
f"<text class='caption' text-anchor='end' xml:space='preserve' "
f"font-family='{preset.font}' font-size='{preset.size}'>{spans}</text>",
(x - width, y - preset.size / 2),
(x, y + preset.size / 2 + (len(lines) - 1) * line_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") not in ("molecule", "rgroup"):
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") == "simpleObject":
_shape_points(node)
elif node.get("type") == "image":
from vibesketch.render.nanostructures import depiction_of
from vibesketch.render.surfaces import surface_of
if depiction_of(node) is None and surface_of(node) is None:
raise RenderRefused(
"cannot render a picture on the canvas: figures draw only vibe-sketch's "
"nanostructure pictures"
)
elif node.get("type") not in ("arrow", "plus", "text"):
raise RenderRefused(f"cannot render KET object {node.get('type')!r}")
#: The shapes the canvas engine draws (Ketcher's simple objects).
CANVAS_SHAPES = ("rectangle", "ellipse", "line")
#: The shapes figures print: the canvas's, and KET's polyline, which reaches a
#: document only from a KET file or the API because Ketcher 3.18 cannot draw it.
SHAPES = (*CANVAS_SHAPES, "polyline")
#: The most points a polyline may have.
MAX_POLYLINE = 10_000
def _shape_points(node: dict[str, Any]) -> tuple[str, list[Point]]:
"""A shape's mode and its points in KET coordinates; refused unless drawable.
A rectangle, an ellipse and a line have two points, a polyline two or more.
"""
data = node.get("data") or {}
mode = data.get("mode")
if mode not in SHAPES:
raise RenderRefused(f"cannot render KET shape {mode!r}; shapes: {', '.join(SHAPES)}")
pos = data.get("pos") or []
if mode == "polyline":
if not 2 <= len(pos) <= MAX_POLYLINE:
raise RenderRefused(f"a KET polyline needs 2 to {MAX_POLYLINE} points, not {len(pos)}")
elif len(pos) != 2:
raise RenderRefused(f"a KET {mode} needs two points, not {len(pos)}")
points = [(float(p["x"]), float(p["y"])) for p in pos]
for x, y in points:
if not (math.isfinite(x) and math.isfinite(y)):
raise RenderRefused(f"a KET {mode} has a non-finite point")
return str(mode), points
def _shape(drawing: _Drawing, node: dict[str, Any], preset: Preset) -> None:
"""A rectangle, an ellipse inscribed in its two points' box, a line or a
polyline through its points, unfilled."""
mode, points = _shape_points(node)
drawn = [_point(point, preset) for point in points]
xs, ys = [x for x, _ in drawn], [y for _, y in drawn]
left, right, top, bottom = min(xs), max(xs), min(ys), max(ys)
stroke = f"fill='none' stroke='#000' stroke-width='{preset.line}'"
if mode == "rectangle":
body = (
f"<rect x='{left:.4f}' y='{top:.4f}' width='{right - left:.4f}' "
f"height='{bottom - top:.4f}' {stroke}/>"
)
elif mode == "ellipse":
body = (
f"<ellipse cx='{(left + right) / 2:.4f}' cy='{(top + bottom) / 2:.4f}' "
f"rx='{(right - left) / 2:.4f}' ry='{(bottom - top) / 2:.4f}' {stroke}/>"
)
else:
path = " L ".join(f"{x:.4f},{y:.4f}" for x, y in drawn)
join = " stroke-linejoin='round'" if mode == "polyline" else ""
body = f"<path d='M {path}' {stroke} stroke-linecap='round'{join}/>"
half = preset.line / 2
drawing.add(
f"<g class='shape' data-mode={quoteattr(mode)}>{body}</g>",
(left - half, top - half),
(right + half, bottom + half),
)
def _picture(
drawing: _Drawing, node: dict[str, Any], nanostructures: dict[str, Any], preset: Preset
) -> None:
"""A nanostructure picture drawn afresh from its species, in its box (#138)."""
from vibesketch.render.nanostructures import (
DepictionRefused,
caption_room,
depiction_of,
image_box,
picture,
picture_svg,
)
from vibesketch.render.surfaces import surface_of, surface_picture
surface = surface_of(node)
if surface is not None:
left, top, width, height = image_box(node)
x, y = _point((left, top), preset)
box = (x, y, width * preset.bond, height * preset.bond)
try:
body = picture_svg(
surface_picture(surface, nanostructures, width / height),
box,
preset.line,
preset.font,
preset.size,
)
except DepictionRefused as error:
raise RenderRefused(str(error)) from None
drawing.add(
f"<g class='surface' data-surface={quoteattr(surface.id)}>{body}</g>",
(x, y),
(x + box[2], y + box[3]),
)
return
found = depiction_of(node)
assert found is not None
depiction = found[0]
species = nanostructures.get(depiction.species)
if species is None:
raise RenderRefused(
f"a nanostructure picture names species {depiction.species}, which the document "
"does not have"
)
try:
left, top, width, height = image_box(node)
x, y = _point((left, top), preset)
box = (x, y, width * preset.bond, height * preset.bond)
shown = picture(species, depiction)
room = caption_room(shown, preset.size)
# A caption is set below the picture, inside the box the canvas shows.
box = (box[0], box[1], box[2], max(box[3] - room, box[3] / 2))
body = picture_svg(shown, box, preset.line, preset.font, preset.size)
except DepictionRefused as error:
raise RenderRefused(str(error)) from None
drawing.add(
f"<g class='nanostructure' data-species={quoteattr(depiction.species)} "
f"data-style={quoteattr(depiction.style)}>{body}</g>",
(box[0], box[1]),
(box[0] + box[2], box[1] + box[3] + room),
)
def _joined(fragment: KetFragment, anchors: list[tuple[float, float]]) -> None:
"""Drop the label of an attachment atom placed on a nanostructure's anchor.
Its bond then meets the picture where the anchor is, so a tube and the
molecule bonded to it print as one structure.
"""
from vibesketch.render.nanostructures import ANCHOR_REACH
figure = fragment.figure
if figure is None or not anchors or not figure.GetNumConformers():
return
conformer = figure.GetConformer()
for atom in figure.GetAtoms():
if not atom.HasProp("attachmentLabel"):
continue
at = conformer.GetAtomPosition(atom.GetIdx())
if any(math.hypot(at.x - x, at.y - y) <= ANCHOR_REACH for x, y in anchors):
atom.SetProp("_displayLabel", "")
atom.SetProp("_displayLabelW", "")
#: Superscript characters and the glyphs they are set with, raised and smaller (#141):
#: the publication font has no superscript minus or plus.
_SUPERSCRIPTS = dict(zip("⁻⁺⁰¹²³⁴⁵⁶⁷⁸⁹", "−+0123456789", strict=True))
#: A superscript's size and how far its baseline is raised, as fractions of the text size;
#: a subscript is lowered by _SUB_DROP.
_SUPER_SIZE, _SUPER_RISE, _SUB_DROP = 0.7, 0.38, 0.2
def _raised_text(drawing: _Drawing, lines: list[str], x: float, y: float, preset: Preset) -> None:
"""A text whose superscripts (2e⁻, Pb²⁺) are set raised and smaller, centred at x, y,
and a solid's formula (Fe₂O₃(s)) with its counts lowered.
Each run is a text of its own at a measured position, so the vector
formats, which take one size per text, print it the same way.
"""
from vibesketch.render.fonts import advance
line_height = preset.size * 1.3
top = y - len(lines) * line_height / 2
widest = 0.0
parts: list[str] = []
for number, line in enumerate(lines):
# Each run: its text and its shift, +1 raised, -1 lowered, 0 on the line.
runs: list[tuple[str, int]] = []
solid = solid_runs(line)
if solid is not None:
runs = [(part, -1 if low else 0) for part, low in solid]
else:
for char in line:
shift = 1 if char in _SUPERSCRIPTS else 0
glyph = _SUPERSCRIPTS.get(char, char)
if runs and runs[-1][1] == shift:
runs[-1] = (runs[-1][0] + glyph, shift)
else:
runs.append((glyph, shift))
widths = [advance(run) * preset.size * (_SUPER_SIZE if up else 1) for run, up in runs]
total = sum(widths)
widest = max(widest, total)
left = x - total / 2
baseline = top + preset.size + number * line_height
for (run, up), width in zip(runs, widths, strict=True):
size = preset.size * _SUPER_SIZE if up else preset.size
rise = preset.size * (_SUPER_RISE if up > 0 else -_SUB_DROP if up < 0 else 0.0)
parts.append(
f"<text x='{left:.4f}' y='{baseline - rise:.4f}' xml:space='preserve' "
f"font-family='{preset.font}' font-size='{size:g}'>{escape(run)}</text>"
)
left += width
drawing.add(
f"<g class='annotation'>{''.join(parts)}</g>",
(x - widest / 2, top),
(x + widest / 2, top + len(lines) * line_height),
)
[docs]
def scene_svg(
canvas: dict[str, Any],
preset: Preset = PRESETS["acs-1996"],
marks: Callable[[KetFragment], Marks] | None = None,
texts: Callable[[KetFragment], Printed | None] | None = None,
nanostructures: dict[str, Any] | None = None,
) -> bytes | None:
"""Render a drawing, or return ``None`` for an empty canvas.
``texts`` gives, per molecule, text printed in its place, such as a
peptide's sequence; ``None`` draws the structure. ``nanostructures``
maps species ids to the generated nanostructures the drawing's pictures
show.
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
shapes = [
node
for node in canvas.get("root", {}).get("nodes", [])
if node.get("type") == "simpleObject"
]
pictures = [n for n in canvas.get("root", {}).get("nodes", []) if n.get("type") == "image"]
if not (scene.fragments or scene.arrows or scene.pluses or scene.texts or shapes or pictures):
return None
drawing = _Drawing()
from vibesketch.render.nanostructures import DepictionRefused, anchor_points
try:
anchors = [(x, y) for p in pictures for x, y, _, _ in anchor_points(p).values()]
except DepictionRefused as error:
raise RenderRefused(str(error)) from None
for node in pictures:
_picture(drawing, node, nanostructures or {}, preset)
for index, fragment in enumerate(scene.fragments):
_joined(fragment, anchors)
if fragment.unsupported and fragment.figure is None:
raise RenderRefused(f"cannot render {fragment.node}: {fragment.unsupported}")
replacement = texts(fragment) if texts else None
if replacement is not None:
_in_place(drawing, fragment, index, replacement, preset)
continue
_molecule(drawing, fragment, index, preset, marks(fragment) if marks else [])
if fragment.caption:
_caption(drawing, fragment, preset)
for node in shapes:
_shape(drawing, node, 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")
if any(char in _SUPERSCRIPTS for char in text.text) or any(
solid_runs(line) is not None for line in lines
):
_raised_text(drawing, lines, x, y, preset)
continue
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()