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