Source code for vibesketch.render.styles

"""Journal drawing parameters in print points; sources are also in formats.md.

RSC: Green Chemistry author guidelines, chemical structures.
https://www.rsc.org/publishing/publish-with-us/publish-a-journal-article/green-chemistry
Wiley: Journal of Computational Chemistry graphics FAQ (not all Wiley journals).
https://onlinelibrary.wiley.com/page/journal/1096987x/homepage/graphics_faq_jcc.html
"""

from __future__ import annotations

import math
import re
from dataclasses import dataclass
from typing import Any


[docs] @dataclass(frozen=True) class Preset: name: str bond: float line: float size: int spacing: float bold: float hash_spacing: float margin: float font: str = "Arial, Helvetica, sans-serif"
[docs] def configure(self, options: Any, mean_bond: float = 1.0) -> None: from vibesketch.render import _draw2d _draw2d().SetACS1996Mode(options, mean_bond if mean_bond > 0 else 1.0) if self.name == "acs-1996": return options.scalingFactor = self.bond / (mean_bond if mean_bond > 0 else 1.0) options.fixedBondLength = self.bond options.bondLineWidth = self.line options.fixedFontSize = self.size options.multipleBondOffset = self.spacing options.additionalAtomLabelPadding = self.margin / self.bond
# RSC does not specify label margin: 2 pt is our explicit default. # ACS stays on RDKit's established renderer, including its wedge geometry. PRESETS = { "acs-1996": Preset("acs-1996", 14.4, 0.6, 10, 0.18, 2.0, 2.5, 0.95), "rsc": Preset("rsc", 12.2, 0.5, 7, 0.20, 1.6, 1.8, 2.0), "wiley": Preset("wiley", 17.0, 0.75, 12, 0.18, 2.6, 2.6, 2.0), } STYLES = tuple(PRESETS)
[docs] def resolve(name: str) -> Preset: from vibesketch.render import RenderRefused try: return PRESETS[name] except KeyError: raise RenderRefused(f"no style preset {name!r}; available: {', '.join(STYLES)}") from None
[docs] def prepare(mol: Any, preset: Preset) -> tuple[Any, dict[int, bool]]: """Prepare a copy; measure dashed bonds as solid wedges for label clipping. RDKit has no independent bold-width/hash-spacing controls. Its measured wedge endpoints retain label clearance; finish_svg replaces only those bonds, preserving RDKit's stereo assignment and all other graphics. """ if preset.name == "acs-1996": return mol, {} from rdkit import Chem from vibesketch.render import _draw2d prepared = _draw2d().PrepareMolForDrawing(mol) wedges: dict[int, bool] = {} for bond in prepared.GetBonds(): direction = bond.GetBondDir() if direction in (Chem.BondDir.BEGINWEDGE, Chem.BondDir.BEGINDASH): wedges[bond.GetIdx()] = direction == Chem.BondDir.BEGINDASH bond.SetBondDir(Chem.BondDir.BEGINWEDGE) return prepared, wedges
[docs] def finish_svg(svg: str, drawer: Any, mol: Any, wedges: dict[int, bool], preset: Preset) -> str: """Apply independent stereo widths and physical hash spacing to RDKit SVG.""" from vibesketch.render import RenderRefused if preset.name != "acs-1996": # RDKit rounds SVG stroke widths to tenths (0.75 becomes 0.8). svg = svg.replace(f"stroke-width:{preset.line:.1f}px", f"stroke-width:{preset.line:g}px") paths = list(re.finditer(r"<path\b[^>]*?/>", svg, re.DOTALL)) for index, hashed in wedges.items(): selected = [p for p in paths if re.search(rf"class='bond-{index} ", p.group())] if not selected: raise RenderRefused("could not measure a publication stereo bond") bond = mol.GetBondWithIdx(index) start = drawer.GetDrawCoords(bond.GetBeginAtomIdx()) end = drawer.GetDrawCoords(bond.GetEndAtomIdx()) dx, dy = end.x - start.x, end.y - start.y length = math.hypot(dx, dy) if length < 1e-8: raise RenderRefused("cannot render a stereo bond with coincident atoms") ux, uy = dx / length, dy / length projected: list[float] = [] for path in selected: data = re.search(r"\bd='([^']*)'", path.group()) if data is None or re.search(r"[^MLZmlz0-9., +eE\-\n]", data[1]): raise RenderRefused("could not measure a publication stereo outline") numbers = [ float(n) for n in re.findall(r"[-+]?(?:\d*\.)?\d+(?:[eE][-+]?\d+)?", data[1]) ] projected.extend( (x - start.x) * ux + (y - start.y) * uy for x, y in zip(numbers[::2], numbers[1::2], strict=True) ) low, high = max(0.0, min(projected)), min(length, max(projected)) if high <= low: raise RenderRefused("the stereo bond has no room between its labels") def point( distance: float, offset: float = 0.0, x: float = start.x, y: float = start.y, vx: float = ux, vy: float = uy, ) -> str: return f"{x + vx * distance - vy * offset:.4f},{y + vy * distance + vx * offset:.4f}" attributes = ( f"class='bond-{index} atom-{bond.GetBeginAtomIdx()} atom-{bond.GetEndAtomIdx()}' " f"data-stereo='{'hash' if hashed else 'wedge'}'" ) if hashed: # Keep the physical spacing exact; centre the strokes in the visible bond. count = max(1, math.floor((high - low) / preset.hash_spacing)) first = (low + high - (count - 1) * preset.hash_spacing) / 2 segments = [] for i in range(count): distance = first + i * preset.hash_spacing half = preset.bold * (distance - low) / (high - low) / 2 segments.append(f"M {point(distance, -half)} L {point(distance, half)}") replacement = ( f"<path {attributes} d='{' '.join(segments)}' fill='none' " f"stroke='#000' stroke-width='{preset.line}' stroke-linecap='butt'/>" ) else: replacement = ( f"<path {attributes} d='M {point(low)} L {point(high, -preset.bold / 2)} " f"L {point(high, preset.bold / 2)} Z' fill='#000' stroke='none'/>" ) for position, path in enumerate(selected): svg = svg.replace(path.group(), replacement if position == 0 else "", 1) return svg