"""Publication graphics from the document's drawing.
SVG preserves the KET scene's coordinates, repeated molecule occurrences,
arrows and annotations. RDKit draws molecules with the selected journal preset. Documents
without a drawing fall back to rows composed from their species/reactions.
The server, SDK, CLI, MCP and browser all use the same export implementation.
PNG and PDF use the optional publication backend, with the same scene SVG.
"""
from __future__ import annotations
import math
import re
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
from xml.sax.saxutils import escape
from vibesketch.document.model import Document, Reaction
from vibesketch.document.sequences import Printed
from vibesketch.render.fonts import embed_font
from vibesketch.render.styles import STYLES, Preset, finish_svg, prepare, resolve
__all__ = ["STYLES", "RenderRefused", "RenderUnavailable", "render", "render_svg", "thumbnail"]
_FONT = "Roboto, sans-serif"
_TEXT_PT = 10.0
_GAP = 12.0 # between items in a row, in pt
_ROW_GAP = 28.0
[docs]
class RenderRefused(ValueError):
"""The document cannot be rendered in the requested format or style."""
[docs]
class RenderUnavailable(RuntimeError):
"""This machine lacks a system library that RDKit's drawing code needs."""
def _draw2d() -> Any:
"""RDKit's 2D drawing module; raises :class:`RenderUnavailable` naming what is missing."""
try:
from rdkit.Chem.Draw import rdMolDraw2D
except ImportError as error:
# RDKit's Linux wheels bundle Cairo but link the system's X rendering
# libraries, which minimal images and headless servers do not carry.
raise RenderUnavailable(
f"drawing is unavailable on this machine ({error}). RDKit draws with Cairo, "
"which needs the X rendering libraries; on Debian or Ubuntu install them "
"with 'apt-get install libxrender1 libxext6'"
) from error
return rdMolDraw2D
@dataclass
class _Box:
"""A placed item: its SVG fragment and its size in pt."""
body: str
width: float
height: float
def _draw_structure(
drawer: Any,
mol: Any,
preset: Preset,
*,
zoom: float = 1.0,
highlights: dict[str, Any] | None = None,
) -> None:
"""Draw ``mol`` with the preset at ``zoom`` times its print size.
This is RDKit's ``DrawMoleculeACS1996``, spelled out so it can zoom and
draw a structure without bonds: RDKit sizes the drawing by the mean bond
length and refuses a lone atom or ion (H+, water, Na+). Labels have a
fixed size in this style, so any length serves there.
"""
from vibesketch.chem._rdkit import untyped
draw2d = _draw2d()
options = untyped(drawer.drawOptions())
if preset.stereo_labels:
from vibesketch.chem.cip import labelled
mol = labelled(mol)
length = float(draw2d.MeanBondLength(mol)) if mol.GetNumBonds() else 0.0
preset.configure(options, length)
if zoom != 1.0:
options.scalingFactor *= zoom
options.fixedBondLength *= zoom
options.bondLineWidth *= zoom
options.fixedFontSize = round(options.fixedFontSize * zoom)
options.minFontSize = round(options.minFontSize * zoom)
options.maxFontSize = max(options.maxFontSize, options.fixedFontSize)
drawer.DrawMolecule(mol, **(highlights or {}))
drawer.FinishDrawing()
def _structure(mol: Any, preset: Preset, marks: list[tuple[list[int], str]] | None = None) -> _Box:
from vibesketch.chem._rdkit import untyped
drawer = _draw2d().MolDraw2DSVG(-1, -1)
untyped(drawer.drawOptions()).clearBackground = False
mol, wedges = prepare(mol, preset)
if preset.name != "acs-1996":
untyped(drawer.drawOptions()).prepareMolsBeforeDrawing = False
from vibesketch.render.scene import highlight_options
_draw_structure(drawer, mol, preset, highlights=highlight_options(mol, marks or []))
text = finish_svg(str(drawer.GetDrawingText()), drawer, mol, wedges, preset)
match = re.search(r"<svg[^>]*width='([\d.]+)px'[^>]*height='([\d.]+)px'[^>]*>", text)
if match is None: # pragma: no cover - RDKit's header format
raise RenderRefused("could not read the size of RDKit's drawing")
width, height = float(match.group(1)), float(match.group(2))
body = text[match.end() : text.rfind("</svg>")]
body = body.replace("<!-- END OF HEADER -->", "").strip()
return _Box(body, width, height)
def _text_width(text: str, size: float) -> float:
return 0.52 * size * len(text)
def _text(x: float, y: float, text: str, *, size: float = _TEXT_PT, bold: bool = False) -> str:
weight = " font-weight='bold'" if bold else ""
return (
f"<text x='{x:.2f}' y='{y:.2f}' font-family='{_FONT}' font-size='{size:g}'"
f"{weight} text-anchor='middle'>{escape(text)}</text>"
)
def _arrow(x1: float, x2: float, y: float, kind: str, preset: Preset) -> str:
stroke = f"stroke='#000' stroke-width='{preset.line}' fill='none'"
head = 5.0
def filled(tip: float, direction: float, dy: float = 0.0, half: str = "both") -> str:
base = tip - direction * head
top = f"{base:.2f},{y + dy - 2.2:.2f}"
bottom = f"{base:.2f},{y + dy + 2.2:.2f}"
points = {
"both": f"{tip:.2f},{y + dy:.2f} {top} {bottom}",
"top": f"{tip:.2f},{y + dy:.2f} {top} {base:.2f},{y + dy:.2f}",
"bottom": f"{tip:.2f},{y + dy:.2f} {bottom} {base:.2f},{y + dy:.2f}",
}[half]
return f"<polygon points='{points}' fill='#000'/>"
if kind == "equilibrium":
return (
f"<line x1='{x1:.2f}' y1='{y - 1.5:.2f}' x2='{x2:.2f}' y2='{y - 1.5:.2f}' {stroke}/>"
+ filled(x2, 1, -1.5, "top")
+ f"<line x1='{x1:.2f}' y1='{y + 1.5:.2f}' x2='{x2:.2f}' y2='{y + 1.5:.2f}' {stroke}/>"
+ filled(x1, -1, 1.5, "bottom")
)
if kind == "retrosynthetic":
return (
f"<line x1='{x1:.2f}' y1='{y - 2:.2f}' x2='{x2 - 3:.2f}' y2='{y - 2:.2f}' {stroke}/>"
f"<line x1='{x1:.2f}' y1='{y + 2:.2f}' x2='{x2 - 3:.2f}' y2='{y + 2:.2f}' {stroke}/>"
f"<polyline points='{x2 - 6:.2f},{y - 5:.2f} {x2:.2f},{y:.2f} "
f"{x2 - 6:.2f},{y + 5:.2f}' {stroke}/>"
)
line = f"<line x1='{x1:.2f}' y1='{y:.2f}' x2='{x2:.2f}' y2='{y:.2f}' {stroke}/>"
if kind == "resonance":
return line + filled(x2, 1) + filled(x1, -1)
return line + filled(x2, 1)
def _row(items: list[tuple[str, _Box | str, str | None]], preset: Preset) -> _Box:
"""Lay ``items`` out left to right on one baseline.
Each item is ``(kind, payload, label)``: a structure box with an optional
compound label, a ``"+"``, or a prepared arrow box.
"""
label_band = preset.size + 6.0 if any(label for _, _, label in items) else 0.0
height = max((p.height for _, p, _ in items if isinstance(p, _Box)), default=20.0)
mid = height / 2
parts: list[str] = []
x = 0.0
for index, (kind, payload, label) in enumerate(items):
if index:
x += _GAP
if kind == "plus":
parts.append(_text(x + 6, mid + 4, "+", size=14))
x += 12
continue
assert isinstance(payload, _Box)
top = mid - payload.height / 2
parts.append(f"<g transform='translate({x:.2f},{top:.2f})'>{payload.body}</g>")
if label:
parts.append(
_text(
x + payload.width / 2,
height + preset.size + 2,
label,
size=preset.size,
bold=True,
)
)
x += payload.width
return _Box("".join(parts), x, height + label_band)
def _arrow_box(reaction: Reaction, reagents: list[_Box], preset: Preset) -> _Box:
above = reagents
lines = list(reaction.conditions)
if reaction.yield_:
lines.append(reaction.yield_)
above_lines: list[str] = []
below_lines = lines
if not above and lines:
above_lines, below_lines = lines[:1], lines[1:]
widths = [b.width for b in above] + [_text_width(t, preset.size) for t in lines]
length = max(48.0, sum(b.width for b in above) + _GAP * max(0, len(above) - 1), *widths) + 12
above_height = max((b.height for b in above), default=0.0) + preset.size * 1.3 * len(
above_lines
)
below_height = preset.size * 1.3 * len(below_lines)
y = above_height + 6
parts = [_arrow(0, length, y, reaction.arrow, preset)]
x = (length - (sum(b.width for b in above) + _GAP * max(0, len(above) - 1))) / 2
for box in above:
parts.append(f"<g transform='translate({x:.2f},{y - 3 - box.height:.2f})'>{box.body}</g>")
x += box.width + _GAP
for row, line in enumerate(above_lines):
parts.append(
_text(
length / 2,
y - 4 - preset.size * 1.3 * (len(above_lines) - 1 - row),
line,
size=preset.size,
)
)
for row, line in enumerate(below_lines):
parts.append(
_text(length / 2, y + 4 + preset.size * (1 + 1.3 * row), line, size=preset.size)
)
return _Box("".join(parts), length, y + 6 + below_height)
def _wrap(box: _Box, margin: float = 6.0) -> bytes:
width, height = box.width + 2 * margin, box.height + 2 * margin
svg = (
"<?xml version='1.0' encoding='UTF-8'?>\n"
f"<svg xmlns='http://www.w3.org/2000/svg' version='1.1' "
f"width='{width:.2f}pt' height='{height:.2f}pt' viewBox='0 0 {width:.2f} {height:.2f}'>"
f"<g transform='translate({margin},{margin})'>{box.body}</g></svg>\n"
)
return svg.encode("utf-8")
def _scheme(document: Document, mols: dict[str, Any], preset: Preset) -> _Box:
labels = {s.id: s.label for s in document.species}
from vibesketch.document.highlights import marks_for
boxes = {
sid: _structure(mol, preset, marks_for(document, sid, mol)) for sid, mol in mols.items()
}
rows: list[_Box] = []
for reaction in document.reactions:
items: list[tuple[str, _Box | str, str | None]] = []
for index, sid in enumerate(reaction.reactants):
if index:
items.append(("plus", "+", None))
items.append(("structure", boxes[sid], labels.get(sid)))
arrow = _arrow_box(reaction, [boxes[s] for s in reaction.reagents if s in boxes], preset)
items.append(("arrow", arrow, None))
for index, sid in enumerate(reaction.products):
if index:
items.append(("plus", "+", None))
items.append(("structure", boxes[sid], labels.get(sid)))
rows.append(_row(items, preset))
if not rows:
rows.append(
_row(
[("structure", boxes[s.id], s.label) for s in document.species if s.id in boxes],
preset,
)
)
parts: list[str] = []
y = 0.0
for row in rows:
parts.append(f"<g transform='translate(0,{y:.2f})'>{row.body}</g>")
y += row.height + _ROW_GAP
return _Box("".join(parts), max(r.width for r in rows), y - _ROW_GAP)
def _molecules(document: Document) -> dict[str, Any]:
from vibesketch.document.formats import ExportRefused, species_mols
try:
return species_mols(document)
except ExportRefused as error:
raise RenderRefused(str(error)) from error
def _marks(document: Document) -> Any:
"""The document's highlights for each drawn fragment, as the scene draws them."""
if not document.highlights:
return None
from vibesketch.chem.species import identity
from vibesketch.document.highlights import marks_for
by_smiles = {s.smiles: s.id for s in document.species}
def marks(fragment: Any) -> list[tuple[list[int], str]]:
if fragment.mol is None: # a drawing, such as a repeat unit, is no species
return []
species = by_smiles.get(identity(fragment.mol).smiles)
return marks_for(document, species, fragment.mol) if species else []
return marks
def _sequences(document: Document) -> Callable[[Any], Printed | None]:
"""Per scene fragment, the sequence a figure prints instead of its structure (#98)."""
from vibesketch.chem.species import identity
from vibesketch.document.sequences import SequenceRefused, printed
by_key = {s.inchikey: s.sequence.helm for s in document.species if s.sequence is not None}
def text(fragment: Any) -> Printed | None:
if fragment.mol is None or not by_key:
return None
helm = by_key.get(identity(fragment.mol).inchikey)
if helm is None:
return None
try:
return printed(helm)
except SequenceRefused as error:
raise RenderRefused(str(error)) from None
return text
def _preset(document: Document, style: str | None, stereo_labels: bool) -> Preset:
from dataclasses import replace
preset = resolve(style if style is not None else document.style.preset)
return replace(preset, stereo_labels=True) if stereo_labels else preset
[docs]
def render_svg(
document: Document,
*,
style: str | None = None,
stereo_labels: bool = False,
sequences: bool = False,
) -> bytes:
"""The drawing as an SVG figure, or reaction rows when no drawing exists.
With ``stereo_labels``, CIP descriptors and enhanced-stereo groups are
printed beside the structures, freshly assigned (:mod:`vibesketch.chem.cip`).
With ``sequences``, a species built from a sequence is printed as it,
H-Tyr-Gly-OH or 5'-ACGU-3', in place of its structure.
"""
from vibesketch.render.scene import scene_svg
preset = _preset(document, style, stereo_labels)
texts = _sequences(document) if sequences else None
made = {s.id: s for s in document.species if s.nanostructure is not None}
if svg := scene_svg(document.canvas.data, preset, _marks(document), texts, made):
return embed_font(svg)
if sequences and any(s.sequence is not None for s in document.species):
raise RenderRefused("printing sequences needs the drawing, and this document has none")
mols = _molecules(document)
if not mols:
raise RenderRefused("the document has nothing to render")
return embed_font(_wrap(_scheme(document, mols, preset)))
[docs]
def render(
document: Document,
fmt: str,
*,
style: str | None = None,
dpi: int = 300,
stereo_labels: bool = False,
sequences: bool = False,
) -> bytes:
"""Render SVG, PNG, PDF, EPS or EMF; PNG DPI defaults to 300 (36–2400).
EPS and EMF, for office documents, are written by vibe-sketch itself
from the same SVG with the text outlined (:mod:`vibesketch.render.vector`).
"""
if fmt not in ("svg", "png", "pdf", "eps", "emf"):
raise RenderRefused(f"cannot render {fmt!r}; formats: svg, png, pdf, eps, emf")
svg = render_svg(document, style=style, stereo_labels=stereo_labels, sequences=sequences)
if fmt == "svg":
return svg
if fmt in ("eps", "emf"):
from vibesketch.render.fonts import check_text
from vibesketch.render.vector import emf, eps
check_text(svg)
return eps(svg) if fmt == "eps" else emf(svg)
from vibesketch.render.publication import convert
try:
return convert(svg, fmt, dpi=dpi)
except RenderUnavailable:
# Keep the pre-publication-extra single-structure PNG capability. A
# Cairo thumbnail would silently lose scene text/arrows/repeated nodes.
from vibesketch.document.ket import read_scene
from vibesketch.render.publication import _png_density, dimensions
preset = _preset(document, style, stereo_labels)
scene = read_scene(document.canvas.data)
if (
fmt != "png"
or sequences
or preset.name != "acs-1996"
or document.reactions
or scene.arrows
or scene.texts
or scene.pluses
or len(scene.fragments) > 1
):
raise
mols = _molecules(document)
if len(mols) != 1:
raise
dimensions(svg, dpi)
drawer = _draw2d().MolDraw2DCairo(-1, -1)
_draw_structure(drawer, next(iter(mols.values())), preset, zoom=dpi / 72)
return _png_density(bytes(drawer.GetDrawingText()), dpi)
#: A thumbnail panel's size in pixels before scaling, and the panels a row holds at least.
_PANEL = (300, 225)
_COLUMNS = 4
#: The longest side of a thumbnail, in pixels (the QVF spec asks for 256 to 1024).
_THUMBNAIL_MAX = 1024
[docs]
def thumbnail(document: Document) -> tuple[bytes, int, int]:
"""A PNG of the document's species side by side, for file previews.
The panels shrink so the longest side stays within 1024 pixels; one
species fills a panel of 300 by 225. Returns the PNG, its width and its
height. Raises :class:`RenderRefused` for a document without species and
:class:`RenderUnavailable` without the drawing libraries.
"""
from vibesketch.chem._rdkit import untyped
mols = _molecules(document)
if not mols:
raise RenderRefused("the document has nothing to show")
labels = {s.id: s.label or s.id for s in document.species}
columns = min(len(mols), max(_COLUMNS, math.ceil(math.sqrt(len(mols)))))
rows = -(-len(mols) // columns)
scale = min(1.0, _THUMBNAIL_MAX / (_PANEL[0] * columns), _THUMBNAIL_MAX / (_PANEL[1] * rows))
panel = (int(_PANEL[0] * scale), int(_PANEL[1] * scale))
width, height = panel[0] * columns, panel[1] * rows
drawer = _draw2d().MolDraw2DCairo(width, height, *panel)
untyped(drawer.drawOptions()).legendFontSize = 14
drawer.DrawMolecules(list(mols.values()), legends=[labels[i] for i in mols])
drawer.FinishDrawing()
return bytes(drawer.GetDrawingText()), width, height