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