"""Edit operations: `(doc, op) -> doc`, the only way a document ever changes. MCP tool calls, the HTML surface, the browser editor and the computer-use agent all end up here. That is what makes the surfaces comparable: a drag in the editor and an `update_element` call are the same operation, validated the same way, recorded in the same trajectory format. Operations never mutate their input. `apply` copies, edits the copy and returns it with a small result dict, so undo is just keeping the previous document. """ from __future__ import annotations import difflib import math from dataclasses import dataclass, field from typing import Any from .colors import parse_color from .doc import DesignDoc, Element, Run from .fonts import FontRegistry from .text import fit_box class OpError(ValueError): """A rejected operation. The message is written for the model to read.""" @dataclass class OpContext: fonts: FontRegistry assets: dict[str, dict] = field(default_factory=dict) # asset_id -> {"aspect", "kind", ...} COMMON_PROPS = {"name", "x", "y", "width", "height", "angle", "opacity", "visible", "locked", "flip_x", "flip_y"} KIND_PROPS = { "image": {"asset_id"}, "shape": {"shape", "fill", "stroke", "stroke_width", "radius"}, "text": {"text", "font_family", "font_size", "color", "bold", "italic", "underline", "align", "line_height", "letter_spacing", "uppercase", "runs", "auto_width"}, } TEXT_LAYOUT_PROPS = {"text", "font_family", "font_size", "bold", "italic", "line_height", "letter_spacing", "uppercase", "runs"} def _num(name: str, value: Any, lo: float | None = None, hi: float | None = None) -> float: try: v = float(value) except (TypeError, ValueError): raise OpError(f"{name} must be a number, got {value!r}") from None if not math.isfinite(v): raise OpError(f"{name} must be finite") if lo is not None and v < lo: raise OpError(f"{name} must be >= {lo}, got {v}") if hi is not None and v > hi: raise OpError(f"{name} must be <= {hi}, got {v}") return v def _check_geometry(doc: DesignDoc, props: dict) -> None: span = 5 * max(doc.width, doc.height) for key in ("x", "y"): if key in props: props[key] = _num(key, props[key], -span, span) for key in ("width", "height"): if key in props: props[key] = _num(key, props[key], 1, span) if "angle" in props: props["angle"] = _num("angle", props["angle"]) if "opacity" in props: props["opacity"] = _num("opacity", props["opacity"], 0, 1) def _font(ctx: OpContext, family: str | None) -> str: if family is None: return ctx.fonts.resolve(None) if ctx.fonts.available and not ctx.fonts.knows(family): close = difflib.get_close_matches(str(family), ctx.fonts.families(), n=3, cutoff=0.5) hint = f" Did you mean: {', '.join(close)}?" if close else " Call list_fonts to see them." raise OpError(f"font {family!r} is not available.{hint}") return ctx.fonts.resolve(family) def _color(name: str, value) -> str: try: return parse_color(value) except ValueError as exc: raise OpError(f"{name}: {exc}") from None def _runs(value) -> list[Run] | None: if value in (None, []): return None if not isinstance(value, list): raise OpError("runs must be a list of {text, color?, bold?, italic?}") try: return [Run(**r) if isinstance(r, dict) else Run.model_validate(r) for r in value] except Exception as exc: # pydantic's message is fine for a model to read raise OpError(f"invalid runs: {exc}") from None def _refit_text(el: Element, ctx: OpContext) -> None: width, height = fit_box(el, ctx.fonts) el.width = width el.height = height def _default_box(doc: DesignDoc, aspect: float) -> tuple[float, float]: side = 0.4 * min(doc.width, doc.height) if aspect >= 1: return side * aspect if side * aspect <= doc.width else doc.width, side return side, side / aspect # ------------------------------------------------------------------ ops def add_image(doc: DesignDoc, ctx: OpContext, asset_id: str, x=None, y=None, width=None, height=None, angle=0.0, opacity=1.0, name="") -> dict: if asset_id not in ctx.assets: known = ", ".join(sorted(ctx.assets, key=lambda a: int(a[1:]) if a[1:].isdigit() else 0)) raise OpError(f"unknown asset {asset_id!r}; available: {known or 'none'}") aspect = float(ctx.assets[asset_id].get("aspect") or 1.0) props = {k: v for k, v in dict(x=x, y=y, width=width, height=height, angle=angle, opacity=opacity).items() if v is not None} _check_geometry(doc, props) w, h = props.get("width"), props.get("height") if w is None and h is None: w, h = _default_box(doc, aspect) elif w is None: w = h * aspect elif h is None: h = w / aspect px = props.get("x", (doc.width - w) / 2) py = props.get("y", (doc.height - h) / 2) el = Element(id=doc.new_id(), kind="image", asset_id=asset_id, x=px, y=py, width=w, height=h, angle=props.get("angle", 0.0), opacity=props.get("opacity", 1.0), name=name or "") doc.elements.append(el) return {"element_id": el.id, "element": el.public()} def add_text(doc: DesignDoc, ctx: OpContext, text: str, x=None, y=None, width=None, font_family=None, font_size=48.0, color="#000000", align="left", bold=False, italic=False, underline=False, line_height=1.0, letter_spacing=0.0, uppercase=False, angle=0.0, opacity=1.0, runs=None, name="") -> dict: if not isinstance(text, str) or (not text and not runs): raise OpError("text must be a non-empty string") if align not in ("left", "center", "right"): raise OpError("align must be left, center or right") props = {k: v for k, v in dict(x=x, y=y, width=width, angle=angle, opacity=opacity).items() if v is not None} _check_geometry(doc, props) el = Element( id=doc.new_id(), kind="text", text=text, name=name or "", font_family=_font(ctx, font_family), font_size=_num("font_size", font_size, 1, 2000), color=_color("color", color), align=align, bold=bool(bold), italic=bool(italic), underline=bool(underline), line_height=_num("line_height", line_height, 0.1, 10), letter_spacing=_num("letter_spacing", letter_spacing, -50, 500), uppercase=bool(uppercase), angle=props.get("angle", 0.0), opacity=props.get("opacity", 1.0), runs=_runs(runs), width=props.get("width", 10.0), auto_width="width" not in props, ) _refit_text(el, ctx) el.x = props.get("x", (doc.width - el.width) / 2) el.y = props.get("y", (doc.height - el.height) / 2) doc.elements.append(el) return {"element_id": el.id, "element": el.public()} def add_shape(doc: DesignDoc, ctx: OpContext, shape="rect", x=None, y=None, width=None, height=None, fill="#d9d9d9", stroke=None, stroke_width=0.0, radius=0.0, angle=0.0, opacity=1.0, name="") -> dict: if shape not in ("rect", "ellipse"): raise OpError("shape must be rect or ellipse") props = {k: v for k, v in dict(x=x, y=y, width=width, height=height, angle=angle, opacity=opacity).items() if v is not None} _check_geometry(doc, props) side = 0.25 * min(doc.width, doc.height) w, h = props.get("width", side), props.get("height", side) el = Element( id=doc.new_id(), kind="shape", shape=shape, name=name or "", x=props.get("x", (doc.width - w) / 2), y=props.get("y", (doc.height - h) / 2), width=w, height=h, angle=props.get("angle", 0.0), opacity=props.get("opacity", 1.0), fill=None if fill in (None, "none", "") else _color("fill", fill), stroke=None if stroke in (None, "none", "") else _color("stroke", stroke), stroke_width=_num("stroke_width", stroke_width or 0, 0, 1000), radius=_num("radius", radius or 0, 0, 10000), ) doc.elements.append(el) return {"element_id": el.id, "element": el.public()} def update_element(doc: DesignDoc, ctx: OpContext, element_id: str, **props) -> dict: el = doc.find(element_id) props = {k: v for k, v in props.items() if v is not None or k in ("stroke", "fill", "runs")} allowed = COMMON_PROPS | KIND_PROPS[el.kind] bad = sorted(set(props) - allowed) if bad: raise OpError( f"{el.kind} elements have no {', '.join(bad)}; settable: {', '.join(sorted(allowed))}" ) if not props: raise OpError("nothing to update") _check_geometry(doc, props) if el.kind == "text": if "height" in props: # Text height follows the lines, as in any editor's auto-height box. props.pop("height") if "font_family" in props: props["font_family"] = _font(ctx, props["font_family"]) if "font_size" in props: props["font_size"] = _num("font_size", props["font_size"], 1, 2000) if "line_height" in props: props["line_height"] = _num("line_height", props["line_height"], 0.1, 10) if "letter_spacing" in props: props["letter_spacing"] = _num("letter_spacing", props["letter_spacing"], -50, 500) if "color" in props: props["color"] = _color("color", props["color"]) if "align" in props and props["align"] not in ("left", "center", "right"): raise OpError("align must be left, center or right") if "runs" in props: props["runs"] = _runs(props["runs"]) elif "text" in props: props["runs"] = None if "width" in props: props.setdefault("auto_width", False) if el.kind == "image" and "asset_id" in props and props["asset_id"] not in ctx.assets: raise OpError(f"unknown asset {props['asset_id']!r}") if el.kind == "shape": for key in ("fill", "stroke"): if key in props: props[key] = None if props[key] in (None, "none", "") else _color(key, props[key]) if "shape" in props and props["shape"] not in ("rect", "ellipse"): raise OpError("shape must be rect or ellipse") # Anchor an auto-width text box on its alignment edge, so centred text # stays centred when it grows, as it does in Figma and Canva. anchor = None if el.kind == "text" and el.auto_width and "x" not in props: anchor = {"left": el.x, "center": el.x + el.width / 2, "right": el.x + el.width}[el.align] try: for key, value in props.items(): setattr(el, key, value) except Exception as exc: raise OpError(f"invalid value: {exc}") from None if el.kind == "text" and (TEXT_LAYOUT_PROPS & set(props) or "width" in props or "auto_width" in props): _refit_text(el, ctx) if anchor is not None and el.auto_width: el.x = {"left": anchor, "center": anchor - el.width / 2, "right": anchor - el.width}[el.align] return {"element_id": el.id, "element": el.public()} def delete_element(doc: DesignDoc, ctx: OpContext, element_id: str) -> dict: idx = doc.index_of(element_id) doc.elements.pop(idx) return {"deleted": element_id} def duplicate_element(doc: DesignDoc, ctx: OpContext, element_id: str, dx=20.0, dy=20.0) -> dict: idx = doc.index_of(element_id) clone = doc.elements[idx].model_copy(deep=True) clone.id = doc.new_id() clone.x += _num("dx", dx) clone.y += _num("dy", dy) doc.elements.insert(idx + 1, clone) return {"element_id": clone.id, "element": clone.public()} def reorder_element(doc: DesignDoc, ctx: OpContext, element_id: str, to="front") -> dict: idx = doc.index_of(element_id) el = doc.elements.pop(idx) n = len(doc.elements) if to == "front": new = n elif to == "back": new = 0 elif to == "forward": new = min(n, idx + 1) elif to == "backward": new = max(0, idx - 1) else: try: new = int(to) except (TypeError, ValueError): doc.elements.insert(idx, el) raise OpError("to must be front, back, forward, backward or a layer index") from None new = max(0, min(n, new)) doc.elements.insert(new, el) return {"element_id": element_id, "index": new, "order": [e.id for e in doc.elements]} def _bounds(el: Element) -> tuple[float, float, float, float]: """Axis-aligned bounds of the rotated box.""" if not el.angle: return el.x, el.y, el.x + el.width, el.y + el.height cx, cy = el.x + el.width / 2, el.y + el.height / 2 a = math.radians(el.angle) hw = (abs(el.width * math.cos(a)) + abs(el.height * math.sin(a))) / 2 hh = (abs(el.width * math.sin(a)) + abs(el.height * math.cos(a))) / 2 return cx - hw, cy - hh, cx + hw, cy + hh def align_elements(doc: DesignDoc, ctx: OpContext, element_ids: list[str], alignment: str, relative_to: str = "canvas") -> dict: if isinstance(element_ids, str): element_ids = [element_ids] els = [doc.find(e) for e in element_ids] if not els: raise OpError("element_ids is empty") if relative_to == "selection" and len(els) > 1: boxes = [_bounds(e) for e in els] ref = (min(b[0] for b in boxes), min(b[1] for b in boxes), max(b[2] for b in boxes), max(b[3] for b in boxes)) else: ref = (0.0, 0.0, float(doc.width), float(doc.height)) for el in els: x0, y0, x1, y1 = _bounds(el) if alignment == "left": el.x += ref[0] - x0 elif alignment == "center": el.x += (ref[0] + ref[2]) / 2 - (x0 + x1) / 2 elif alignment == "right": el.x += ref[2] - x1 elif alignment == "top": el.y += ref[1] - y0 elif alignment == "middle": el.y += (ref[1] + ref[3]) / 2 - (y0 + y1) / 2 elif alignment == "bottom": el.y += ref[3] - y1 else: raise OpError("alignment must be left, center, right, top, middle or bottom") return {"aligned": [e.id for e in els], "elements": [e.public() for e in els]} def distribute_elements(doc: DesignDoc, ctx: OpContext, element_ids: list[str], axis: str = "horizontal") -> dict: els = [doc.find(e) for e in element_ids] if len(els) < 3: raise OpError("distribute needs at least three elements") if axis not in ("horizontal", "vertical"): raise OpError("axis must be horizontal or vertical") lo, hi = (0, 2) if axis == "horizontal" else (1, 3) els.sort(key=lambda e: _bounds(e)[lo]) boxes = [_bounds(e) for e in els] total = sum(b[hi] - b[lo] for b in boxes) gap = (boxes[-1][hi] - boxes[0][lo] - total) / (len(els) - 1) cursor = boxes[0][hi] + gap for el, box in zip(els[1:-1], boxes[1:-1], strict=True): shift = cursor - box[lo] if axis == "horizontal": el.x += shift else: el.y += shift cursor += box[hi] - box[lo] + gap return {"distributed": [e.id for e in els]} def set_background(doc: DesignDoc, ctx: OpContext, color: str) -> dict: doc.background = _color("color", color) return {"background": doc.background} def clear_canvas(doc: DesignDoc, ctx: OpContext) -> dict: doc.elements = [] doc.background = "#ffffff" return {"cleared": True} def replace_document(doc: DesignDoc, ctx: OpContext, document: dict) -> dict: """Swap in a whole document (HTML surface, editor import). Size is fixed by the task. Elements parsed from HTML may carry `_anchor: [ax, ay]`: the fraction of their own final size to shift left/up by (`translate(-50%)`, `right:`, `bottom:`). It is applied after text is laid out, since that is when a text box first has a width, and never stored. """ raw = [dict(e) for e in document.get("elements", [])] anchors = [e.pop("_anchor", None) for e in raw] try: new = DesignDoc.model_validate({**document, "elements": raw, "width": doc.width, "height": doc.height}) except Exception as exc: raise OpError(f"invalid document: {exc}") from None for el, anchor in zip(new.elements, anchors, strict=True): if el.kind == "image" and el.asset_id not in ctx.assets: raise OpError(f"unknown asset {el.asset_id!r}") if el.kind == "text": el.font_family = _font(ctx, el.font_family) _refit_text(el, ctx) if anchor: el.x -= float(anchor[0]) * el.width el.y -= float(anchor[1]) * el.height ids = {e.id for e in new.elements} nums = [int(i[1:]) for i in ids if i.startswith("e") and i[1:].isdigit()] new.next_id = max([doc.next_id - 1, *nums, 0]) + 1 doc.background = new.background doc.elements = new.elements doc.next_id = new.next_id return {"elements": len(doc.elements)} OPS = { "add_image": add_image, "add_text": add_text, "add_shape": add_shape, "update_element": update_element, "delete_element": delete_element, "duplicate_element": duplicate_element, "reorder_element": reorder_element, "align_elements": align_elements, "distribute_elements": distribute_elements, "set_background": set_background, "clear_canvas": clear_canvas, "replace_document": replace_document, } # Ops that also exist as editor gestures but must not be offered as agent tools. INTERNAL_OPS = {"replace_document"} def apply(doc: DesignDoc, op: str, args: dict | None, ctx: OpContext) -> tuple[DesignDoc, dict]: fn = OPS.get(op) if fn is None: raise OpError(f"unknown operation {op!r}") new = doc.copy_deep() try: result = fn(new, ctx, **(args or {})) except OpError: raise except KeyError as exc: raise OpError(str(exc).strip("'\"")) from None except TypeError as exc: raise OpError(f"{op}: {exc}") from None return new, result