"""Validated schematic surface images. Publication output never trusts canvas SVG (#140)."""
from __future__ import annotations
import base64
import html
import json
import math
import re
from dataclasses import replace
from typing import Any
from pydantic import ValidationError
from vibesketch.document.model import Surface
from vibesketch.render.nanostructures import (
Depiction,
DepictionRefused,
Picture,
_Ellipse,
_Line,
_Text,
image_box,
picture,
picture_svg,
)
SCHEMA = "vibe-sketch-surface/1"
MARKER = "vibe-sketch-surface"
[docs]
def surface_of(node: dict[str, Any]) -> Surface | None:
"""Read bounded metadata only; refuse corrupt recognised surface objects."""
from vibesketch.limits import parse_json
if node.get("type") != "image" or node.get("format") != "image/svg+xml":
return None
data = node.get("data")
if not isinstance(data, str) or len(data) > 2_000_000:
return None
try:
svg = base64.b64decode(data, validate=True).decode("utf-8")
except (ValueError, UnicodeDecodeError):
return None
if MARKER not in svg:
return None
match = re.search(r"<metadata id=['\"]vibe-sketch-surface['\"]>([^<]*)</metadata>", svg)
try:
if match is None:
raise ValueError("missing surface metadata")
payload = parse_json(html.unescape(match[1]), "surface metadata")
if not isinstance(payload, dict) or payload.pop("schema", None) != SCHEMA:
raise ValueError("unsupported surface metadata schema")
result = Surface.model_validate(payload)
image_box(node)
return result
except (ValueError, ValidationError) as error:
raise DepictionRefused(f"invalid surface metadata: {error}") from None
[docs]
def anchor_points(node: dict[str, Any]) -> dict[str, tuple[float, float, float, float]]:
surface = surface_of(node)
if surface is None:
return {}
x, y, w, h = image_box(node)
return {name: (x + a.u * w, y - a.v * h, a.dx, a.dy) for name, a in surface.anchors.items()}
[docs]
def surface_picture(surface: Surface, species: dict[str, Any], aspect: float = 1) -> Picture:
"""Schematic geometry in a unit box; dimensions are labels, never physical coordinates."""
result = Picture()
# Fixed bounds keep metadata's fractional anchors aligned with resized images.
result.items.extend([_Line([(0, 0), (0, 0)], grey=1), _Line([(1, 1), (1, 1)], grey=1)])
if surface.kind == "nanoparticle":
result.items.append(_Ellipse((0.5, 0.5), 0.5, 0.5))
if surface.ligand_shell:
result.items.append(_Ellipse((0.5, 0.5), 0.43, 0.43, grey=0.5))
label = f"{surface.material}, {surface.diameter_nm:g} nm"
result.items.append(_Text((0.5, 0.5), label))
if surface.ligand_shell:
result.items.append(_Text((0.5, 0.32), surface.ligand_shell))
else:
layers = surface.layers or [surface.material]
slab_top = 0.25 if surface.kind == "nanotube-array" else 1.0
for i, layer in enumerate(layers):
low, high = i * slab_top / len(layers), (i + 1) * slab_top / len(layers)
result.items.append(_Line([(0, low), (1, low), (1, high), (0, high)], closed=True))
for j in range(10):
x = j / 10
result.items.append(_Line([(x, low), (min(1, x + 0.06), high)], 0.35, 0.8))
text = layer + (f"({surface.facet})" if surface.facet and i == len(layers) - 1 else "")
result.items.append(_Text((0.5, (low + high) / 2), text))
if surface.kind == "nanotube-array":
tube = species.get(surface.nanotube or "")
if (
tube is None
or tube.nanostructure is None
or tube.nanostructure.parameters.kind != "nanotube"
):
raise DepictionRefused("a nanotube-array needs an existing nanotube species")
glyph = picture(tube, Depiction(tube.id, tube.nanostructure.key, brk="off"))
left, bottom, right, top = glyph.bounds()
for index in range(surface.count):
cx = (index + 0.5) / surface.count
def turn(p: tuple[float, float], cx: float = cx) -> tuple[float, float]:
return cx - p[1] * 0.8 / (surface.count * (top - bottom)), 0.25 + (
p[0] - left
) * 0.75 / (right - left)
for item in glyph.items:
if isinstance(item, _Line):
result.items.append(replace(item, points=[turn(p) for p in item.points]))
elif isinstance(item, _Ellipse):
result.items.append(
replace(
item,
centre=turn(item.centre),
rx=item.ry * 0.8 / (surface.count * (top - bottom)),
ry=item.rx * 0.75 / (right - left),
)
)
for a in surface.anchors.values():
if a.repeat > 1:
result.items.append(_Text((a.u, 1 - a.v - 0.22), f"x{a.repeat}"))
for i, item in enumerate(result.items):
if isinstance(item, _Line):
result.items[i] = replace(item, points=[(x * aspect, y) for x, y in item.points])
elif isinstance(item, _Ellipse):
result.items[i] = replace(
item, centre=(item.centre[0] * aspect, item.centre[1]), rx=item.rx * aspect
)
else:
result.items[i] = replace(item, at=(item.at[0] * aspect, item.at[1]))
return result
[docs]
def canvas_image(
surface: Surface, species: dict[str, Any], at: tuple[float, float], width: float, height: float
) -> dict[str, Any]:
if not all(math.isfinite(v) for v in (*at, width, height)) or not (
1 <= width <= 100 and 1 <= height <= 100
):
raise DepictionRefused(
"surface dimensions must be 1 to 100 bond lengths and position finite"
)
metadata = html.escape(
json.dumps(
{"schema": SCHEMA, **surface.model_dump(mode="json", exclude_none=True)}, sort_keys=True
),
quote=False,
)
body = picture_svg(
surface_picture(surface, species, width / height),
(0, 0, width * 30, height * 30),
1,
"Arial",
10,
)
svg = (
f"<svg xmlns='http://www.w3.org/2000/svg' width='{width * 30}' height='{height * 30}'>"
f"<metadata id='{MARKER}'>{metadata}</metadata>{body}</svg>"
)
return {
"type": "image",
"format": "image/svg+xml",
"boundingBox": {"x": at[0], "y": at[1], "width": width, "height": height},
"data": base64.b64encode(svg.encode()).decode(),
}