Source code for vibesketch.render

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