|
EazyDraw Automation API — ReferenceThe Automation API is part of EazyDraw 12.10 and later, in both the direct-download and App Store editions. It is a local HTTP API served by the running app; turn it on and copy its bearer token in EazyDraw ▸ Settings ▸ API Settings. The Machine-readable contract: ServerThe API is served by EazyDraw itself while the app is running and the API is enabled. There are two transports with the same wire protocol, handlers, and token: TCP on VersioningAll resource paths under AuthenticationAll endpoints require a bearer token. Clients supply it in an HTTP header on every request:
Python example
Threat modelThe token protects against:
The token does not protect against:
Endpoints
UUID matching is case-insensitive against Recursive group traversalThe path grammar for descending into nested groups is:
Each The terminal segment is always
|
elementType (from GET /v1/libraries/{L}/elements) | Behavior |
|---|---|
graphic | A copy of the library's embedded graphic is placed on the target layer. |
create-tool | A fresh default graphic of the tool's class is instantiated and placed. Initial size depends on the drawing's current zoom (the existing DKDLibPalette use-action does this). |
arrange-tool | Rejected with 422. Arrange tools don't place graphics. |
attribute-action | Rejected with 422. Attribute-action elements modify a graphic's attributes; they don't add graphics. Use POST .../graphics/{uuid}/attributes (below) to apply the transfer to a target graphic. |
Success — 201 Created. Response body is the standard <graphic> dict shape — index, graphicUUID (freshly generated, never matches the library's source UUID), type, typeName, nameGraphic (set to the library element's nameElement), hiddenBounds, and graphicsCount if the placed graphic is a DKDGroup. The returned graphicUUID is immediately usable for follow-up GET / DELETE / future morph calls.
Position and size. Initial position and size come from the existing DKDLibPalette use-action machinery. For graphic elements, they reflect the library author's design (the graphic's saved bounds). For create-tool elements, they reflect the drawing's zoom state at the time of placement. Neither is predictable in detail — a follow-up position / size / rotation endpoint (planned, modeled on EazyDraw's Morph palette) will let clients place a graphic and then move it precisely.
Errors:
400 Bad Request — missing UUIDs in path, empty body, body is not a JSON object, or missing/empty libraryUUID / elementUUID.401 Unauthorized — bearer token missing or wrong.404 Not Found — drawing, layer, library, or element UUID does not resolve. Body: { "error": "Drawing not found" | "Layer not found" | "Library not found" | "Library element not found" }.422 Unprocessable Entity — element is arrange-tool / attribute-action, or use action produced no graphic for unexpected reasons.500 Internal Server Error — response build failure.Fresh UUIDs. After the graphic is added to the target layer, [newGraphic recurseNewUUIDs] runs to assign fresh graphicUUID values for the new graphic and every descendant (groups, sub-graphics). The library's source UUIDs are never reused — a library can be placed any number of times into one or more drawings and each placement has a distinct identity. (The companion recurseUUID: toggle method, used by the Library and Properties panels, retains its original "assign-if-missing / clear" semantics; recurseNewUUIDs is a new method specifically for force-regeneration after placement.)
Threading. Lookup of doc/layer/lib/element, classification, property-list build, setActiveLayer: save-and-restore, the use-action call (-[DKDDocumentView addGraphicsWithArrayOfPropertyLists:]), and recurseNewUUIDs all run inside one dispatch_sync(dispatch_get_main_queue(), ...) block. The target document's activeLayer is briefly set to the API-supplied target layer for the duration of the add, then restored — this routes the new graphic to the right layer using the existing use-action infrastructure without permanently disturbing UI state.
curl:
curl -i -X POST -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"libraryUUID":"'"$LIB_UUID"'","elementUUID":"'"$EL_UUID"'"}' \
http://localhost:52737/v1/drawings/$DRAWING_UUID/layers/$LAYER_UUID/graphics
Python:
r = session.post(
f"http://localhost:52737/v1/drawings/{drawing_uuid}/layers/{layer_uuid}/graphics",
json={"libraryUUID": lib_uuid, "elementUUID": element_uuid},
)
r.raise_for_status()
new_g = r.json()
print("Placed:", new_g["type"], "uuid:", new_g["graphicUUID"], "bounds:", new_g["hiddenBounds"])
POST /v1/drawings/{D}/layers/{L}/shapes — create a parametric shapeCreates one shape from a shape discriminator + geometry, on layer {L}, at the front of the layer's z-order. Geometry only — fill, stroke, and shadow are composed afterward (apply a library swatch via POST .../graphics/{G}/attributes), and position is adjusted with align / distribute / morph. This is the parametric-primitive complement to POST .../graphics (which places a *library* element).
Body — shape plus geometry; most shapes take a bounding box, line takes endpoints. Optional name sets nameGraphic.
shape | Geometry | Params |
|---|---|---|
rectangle | bounds | — |
rounded-rectangle | bounds | cornerRadius |
oval | bounds | — |
arc | bounds | startAngle, endAngle (degrees, 0 = right / 3 o'clock), arcType (arc / pie), clockwise (default true) — circular arc inscribed in the box |
polygon | bounds | sides (≥3), orientation (degrees) — regular, inscribed in the box |
text-box | bounds | string |
line | start, end | — |
polyline | points | — (connected straight segments) |
path | nodes | closed — an editable cubic Bézier path (see Curve Tools) |
diagram-box | bounds | figure, direction |
Style at creation and sizeMode (2026-09-10). For the box shapes (rectangle, rounded-rectangle, oval, arc, polygon, diagram-box) the engine's constructors treat bounds as the painted extent: the path is inset by half the line width so the outline fits inside the box (DKDRectangle defaultGraphicWithZone:bounds: and kin — with the default 1-pt stroke a 144 box yields a 143-pt path, the discrepancy the first Claude Desktop session hit). Two optional fields make that predictable:
style — the same { fill, stroke } block PATCH .../style takes, applied at creation. The inset is computed from this style: stroke.color: "none" → no inset (a 144 box is a 144 path); stroke.width: 4 → a 2-pt inset. The response then carries the same style echo block the style PATCH returns.sizeMode — "painted" (default, above) or "path": the box is the path geometry, whatever the stroke; the painted shape then extends half a stroke beyond it.Implementation: the requested style is built with the same _dkd_styleFromBody: used by the style PATCH, the constructor receives the box pre-adjusted by (requested inset − default inset), and the style is set on the new graphic before insert. 400 for a sizeMode other than painted / path. line, polyline, path, text-box accept style but are not inset.
{ "shape": "oval", "bounds": { "x": 234, "y": 324, "width": 144, "height": 144 },
"style": { "fill": { "color": "#FF0000" }, "stroke": { "color": "none" } },
"name": "red-circle" } // hiddenBounds comes back 144 x 144
bounds is { "x", "y", "width", "height" } (width/height > 0); start / end are { "x", "y" }; points is an array of at least two { "x", "y" }. diagram-box.figure ∈ triangle, box-arrow, fat-arrow, flame, mushroom, nose, trapezoid, brace, drum, tear-off; direction ∈ up, down, left, right (resolved to the concrete DKD{Direction}{Figure} class). Per-figure knobs (tip fraction, lean, brace radius, …) use class defaults in v1.
{ "shape": "rounded-rectangle",
"bounds": { "x": 100, "y": 120, "width": 180, "height": 90 },
"cornerRadius": 16, "name": "title-card" }
{ "shape": "polygon", "bounds": { "x": 0, "y": 0, "width": 120, "height": 120 }, "sides": 6 } // hexagon
{ "shape": "arc", "bounds": { "x": 0, "y": 0, "width": 120, "height": 120 },
"startAngle": 0, "endAngle": 90, "arcType": "pie" } // quarter wedge
{ "shape": "diagram-box", "figure": "fat-arrow", "direction": "right",
"bounds": { "x": 40, "y": 40, "width": 160, "height": 60 } }
{ "shape": "line", "start": { "x": 0, "y": 0 }, "end": { "x": 240, "y": 120 } }
{ "shape": "polyline", "points": [ {"x":0,"y":0}, {"x":50,"y":40}, {"x":120,"y":10} ] }
Authored for an AI client that places *points it has reasoned about* and reads geometry back exactly, not one that drags handles. The contract: write a path, read it, write the read back, and you get the identical path (round-trip fidelity).
A path is { "closed": bool, "nodes": [ Node, … ] }. Segment *i* runs nodes[i] → nodes[i+1]; closed wraps nodes[n-1] → nodes[0].
Node = {
"point": [x, y], // on-curve anchor (absolute document points)
"in": [x, y], // control handle governing the segment ARRIVING at this node
"out": [x, y], // control handle governing the segment LEAVING this node
"continuity": "corner" | "smooth" | "symmetric"
}
point means that side is straight (a degenerate cubic).continuity is derived from the handle geometry on read (EazyDraw stores none): smooth = in/out colinear through the anchor, same direction; symmetric = also equal length; corner = a cusp. On add_path the handles are authoritative; continuity in the input is advisory.add_shape (and so the add_path / add_conduit MCP tools) also accepts a bare [x, y] pair or an { "x", "y" } dict per node and converts it to { "point": [x, y] } — a corner node with no handles — before sending. The HTTP body always carries the node model above.nodes[0].in and nodes[n-1].out are the dangling end handles — they are not part of the path geometry, so they read back coincident with the anchor (degenerate). Closed paths round-trip 100%.POST /v1/drawings/{D}/layers/{L}/shapes with shape: "path" — create a path (add_path)Body — { "shape": "path", "nodes": [ Node, … ], "closed": false, "name"?: … }. in/out/continuity are optional per node (default = straight handles at the anchor, corner). At least 2 nodes. Creates a generalized editable Bézier (DKDBezier, type: "bezier"), open or closed — *not* the straight‑line DKDPath/DKDPolygon forms. Lands at the front of the layer's z-order; honors name/uniquify. Returns the new <graphic> (201). add_path accepts exactly the { closed, nodes } that get_path returns.
{ "shape": "path", "closed": true, "nodes": [
{ "point": [100, 100], "out": [140, 100], "in": [60, 100] },
{ "point": [200, 160], "out": [200, 200], "in": [200, 120] },
{ "point": [100, 220], "out": [60, 220], "in": [140, 220] } ] }
POST /v1/drawings/{D}/layers/{L}/shapes with shape: "fit" — fit a curve through waypoints (fit_curve)Body — { "shape": "fit", "points": [ Waypoint, … ], "closed": false, "alpha"?: 0.5, "tension"?: 0.0, "name"?: … }. The author supplies only the waypoints the curve must pass through (≥ 2) — *intent, not handles*. Each Waypoint is either a bare [x, y] (a smooth point) or a dict for per-node control:
{ "point": [x, y], "corner": true } // a CUSP at this point
{ "point": [x, y], "tension": 0.0, "continuity": 0.0, "bias": 0.0 } // full Kochanek–Bartels
EazyDraw interpolates a cubic Bézier through the points: a non-uniform (centripetal) Catmull–Rom base with per-node Kochanek–Bartels controls on top (Catmull–Rom is exactly KB at t=c=b=0). closed: true wraps modularly; an open path uses one-sided end tangents and collapses the two non-drawing outer handles. Honors name/uniquify. Returns the new <graphic> (201) — get_path it to see the synthesized nodes and refine.
Output class follows the corners (this is the Catmull–Rom ↔ KB / DKDContinuousBezier ↔ DKDBezier pairing, made automatic):
DKDContinuousBezier (type: "continuous-bezier") — editing in EazyDraw keeps it smooth.DKDBezier (type: "bezier") — the corners survive editing.Global controls (clamped, optional):
alpha — *parameterization*, 0..1, default 0.5. 0 = uniform (can overshoot/bulge between widely-spaced points), 0.5 = centripetal (overshoot-free — the right default), 1 = chordal. Non-uniform (Barry–Goldman): each chord weighted by length^alpha.tension — 0..1, default 0.0. Baseline handle tightness, scales handles by 1 − tension (0 roundest, 1 ≈ straight). Overridable per node.Per-node controls (inside a waypoint dict):
corner — true ≡ continuity: -1: the tangent splits so the curve arrives along the incoming chord and leaves along the outgoing one — a sharp cusp. This is the headline knob; a heart is two smooth lobes with corners only at the top dip and bottom point.continuity — -1..1. 0 = smooth; -1 = full corner; in between softens the cusp. (Negative values are what flip the output class to bezier.)tension / bias — 0..1 / -1..1, per-node overrides; bias leans the in/out handle lengths.// A heart: two smooth lobes, cusps at the dip and the bottom tip.
{ "shape": "fit", "closed": true, "points": [
{ "point": [200, 120], "corner": true }, // bottom tip
[120, 180], [110, 250], // left shoulder, left lobe peak (smooth)
{ "point": [200, 210], "corner": true }, // top dip
[290, 250], [280, 180] ] } // right lobe peak, right shoulder
Use fit to author a shape by the points it threads (smooth, or with marked corners); use path when you already have explicit handles (e.g. writing back an edited get_path result, or straight segments).
GET /v1/drawings/{D}/layers/{L}/graphics/{G_chain}/path — read a path (get_path)Returns { "path": <spec>, "editable": bool } — path is "none" or { closed, nodes:[{point, in, out, continuity}] }. editable is true for a freeform path (DKDBezier, DKDPath, DKDPolygon, DKDContinuousBezier) — false for a parametric shape (rectangle, oval, regular polygon, …) and a graphic with no path (text, image, group; the latter path: "none"). Reading a primitive's exact Bézier equivalent and converting it in place (to_path) come with the rest of the curve‑tool foundation.
Success — 201 Created. Body is the standard <graphic> dict (type, typeName, graphicUUID, nameGraphic, hiddenBounds, locks). The graphic is built via +[<class> defaultGraphicWithZone:bounds:] (line via +[DKDLine defaultLineWithZone:start:end:]), carries the default attribute transfer, and is inserted undoably ("Add Shape").
Naming. The body accepts an optional name (and uniquify), with the same uniqueness rule as PATCH …/name — 409 if the name is taken, unless uniquify: true. Omit name and, in a UUID drawing, the graphic is auto-named (see Graphic naming model). The response's nameGraphic is always the final name.
Limitations. arcType: "chord" (the chord-closed arc) returns 422, and morph rotation is always about the graphic's geometric center (no custom pivot). (A broader angle-conventions pass across the other angle-bearing graphics — rotated rectangle, math-function graphics, cut circles, … — is separate.)
Errors: 400 — unknown shape / figure / direction, missing or non-positive bounds, sides < 3, line without start / end, or polyline with fewer than two points; 404 — drawing or layer not found; 422 — arcType: "chord"; 423 — the layer is locked (pass ?force=true to override).
A conduit is a band of fixed thickness that follows a centerline path — its left and right edges stay parallel to and thickness/2 from the path. It's the general "thick path / wall / pipe / road" primitive. Backed by EazyDraw's wall‑graphic family (root DKDWallPath); the family's several engine classes are deliberately hidden behind the single conduit concept, and the type token always reads back conduit.
Tier‑1 is predictable and explicit: it exposes the two appearance levers and nothing that makes EazyDraw decide for you. Excluded (by design): the dimension snap‑point setting (only matters for dimension connectors, which the API doesn't have yet), automatic end‑mating / snapping to other conduits, automatic multi‑conduit intersection geometry, and the floor‑plan window/door fittings.
POST /v1/drawings/{D}/layers/{L}/shapes with shape: "conduit" (add_conduit)Body — { "shape": "conduit", "nodes": [ Node, … ], "closed": false, "thickness": 12.0, "join"?: "miter", "startCap"?: "butt", "endCap"?: "butt", "miterLimit"?: …, "name"?: … }. nodes is the centerline, the same {point, in, out} node list as add_path (≥ 2 nodes; straight or curved). thickness (required, > 0) is the band width.
join — interior‑corner appearance (WallJoinStyle): bevel | round | miter (default miter); miterLimit caps long miter spikes.startCap / endCap — how each end of the band looks (WallCornerShape): open | butt | relief | extend | miter | round (default butt, squared off). One value per end; it applies to both sides of that end.Success — 201 Created → the standard <graphic> dict (type: "conduit").
{ "shape": "conduit", "thickness": 16, "join": "round", "startCap": "round", "endCap": "butt",
"nodes": [ {"point":[60,200]}, {"point":[180,120]}, {"point":[300,220]} ] }
GET / PATCH /v1/drawings/{D}/layers/{L}/graphics/{G_chain}/conduit (get_conduit / set_conduit)GET returns { "conduit": <spec>, "conduitable": bool } — <spec> is "none" (not a conduit) or { thickness, join, startCap, endCap, miterLimit }. PATCH body { "conduit": { thickness?, join?, startCap?, endCap?, miterLimit? } } changes only the given fields; undoable, layerLock‑gated (423 + ?force=true), 422 if the graphic isn't a conduit. MCP: add_conduit(nodes, thickness, …), get_conduit(name), set_conduit(name, thickness?, join?, start_cap?, end_cap?, miter_limit?).
POST /v1/drawings/{D}/layers/{L}/graphics/{G_chain}/offset — parallel‑offset a path (offset_path)Creates a new editable path that runs parallel to an existing one — the "Offset Path" operation (cf. Illustrator). The source graphic is unchanged; a fresh DKDBezier is generated distance document points to one side, following the source's twists at constant separation. Backed by the same conduit/wall engine: internally a scratch band of width 2 × distance with squared (butt) ends is built and its left or right edge is taken as the result. The offset of a tightly curved path is an approximation — a true 2‑D parallel can self‑intersect or crinkle where the radius is smaller than distance.
Body — { "side": "left" | "right" | "both", "distance": N, "name"?: …, "uniquify"?: false }. side is relative to the path's travel direction (the order its nodes were drawn); both produces two results (one each way). distance must be positive. The source must have a path (a line, bezier, shape outline, or a conduit centerline) — 422 otherwise. With name and side: "both", the side is appended to each result (name-left, name-right).
Success — 201 Created → { "graphics": [ <graphic>, … ] } — one new bezier per side, inserted at the front of the source's layer. Undoable as a single step ("Offset Path"); layerLock‑gated (423 + ?force=true). MCP: offset_path(name, distance, side="right", new_name?, uniquify?).
{ "side": "both", "distance": 10 } // → two paths, ±10 pt, parallel to the source
GET /v1/drawings/{D}/layers/{L}/graphics/{G_chain} — fetch a single graphicReturns the standard <graphic> dict for a single graphic identified by UUID chain. Same chain grammar as the morph, export, and DELETE endpoints — one UUID for a top-level layer graphic, additional UUIDs to descend into groups.
Success — 200 OK. Body is the standard <graphic> dict (same shape as one entry in GET /v1/drawings/{D}/layers/{L}/graphics). The index field reflects the graphic's position in its immediate container (layer or parent group), not a global drawing-wide index.
Errors: 400 (missing UUIDs), 401 (auth), 404 (any UUID along the chain does not resolve, or an intermediate non-group blocks descent), 500 (response build failure).
Use case: when you have a UUID from a prior call (POST use, PATCH morph, or a listing) and you want to refetch the current state without re-listing the layer. Cheaper than layer_graphics() for spot-checks; especially useful after operations whose response represents pre-state (DELETE) or in-flight state.
g = ed.graphic(d_uuid, l_uuid, g_uuid)
print(g["type"], g["hiddenBounds"])
DELETE /v1/drawings/{D}/layers/{L}/graphics/{G_chain} — remove a graphicRemoves the graphic identified by UUID chain. The remove is registered with the document's undo manager, so Edit → Undo in the EazyDraw UI restores the graphic at the same z-order index in its original container.
Success — 200 OK. Body is the standard <graphic> dict for the pre-delete state. The graphicUUID will no longer resolve in subsequent calls (until an undo restores it).
Container handling:
chain length == 1): removed via [doc removeGraphic:atIndex:] which handles connection cleanup, contained text pairs, layer panel sync, and undo registration.chain length > 1): removed from its immediate parent group's groupArray via [parentGroup groupRemoveGraphicAtIndex:]. Same undo registration pattern.In both cases the index field in the response dict reflects the position in the immediate container at the time of delete.
Errors: 400 (missing UUIDs), 401 (auth), 404 (any UUID does not resolve, or intermediate non-group blocks descent), 500 (response build failure).
Threading. Resolution, dict build, and removal all run inside one dispatch_sync(dispatch_get_main_queue(), ...) block — atomic against other API requests.
curl -i -X DELETE -H "Authorization: Bearer $TOKEN" \
http://localhost:52737/v1/drawings/$D/layers/$L/graphics/$G
deleted = ed.delete_graphic(d_uuid, l_uuid, g_uuid)
print("Removed", deleted["type"], "at z-order", deleted["index"])
POST /v1/drawings/{D}/layers/{L}/graphics/{G_chain}/duplicate — duplicate a graphicDuplicates a graphic — the copy primitive (the companion to create / name / delete). A deep copy (the engine's dupGraphics: with fresh paste IDs), inserted into the same layer just above the source, then offset. Each copy is introduced through the normal funnel, so in a UUID drawing it gets a fresh UUID and a uniquified name (a copy of "box" becomes "box 2").
Body (all optional):
{ "offset": { "x": 20, "y": 20 }, // internal points, Y-down; default { 20, 20 }
"count": 1 } // number of copies, 1..100; default 1
With count > 1, copy *k* is offset by k × the offset vector (a cascade).
Success — 201 Created → { "graphics": [ <graphic>, … ] }, one entry per copy (each with its new UUID and name).
v1 scope: top-level layer graphics only — duplicating a nested (grouped) graphic returns 422 (chain length > 1). Undoable ("Duplicate"), layerLock-gated (423; ?force=true overrides). 404 — drawing / layer / graphic not found.
new = ed.duplicate_graphic(d_uuid, l_uuid, g_uuid, offset={"x": 30, "y": 0}, count=3)
for g in new["graphics"]:
print("copy:", g["nameGraphic"], g["graphicUUID"])
POST /v1/drawings/{D}/layers/{L}/graphics/{G}/attributes — apply a transferApplies a library attribute-action element's DKDTransfer (fill color, line color/style, gradient, hatch, dashes, arrows, brush, shadow, dimension scopes) to a target graphic — the programmatic equivalent of dragging that library swatch onto the graphic, or selecting the graphic and clicking the element's Use button.
Request body:
{
"libraryUUID": "<DKDLib.libraryUUID>",
"elementUUID": "<DKDLibElement.libraryUUID>"
}
Both required, non-empty strings. The element must classify as attribute-action — a DKDLibGraphicElement whose embedded graphic is not a DKDGroup, carries a non-nil dkdTransfer, and has at least one active scope flag (brushScopeTransfer, shadowScopeTransfer, gradientScopeTransfer, hatchScopeTransfer, dashesScopeTransfer, patternScopeTransfer, arrowsScopeTransfer, colorAndStyleScopeTransfer, dimensionScopeTransfer). A graphic / create-tool / arrange-tool element returns 422 (use POST .../graphics to place those).
v1 — top-level graphics only. The target is a single top-level layer graphic; a nested (grouped) UUID chain returns 422. The transfer is applied by the same mechanism as a drag-drop (DKDTransferApply dropTransfer): save the current selection, select the target graphic, run the per-scope inspector-panel applies against it, then restore the prior selection. On-canvas selection must be able to address the target, which today means a top-level graphic. (A brief selection change may be visible to a user watching the document.)
Success — 200 OK. Body is the standard <graphic> dict; the graphicUUID is preserved (the graphic is restyled in place, not replaced). The summary dict does not include attribute values — to see the resulting fill/line/etc. inline, GET .../graphics/{G}/export/native or the layer /state.
Undo. Registered as a single "Transfer" step by applyTransferFromGraphic: (the action name reflects the scopes applied), so Edit → Undo reverts the change.
Locks. Only the layer's layerLock blocks the apply — 423 Locked, with ?force=true to override. The graphic's moveLock / sizeLock / deleteLock do not apply: an attribute change is neither a move, a resize, nor a delete.
Errors:
400 Bad Request — missing path UUIDs; empty/malformed body; missing/empty libraryUUID / elementUUID.401 Unauthorized — bearer token missing or wrong.404 Not Found — drawing, layer, top-level graphic, library, or element UUID does not resolve.422 Unprocessable Entity — element is not an attribute-action (it's a graphic / create-tool / arrange-tool); or the target is a nested graphic (top-level graphics only).423 Locked — the target layer is locked; retry with ?force=true.500 Internal Server Error — response build failed.Threading. Resolution, the select → applyTransferFromGraphic: → restore-selection sequence, and the dict build all run inside one dispatch_sync(dispatch_get_main_queue(), …) block — atomic against other API requests.
Python:
g = ed.apply_attributes(d_uuid, l_uuid, g_uuid, lib_uuid, el_uuid)
print(g["type"], g["graphicUUID"])
# to confirm the applied attributes, read the flat state:
native = ed.export_graphic(d_uuid, l_uuid, g_uuid, fmt="native")
PATCH /v1/drawings/{D}/layers/{L}/graphics/{G_chain}/style — solid fill & strokeSets a graphic's solid color fill and stroke — DKDGraphicStyle, the 90% case. The compose-after-creation styling step: splash a shape, then color it.
Terminology. The API uses fill and stroke (SVG/CSS-aligned, the vocabulary agents already speak). EazyDraw's *UI* calls the stroke the "outline"; the API standardizes on stroke.
Body — both blocks and all keys optional; only what you pass changes. color is a hex string (#RRGGBB / #RRGGBBAA); "none" (or null) turns that fill/stroke off.
{
"fill": { "color": "#cc2200" | "none", "rule": "nonzero" | "evenodd" },
"stroke": { "color": "#000000" | "none", "width": 2.0,
"cap": "butt" | "round" | "square", "join": "miter" | "round" | "bevel" }
}
rule is the SVG fill-rule (NSWindingRule); cap/join map to NSLineCapStyle/NSLineJoinStyle. Works on nested (grouped) graphics too.
Success — 200 OK. Returns the standard <graphic> dict plus a style block echoing the result:
{ "type": "rectangle", "graphicUUID": "…", "hiddenBounds": {…}, "locks": {…},
"style": { "fill": { "color": "#cc2200", "rule": "nonzero" },
"stroke": { "color": "#000000", "width": 2.0, "cap": "butt", "join": "miter" } } }
Model reuse. DKDGraphicStyle instances are shared and immutable — a style change builds one new immutable style (never mutates the shared instance in place), and the undo registration holds the style references (not copies), so the wide reuse of the attribute model survives undo/redo. (A future batch op styling N graphics will share a single instance across all of them.)
Richer looks — dashes now have a native endpoint (PATCH .../dash, below). Gradient / pattern / hatch fills and arrow / crossover / brush strokes remain Tier 2 for now: apply a saved library swatch with POST .../graphics/{G}/attributes.
Undoable ("Color and Style"), layerLock-gated (423; ?force=true overrides). 400 — body without fill or stroke; 404 — drawing / layer / graphic not found.
GET /v1/drawings/{D}/layers/{L}/graphics/{G_chain}/style — read fill & strokeReturns the graphic's current { "fill": {color, rule}, "stroke": {color, width, cap, join} } (the same style block shape). color is a hex string, or "none" when that fill/stroke is off.
GET /v1/dash-patterns — builtin named dash catalogLists EazyDraw's builtin named dash patterns (the factory dashes: Long Dash, Dash Dot, Dash Dot Tight, Long Dash Tight, Sparse Dot, Dot Dash, Dotted — names describe what is *inked*, never the gap). Global — not drawing-scoped. Use a name as the dash spec on PATCH .../dash. The pre-2026-06 names (Long, Dash Space, Short Space, Long Space, Space Space, Short) are still accepted as aliases on PATCH .../dash.
{ "dashPatterns": [ { "name": "Long Dash", "pattern": [16, 16] },
{ "name": "Dash Dot", "pattern": [16, 4, 4, 16] }, … ] }
pattern is the dash/gap length sequence in points (the PDF/Adobe dash array).
PATCH /v1/drawings/{D}/layers/{L}/graphics/{G_chain}/dash — set the line dashSets a graphic's stroke dash (DKDDashPattern, stored on the graphic's bezier). The dash is its own attribute, parallel to /style's solid stroke (and to the gradient / arrow attributes).
Body — { "dash": <spec> }, where <spec> is one of:
"none" — solid line (dash off)"Dash Dot" — a builtin name (or { "name": "Dash Dot" }); see GET /v1/dash-patterns{ "pattern": [10, 5, 2, 5], "phase": 0 } — any dash, the PDF/Adobe model: pattern is an array of ≥2 non-negative on/off lengths in points (at least one positive); phase (optional, default 0) is the absolute length offset where dashing begins.{ "dash": { "pattern": [10, 5], "phase": 0 } }
Success — 200 OK → { "dash": <resolved spec>, "dashable": true }. The resolved spec is { "pattern": […], "phase": <absolute>, "name": <builtin name if it matches one> }, or "none".
Internally phaseDash is a fraction of the total dash length; the API converts to/from the absolute PDF phase so the client stays in the standard model.
Undoable ("Dashes"), layerLock-gated (423; ?force=true overrides). 422 — the graphic type does not accept dashes (e.g. text, image, group), or the pattern / name is invalid. 400 — missing dash. 404 — drawing / layer / graphic not found.
GET /v1/drawings/{D}/layers/{L}/graphics/{G_chain}/dash — read the line dashReturns { "dash": <spec>, "dashable": bool } — dash is "none" or { pattern, phase, name? }; dashable is false for graphic types that cannot carry a dash.
PATCH /v1/drawings/{D}/layers/{L}/graphics/{G_chain}/shadow — set the drop shadowSets a graphic's drop shadow (DKDShadow on graphicShadow). The API exposes bitmap shadows (the soft, system-rendered NSShadow) on the graphic/path — not the associated annotation text, and not the EazyDraw vector shadow. This is the 90% case.
Body — { "shadow": <spec> }, where <spec> is one of:
"none" — shadow off{ "color": "#000000cc", "drop": 5, "angle": 315, "blur": 4 } — a bitmap shadow, turned on. All keys optional; the shadow starts from the graphic's current (or default) shadow and overrides only what you pass.color — hex; alpha welcome (#RRGGBBAA) — shadows are usually translucentdrop — offset distance, pointsangle — degrees (the Shadow panel's angle dial; e.g. a lower-right drop)blur — blur radius, points (≥ 0){ "shadow": { "color": "#00000080", "drop": 6, "angle": 315, "blur": 5 } }
Success — 200 OK → { "shadow": <resolved>, "shadowable": true }. The resolved spec is { color, drop, angle, blur, method } (or "none"). method is read-only ("bitmap" | "vector") — a vector shadow set in the UI stays legible, but PATCH always writes bitmap.
Undoable ("Shadow"), layerLock-gated (423; ?force=true overrides). 422 — the graphic type does not accept a shadow, or the color/blur is invalid. 400 — missing shadow. 404 — drawing / layer / graphic not found.
GET /v1/drawings/{D}/layers/{L}/graphics/{G_chain}/shadow — read the drop shadowReturns { "shadow": <spec>, "shadowable": bool } — shadow is "none" or { color, drop, angle, blur, method }; shadowable is false for graphic types that cannot carry a shadow.
PATCH /v1/drawings/{D}/layers/{L}/graphics/{G_chain}/gradient — set the gradient fillSets a graphic's gradient fill (DKDGradientFill on gradientFill). The API exposes the bitmap gradient — the system NSGradient — in linear or radial mode; the vector EazyDraw gradient types are not written here.
Body — { "gradient": <spec> }, where <spec> is one of:
"none" — no gradient fill (reverts to the solid fill){ "type": "linear"|"radial", "stops": [ {"color":"#RRGGBB","location":0.0}, … ], "angle": 270, "radius": 0 }type — "linear" (uses angle) or "radial"stops — the gradient color stops, ≥2 of { color: hex (alpha ok), location: 0..1 }. Linear: location 0 is one edge, location 1 the other (in the angle direction). Radial: location 0 is the center, location 1 the outer edge. Intermediate stops sit at their location.angle — degrees, standard-math / screen convention (counterclockwise from →). Linear: the direction colors run, low→high location (270 = top→bottom, 90 = bottom→top, 0 = left→right). Radial: the off-center direction (used with radius).radius — percent, radial only: how far the gradient center is offset from the shape center (0 = centered).{ "gradient": { "type": "linear",
"stops": [ { "color": "#ff5500", "location": 0 },
{ "color": "#ffd000", "location": 0.5 },
{ "color": "#2200ff", "location": 1 } ],
"angle": 270 } }
Success — 200 OK → { "gradient": <resolved>, "gradientable": true }. The resolved spec is { type, stops, angle, radius? } (or "none") and round-trips with what you sent. A graphic carrying a *vector* gradient set in the UI reports type: "vector" (read-only) with its stops; PATCH always writes a bitmap linear/radial gradient.
If the graphic also has a pattern fill whose overlay option is off, the response adds "note" — the gradient is stored but the pattern draws over it (enable the pattern overlay in EazyDraw to reveal it). The endpoint does not silently change the pattern.
Undoable ("Gradient"), layerLock-gated (423; ?force=true overrides). 422 — the graphic type does not accept a gradient, or the type/stops are invalid. 400 — missing gradient. 404 — not found.
GET /v1/drawings/{D}/layers/{L}/graphics/{G_chain}/gradient — read the gradient fillReturns { "gradient": <spec>, "gradientable": bool } — gradient is "none" or { type, stops, angle, radius? } (type "linear" | "radial" | "vector"); gradientable is false for graphic types that cannot carry a gradient.
GET /v1/pattern-sets — builtin pattern / texture catalogReturns { "sets": [ { "name": "Brick", "patterns": ["Ancient","Bricks","HerringBone","Old","Red","Yellow"] }, … ] } — EazyDraw's builtin pattern and texture fills, grouped into named sets. A pattern is a small bitmap tile (typically ≤ 64×64 px) painted by tiling; a *texture* is just a larger tile (sets like Texture, Favorite Textures). Global (not drawing-scoped). Apply one with a { set, name } on PATCH .../pattern. (The DXF hatch sets are intentionally not listed here — hatch is a separate attribute.)
PATCH /v1/drawings/{D}/layers/{L}/graphics/{G_chain}/pattern — set the pattern fillSets a graphic's pattern fill (DKDPattern on graphicPattern) to a builtin tile, named by set + pattern.
Body — { "pattern": <spec> }, where <spec> is one of:
"none" — no pattern fill (reverts to the solid / gradient fill){ "set": "Brick", "name": "Red", "overlay": false }set + name — a set and pattern from GET /v1/pattern-sets (both case-insensitive)overlay (optional, default false) — the pattern's overlay mode. Default false is the usual opaque pattern fill (the tile covers any solid/gradient fill). true turns on overlay so an underlying gradient shows through (the same option referenced by the gradient endpoint's pattern note){ "pattern": { "set": "Wood", "name": "Oak" } }
Success — 200 OK → { "pattern": <resolved>, "patternable": true }. The resolved spec is { set, name, overlay } (or "none"); name is returned in its canonical capitalization.
Undoable ("Pattern"), layerLock-gated (423; ?force=true overrides). 422 — the graphic type does not accept a pattern, or the set/name is unknown. 400 — missing pattern. 404 — not found.
GET /v1/drawings/{D}/layers/{L}/graphics/{G_chain}/pattern — read the pattern fillReturns { "pattern": <spec>, "patternable": bool } — pattern is "none" or { set, name, overlay }; patternable is false for graphic types that cannot carry a pattern.
GET /v1/hatch-patterns — builtin vector-hatch catalogReturns { "hatchPatterns": [ "ANSI31", "Concrete", … ] } — EazyDraw's builtin vector hatches: line fills drawn as real bezier lines (distinct from the bitmap tiles of /v1/pattern-sets). Flat named list (the source is the bundle drawing NamedHatches.ezddata). Global (not drawing-scoped). Apply one with a { name } on PATCH .../hatch.
PATCH /v1/drawings/{D}/layers/{L}/graphics/{G_chain}/hatch — set the vector hatch fillSets a graphic's hatch fill (DKDHatch on graphicHatch) to a builtin named hatch, with optional parametric overrides.
Body — { "hatch": <spec> }, where <spec> is one of:
"none" — no hatch fill{ "name": "ANSI31", "angle": 45, "scale": 1.0, "double": false }name — a hatch from GET /v1/hatch-patterns (case-insensitive)angle (optional, degrees, counterclockwise) — rotates the hatch lines, relative to the hatch's natural orientation (0 = as designed)scale (optional) — density multiplier (1.0 = the hatch's default; larger = coarser)double (optional, default false) — cross-hatch (a second set of lines across the first){ "hatch": { "name": "ANSI31", "angle": 45, "double": true } }
Success — 200 OK → { "hatch": <resolved>, "hatchable": true }. The resolved spec is { name, angle, scale, double } (or "none"); name echoes back in canonical case.
Undoable ("Hatch"), layerLock-gated (423; ?force=true overrides). 422 — the graphic type does not accept a hatch, or the name is unknown. 400 — missing hatch. 404 — not found.
GET /v1/drawings/{D}/layers/{L}/graphics/{G_chain}/hatch — read the vector hatch fillReturns { "hatch": <spec>, "hatchable": bool } — hatch is "none" or { name, angle, scale, double }; hatchable is false for graphic types that cannot carry a hatch.
GET /v1/arrow-forms — builtin arrow-head catalogReturns { "arrowForms": [ "Solid", "Open", "Curved", "Bar", "Dot", "Diamond", … ] } — EazyDraw's builtin arrow-head forms (the basic, model-level set). Global (not drawing-scoped). Apply one with a { form } on PATCH .../arrow.
PATCH /v1/drawings/{D}/layers/{L}/graphics/{G_chain}/arrow — set the line-end arrowPuts an arrow head on a graphic's path ends (DKDArrow on the DKDBezier). Open paths only (a line, multi-segment line, arc, or open bezier). One form applies to whichever ends you choose — start, end, or both.
Body — { "arrow": <spec> }, where <spec> is one of:
"none" — no arrow (both ends off){ "form": "Solid", "ends": "end", "size": 12, "angle": 160, "reference": "relief", "shift": 0 }form — an arrow form from GET /v1/arrow-forms (required, case-insensitive)ends — "start" | "end" | "both" (default "end"); which path end(s) carry the headsize (optional) — the head sizeangle (optional, degrees) — the head's spread angle (default 160°)reference — "relief" | "offset" (default "relief"); how the tip sits vs. the line end — relief keeps a thick line from poking through the headshift (optional) — the relief/offset distance along the path end{ "arrow": { "form": "Solid", "ends": "both", "size": 14 } }
Success — 200 OK → { "arrow": <resolved>, "arrowable": true }. The resolved spec is { form, ends, size, angle, reference, shift } (or "none"); form echoes back in canonical case.
Undoable ("Arrow"), layerLock-gated (423; ?force=true overrides). 422 — the graphic type does not accept an arrow (only open paths do), or the form / ends is invalid. 400 — missing arrow. 404 — not found.
GET /v1/drawings/{D}/layers/{L}/graphics/{G_chain}/arrow — read the line-end arrowReturns { "arrow": <spec>, "arrowable": bool } — arrow is "none" or { form, ends, size, angle, reference, shift }; arrowable is false for graphic types that cannot carry an arrow.
GET /v1/crossover-styles — builtin crossover catalogReturns the schematic crossover symbols (the mark where one line crosses another), each tagged with the meaning that matters most — does the crossing connect or not:
{ "crossoverStyles": [
{ "name": "Junction", "group": "connection", "family": "junction", "aliases": ["Dot"] },
{ "name": "Terminal", "group": "connection", "family": "terminal", "aliases": ["Block"] },
{ "name": "Header", "group": "connection", "family": "header", "aliases": ["Square"] },
{ "name": "Hop (Round)", "group": "no-connection", "family": "hop", "aliases": ["Bridge","Jumper"] },
{ "name": "Hop (Square)","group": "no-connection", "family": "hop", "aliases": ["Hop"] },
{ "name": "Gap (Plain)", "group": "no-connection", "family": "gap", "aliases": ["Open"] },
{ "name": "Gap (Dashed)","group": "no-connection", "family": "gap", "aliases": ["Dash"] },
{ "name": "Gap (Barred)","group": "no-connection", "family": "gap", "aliases": ["Bar"] },
{ "name": "Gap (Arrow)", "group": "no-connection", "family": "gap", "aliases": ["Break"] },
{ "name": "Jog (Z)", "group": "no-connection", "family": "jog", "aliases": ["Z Break"] },
{ "name": "Jog (S)", "group": "no-connection", "family": "jog", "aliases": ["S Break"] },
{ "name": "Jog (N)", "group": "no-connection", "family": "jog", "aliases": ["N Break"] },
{ "name": "Loop (Open)", "group": "no-connection", "family": "marker", "aliases": ["Eye"] }
],
"positions": ["Percent","Across","Down","Automatic"],
"directions": ["Path Right","Path Left","Path Alternate"] }
group is the key distinction: connection symbols mean the wires join (a filled Junction is the universal schematic dot) — no-connection symbols mean they cross *without* joining (the hops, gaps, jogs). Don't substitute one for the other. Global (not drawing-scoped). Apply one with a { style } on PATCH .../crossover; every legacy aliases name is still accepted on input.
PATCH /v1/drawings/{D}/layers/{L}/graphics/{G_chain}/crossover — set the crossover symbolSets a graphic's crossover (DKDCrossOver on the DKDBezier) — the wire-hop/break symbol drawn where this path crosses another. Open paths only.
Body — { "crossover": <spec> }, where <spec> is one of:
"none" — no crossover (active off){ "style": "Hop (Round)", "position": "Automatic", "direction": "Path Right", "along": 0.5, "across": 0, "down": 0, "size": {"width": 8, "height": 8} }style — a crossover style name (or any legacy alias) from GET /v1/crossover-styles (required, case-insensitive). Pick from the matching group — no-connection for a crossing, connection to join.position — Percent | Across | Down | Automatic (default for a new crossover). Automatic auto-detects where this path crosses other paths and places the symbol there — the headline of the tool.direction — Path Right | Path Left | Path Alternate (which way the symbol bends)along — 0..1 down the path (used when position = Percent); across/down are the values for the Across/Down fixed modessize — { "width", "height" } of the symbol{ "crossover": { "style": "Hop (Round)", "position": "Automatic" } }
Success — 200 OK → { "crossover": <resolved>, "crossoverable": true }. The resolved spec is { style, position, direction, along, across, down, size } (or "none"); names echo back in canonical case.
Undoable ("Crossover"), layerLock-gated (423; ?force=true overrides). 422 — the graphic type does not accept a crossover (only open paths do), or the style / position / direction is invalid. 400 — missing crossover. 404 — not found.
GET /v1/drawings/{D}/layers/{L}/graphics/{G_chain}/crossover — read the crossoverReturns { "crossover": <spec>, "crossoverable": bool } — crossover is "none" or { style, position, direction, along, across, down, size }; crossoverable is false for graphic types that cannot carry a crossover.
DKDBrush)A brush gives a path a stroke whose width varies along its length (the Illustrator/Affinity "width profile", or a hand‑drawn artistic outline). Stroke‑side, on DKDBezier (acceptsBrush → only bezier paths). Tier‑1 ships the Artistic engine brush authored two ways; both produce the same kind of brush and differ only in how the defining outline is built.
How it works: a brush is one closed *outline* whose X spans the path's length and whose height at each X is the stroke width there (centered on the path). The profile form generates that outline from a width array; the artistic form takes the outline directly. The drawing engine needs a dense defining outline, so the profile is sampled to density points per side (default 64, clamped 8…512) — the discoverable appearance lever.
PATCH /v1/drawings/{D}/layers/{L}/graphics/{G_chain}/brush — set the brush (set_brush)Body — { "brush": <spec> }:
// remove the brush
{ "brush": "none" }
// PROFILE — width along the path (Illustrator/Affinity model)
{ "brush": {
"type": "profile",
"strokeWidthBase": 12.0, // full stroke width (document points) at widthScale 1.0
"widthProfile": [ // >= 2 points; t 0..1 along the path, widthScale 0..
{ "t": 0.0, "widthScale": 0.0 }, // tapered start (a point)
{ "t": 0.5, "widthScale": 1.0 }, // full width mid
{ "t": 1.0, "widthScale": 0.0 } ], // tapered end
"density": 64, // optional; samples per side, 8..512
"curveLinear": true // optional; interpret the brush along curves
} }
// ARTISTIC — supply the outline directly (full control; the ivy-leaf case)
{ "brush": {
"type": "artistic",
"outline": { "closed": true, "nodes": [ { "point": [x, y], "in": [x, y], "out": [x, y] }, … ] },
"curveLinear": true
} }
widthProfile is interpolated linearly and need not be evenly spaced; values before the first / after the last t hold the endpoint. outline is the same { closed, nodes } shape as add_path/get_path. Tier‑1 uses brushStyle: path (the stroke takes the host path's color/line attributes — no separate brush graphic style); a per‑brush style is a later tier.
Success — 200 OK → { "brush": <spec>, "brushable": true }. The response brush echoes the exact authoring spec (the profile or outline you sent) — the spec is stashed on the brush so get_brush round‑trips it verbatim. layerLock‑gated (423 + ?force=true); 422 if the graphic does not accept a brush.
GET /v1/drawings/{D}/layers/{L}/graphics/{G_chain}/brush — read the brush (get_brush)Returns { "brush": <spec>, "brushable": bool } — brush is "none" or the stashed authoring spec (type + strokeWidthBase/widthProfile/density, or outline) plus curveLinear and style. A brush built in EazyDraw's UI (no API stash) reports a small geometry summary (type: "artistic", pathCount) instead of the exact spec.
MCP. set_brush(name, width_profile=…, stroke_width_base=…) for the profile form, set_brush(name, outline=…) for artistic, on=False to remove; get_brush(name) to read. The example‑brush table in EazyDraw's UI is a mechanical assembly tool, intentionally not exposed to the client.
PATCH /v1/drawings/{D}/layers/{L}/graphics/{G_chain}/name — name a graphicSets a graphic's nameGraphic — a human-meaningful handle ("ceo-photo-frame", "living-room-fireplace") so a client can address it in a complex scene.
Body — { "name": "garden-north-lettuce-plot", "uniquify": false }. name is required and non-empty; uniquify defaults to false.
Uniqueness (server-owned). A name must be unique among the drawing's top-level graphics. If name is already taken, the request fails with 409 Conflict — unless uniquify: true, which appends a suffix to make it unique. Either way the response is the <graphic> dict carrying the final nameGraphic, so the client always learns the actual name it can address.
Renaming a graphic to its own current name is a no-op (not a conflict). Naming is metadata — allowed even on a locked layer (not lock-gated). Undoable ("Name Graphic"). Works on nested (grouped) graphics. 404 — drawing / layer / graphic not found; 400 — empty name.
Source of truth: the drawing's UUID. A drawing "uses UUIDs" iff it has a documentUUID (the Flat/UUID feature) — *not* any app setting or default, which only seed new drawings.
typeNameForMenu ("Rectangle", "Text", …); and any name that collides is uniquified ("Rectangle 2") — so a paste/duplicate (which copies the source's name) becomes "ceo-photo 2", not a second "ceo-photo". The UUID is likewise made unique: a duplicate's copyWithZone: drops it (→ fresh), and a paste deserializes the source's UUID (→ regenerated on collision). File-load is exempt (graphics keep their stored names/UUIDs).documentUUID) → lax: no enforcement, an unnamed graphic stays nameless.So in a UUID drawing every graphic always has a unique name and UUID, and the client can address any of them by name — replacing the bland auto-names with descriptive ones via PATCH …/name.
GET / PATCH /v1/drawings/{D}/layers/{L}/graphics/{G_chain}/lock — read and set locksThe three locks the API enforces (see Lock policy) can be read and set here — the Format ▸ Lock menu for scripts: protect finished work before a bulk edit, or unlock something the user has asked to have changed.
GET …/lock → `{ "locks": { "deleteLock", "moveLock", "sizeLock", "layerLock" },"accepts": { "deleteLock", "moveLock", "sizeLock" } } — which locks are set (the same locks sub-dict every <graphic> carries) and which of them this graphic can take (acceptsDeleteLock / acceptsMoveLock / acceptsSizeLock`).
PATCH …/lock with any subset of `{ "deleteLock": <Bool>, "moveLock": <Bool>,"sizeLock": <Bool> } — only the keys sent change; all three false unlocks. Built the way the menu commands build it: a DKDMutableLock copy of the graphic's DKDLock, flags applied, frozen back to an immutable DKDLock, and installed with swapLock:oldLock:` (self-registering undo). Action name "Lock" (or "Unlock" when the result has no locks set).
Success — 200 OK. PATCH returns the <graphic>, whose locks show the result.
Errors: 400 no lock key, or a value that is not a boolean; 404 graphic not found; 422 the graphic does not accept a requested lock (check accepts); 423 the graphic's layer is locked, unless ?force=true (layerLock itself is set in the Layers table, not here).
Python: ed.get_lock(d, l, g), ed.set_lock(d, l, g, delete=True, move=True). MCP: get_lock(name), set_lock(name, delete?, move?, size?).
POST /v1/drawings/{D}/layers/{L}/graphics/{G_chain}/order — z-orderChanges a graphic's front‑to‑back position within its layer (the layer's graphics array order; 0 = bottom). Reuses -[DKDDocument sendGraphic:toIndex:].
Body: { "to": "front" | "back" | "forward" | "backward" } — front/back to the top/bottom of the layer, forward/backward one step. 200 OK — the <graphic> dict (its index reflects the new z‑position). Undoable; layerLock‑ gated. Top-level graphics only (nested → 422).
POST /v1/drawings/{D}/layers/{L}/graphics/{G_chain}/move-to-layer — move to a layerMoves a graphic from its current layer to another, via -[DKDDocument moveGraphic:toLayer:index:] (undo‑aware). Useful for parking a graphic on a working layer or bringing it onto a visible one.
Body: { "layer": "<destination name or uuid>", "index"?: <Number> } — the destination is matched against each layer's uuid (case‑insensitive) then layerName; index is the z‑position in the destination (default: top). 200 OK — the <graphic> dict; graphicUUID is preserved, so after the move address it under the *destination* layer's uuid. Undoable; layerLock on both source and destination is honored (423 + ?force=true). Top-level graphics only.
Errors: 400 (UUIDs/body/missing layer), 401, 404 (graphic chain or destination layer unresolved), 422 (nested graphic, or already on that layer), 423 (locked), 500.
POST /v1/drawings/{D}/layers/{L}/graphics/{G_chain}/flip — flip a graphicMirrors a graphic in place — the common vector‑editing flip, and the fix for an upside‑down image (something an AI can spot in a render but EazyDraw itself cannot perceive). Reuses the flip menu command's primitive (flipGraphicsWithFlipSpec:), so it handles the same geometry, grid‑reference, and undo behavior as the UI.
Request body: { "axis": "horizontal" | "vertical" | "mirror" } — horizontal mirrors left↔right, vertical top↔bottom, mirror both (180°).
Success — 200 OK. The standard <graphic> dict; the bounding box is unchanged (a flip preserves position and size), and graphicUUID is preserved. One undoable "Flip …" step. Honors layerLock (423 + ?force=true); a flip is neither a move, resize, nor delete, so the per‑graphic move/size/delete locks do not apply.
Errors: 400 (missing UUIDs / body / bad axis), 401, 404 (chain unresolved), 423 (layer locked), 500.
POST /v1/drawings/{D}/layers/{L}/images — insert content (raster or PDF)Inserts content onto the layer at the front of its z-order (above existing content). This is the *insert into an existing drawing* path; to bring a file in as a new drawing sized to its content, use POST /v1/drawings (see below). The destination is the layer in the URL — make that layer active (the agent drives the destination by choosing the active layer). Page geometry is never changed here.
Request body:
{
"imageBase64": "<bytes>", // OR "path": "~/sig.pdf" (a file the EazyDraw process can read)
"quality": "screen | retina | print", // raster only — resample ceiling 72 / 144 / 300; default retina
"width": <Number>, // raster only — display width in document points; default = native pixel size
"x": <Number>, "y": <Number>, // raster only — lower-left origin; default centered on the visible view
"name": "<string>" // optional — makes the graphic an addressable named slot
}
Provide imageBase64 (the contract for remote/MCP callers; same Base64 the API *returns* image data as) or a local path (tilde‑expanded; read server‑side).
Raster (PNG/JPEG/TIFF/HEIC/…) — routed via NSImage initDKDWithData:. One bitmap graphic at its native pixel size (1 px → 1 pt), matching how opening the image sizes a page; pass width (document points) to scale it down. Height follows the source aspect (never distorted). scaleFract = (targetDPI × displayWidthPoints / 72) / sourcePixelsWide, clamped ≤ 1 (never upsamples); resampled to that fraction and tagged at targetDPI. Centered on the visible view unless x/y given.
201 Created — the <graphic> dict plus an image block:{ "pixelsWide", "pixelsHigh", "dpi", "bytes" }. Undoable "Insert Image".
PDF — detected by NSPDFImageRep. Comes in as vector, one DKDPDFImage graphic per page at native page size (no scaling), all pages sharing one data blob. Pages are laid onto the drawing's existing page grid via pointWithPageNumber: (page *i* → drawing page *i*); pages past the current grid extend below the canvas — growing the page count is the user's call (not done here). width/x/y/quality are ignored. With more than one page the name is suffixed -1, -2, … so each page stays individually addressable; every page also gets its own graphicUUID.
201 Created — { "pageCount": <N>, "graphics": [ <graphic>, … ] },each graphic dict carrying an extra "pageNumber" (0-based). Undoable "Insert PDF".
Both honor the layer's layerLock (423 + ?force=true).
Errors: 400 (missing path UUIDs / body / neither imageBase64 nor path / bad Base64), 401, 404 (drawing/layer not found; or path unreadable), 422 (image data could not be decoded), 423 (layer locked), 500.
PUT /v1/drawings/{D}/layers/{L}/graphics/{G_chain}/image — fill a named slotThe image Fill keystone (the analog of set_text). The target is a named placeholder — a plain rectangle a designer drew where the image goes (no need to author a PNG placeholder), or an existing image. The incoming image is fitted into the placeholder's rectangle preserving aspect (contain: min(rectW/imgW, rectH/imgH)), centered on the placeholder's center, then the placeholder is replaced — the new image keeps the placeholder's nameGraphic and graphicUUID, so the slot stays addressable by the same name for the next fill.
Body is the same as the insert endpoint (imageBase64 or path, plus quality screen/retina/print); resampling/DPI policy is identical, sized to the fitted rectangle. Success 200 OK — the new <graphic> dict + image block. One undoable "Fill Image" step. v1: top-level placeholders only (a nested chain returns 422). Honors layerLock (423 + ?force=true).
Errors: 400 (path/body), 401, 404 (graphic chain unresolved / unreadable path), 422 (nested placeholder, undecodable image, or zero-size placeholder), 423 (locked), 500.
PATCH /v1/drawings/{D}/layers/{L}/graphics/{G_chain} — morph a graphicSets the absolute bounds (position + size) and/or rotation of an existing graphic. The path identifies a single graphic, either at the top of a layer or nested in groups (same UUID-chain grammar as the recursive group-children GET and the graphic-export endpoints).
PATCH /v1/drawings/{D}/layers/{L}/graphics/{G} # top-level
PATCH /v1/drawings/{D}/layers/{L}/graphics/{G1}/graphics/{G2} # nested
PATCH /v1/drawings/{D}/layers/{L}/graphics/{G1}/graphics/{G2}/graphics/{G3} # deeper, and so on
Request body: bounds and/or rotation — at least one required:
{
"bounds": { // optional; if present, all four fields required
"x": <Number>,
"y": <Number>,
"width": <Number, > 0>,
"height": <Number, > 0>
},
"rotation": <Number>, // optional; degrees, about the graphic's geometric center
"text": "reflow" | "font" | "stretch" // optional; how a size change reaches text (below)
}
Text boxes resize by reflow (2026-09-10). For a DKDTextArea target a bounds change defaults to "text": "reflow": the box is set (setBoundsTextArea:, the knob-drag behavior, clamped to the box's minSize), the text re-wraps, and the font size is untouched — so "the text doesn't fit, make the box bigger" is one PATCH to the requiredSize, not a delete-and-recreate. "font" scales the point size with the box (FontTextMorphMethod; fontSize in the text dict changes accordingly). "stretch" is the engine's glyph stretch (StretchShrinkTextMorphMethod, the previous default), which leaves fontSize nominal and records the factor in layout.stretch (see the text dict). For non-text graphics text only matters for text nested in a group: "font" scales those labels' point size; otherwise they stretch. Rotation is unaffected by this field. 400 for any other value.
x and y are document-space coordinates; width and height must be positive. rotation is in degrees and spins the graphic about its own geometric center (nativeCenterGraphic), matching the Morph panel and the general expectation for grouped/nested content. Omit bounds to rotate in place; omit rotation for a pure resize/move. A custom pivot point (e.g. via each graphic's DKDGridReference snap point, or an arbitrary point) is a future enhancement.
MCP convenience tools. The morph endpoint is fronted by four intent-named MCP tools that read the graphic's current bounds and fill in the rest: move(x, y) (absolute position, keep size), resize(width?, height?) (absolute size, keep origin), scale(factor) (uniform, about the center), and rotate(degrees) (relative spin about the center). They exist because a single morph verb was hard for an AI client to discover; each is a thin wrapper over this same PATCH.
Success — 200 OK. Body is the standard <graphic> dict reflecting the post-morph state: graphicUUID is preserved, but type may change (see "Class swap" below). Note hiddenBounds is the axis-aligned bounding box, so it grows when a graphic is rotated — it is not the requested bounds once rotation is non-zero.
Class swap (minimum geometric generalization). Some EazyDraw graphics cannot represent themselves under a given transform and convert to a more general class, preserving the graphicUUID and z-order index:
DKDCircle asymmetrically scaled converts to DKDOval (acceptsScalingTransform / conversionClassForScaling / convertForScaling).DKDRectangle (which deliberately has no rotation handle) becomes a DKDRotatedRect (acceptsRotationTransform / conversionClassForRotation / convertForRotation, applied via +[DKDTransMorphPanel performRequiredRotationConversionsWithGraphicView:]). Graphics that already carry an angle (DKDTextArea, DKDRotatedRect) rotate in place. Arc/pie-type graphics morph as Bezier geometry about their center.The type field in the response shows the post-swap type. Both swaps are top-level layer graphics only: a nested graphic that would require a class swap returns 422 "Nested graphic class swap not supported in v1" — uniform-scale, pure-translation, and rotation that needs no class change still work for nested graphics.
Transform sequence. Inside one dispatch_sync(dispatch_get_main_queue(), ...), with the target temporarily selected: (1) scale sX = newW/oldW, sY = newH/oldH if not unity; (2) translate (dx, dy) so the box origin lands at the requested x, y; (3) rotate last — setPureRotation + rotateByDegrees:-rotation, applied via [gView morphSelectedGraphicsWithTransform:includeText:YES] which rotates about the graphic's native center. Selection is saved and restored.
Locks. moveLock (when origin changes), sizeLock (when size changes or when rotating — there is no separate rotate-lock in v1), and the layer's layerLock. 423 Locked + ?force=true to override.
Errors:
400 Bad Request — missing path UUIDs; empty/malformed body; neither bounds nor a non-zero rotation; bounds present but missing/non-numeric x/y/width/height or non-positive width/height; rotation not a number.401 Unauthorized — bearer token missing or wrong.404 Not Found — drawing/layer/graphic chain does not resolve.422 Unprocessable Entity — graphic does not accept the scale/rotation and has no conversion class; source graphic has non-positive bounds; nested graphic would require a class swap (top-level graphics only); or class conversion produced no replacement.423 Locked — target graphic or its layer is locked against the change; retry with ?force=true.500 Internal Server Error — response build failure.curl:
# resize + reposition
curl -i -X PATCH -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"bounds":{"x":100,"y":100,"width":200,"height":150}}' \
http://localhost:52737/v1/drawings/$D/layers/$L/graphics/$G
# rotate 30 degrees in place
curl -i -X PATCH -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"rotation":30}' \
http://localhost:52737/v1/drawings/$D/layers/$L/graphics/$G
Python:
g = ed.morph_graphic(d_uuid, l_uuid, g_uuid,
x=100, y=100, width=200, height=150)
g = ed.morph_graphic(d_uuid, l_uuid, g_uuid, rotation=30) # rotate in place
print(g["type"], g["hiddenBounds"])
# A rectangle rotated comes back type "rotated-rect"; graphicUUID unchanged.
POST /v1/drawings/{D}/layers/{L}/combine — union / difference / intersectionPath booleans of two closed shapes — EazyDraw's Tools ▸ Combine (DKDDocViewCombine: combineKeepInsideA:insideB:, driven through an ordered two-graphic selection so the tested engine path, layer bookkeeping and undo are reused).
{
"operation": "union" | "difference" | "intersection",
"graphics": [ "<A graphicUUID>", "<B graphicUUID>" ], // exactly two, top-level on layer {L}
"name": "<optional name for the result>",
"uniquify": false
}
operation | Engine call | Result |
|---|---|---|
union | keepInsideA NO, insideB NO | one outline covering both shapes |
difference | keepInsideA NO, insideB YES | A minus B — B is cut out of A (order matters) |
intersection | keepInsideA YES, insideB YES | only the overlap |
The result is a new DKDBezier / DKDPolygon initialized from A (dkdinitWithDKDBez:), so it keeps A's graphicUUID, name, style and z-order index — think of it as A reshaped; B is consumed. A's type may change (e.g. rectangle → path). The active layer is switched to the operands' layer for the operation and restored, as the menu command does. One undo step, named Union / Difference / Intersection.
Eligibility. Each operand must answer acceptsCombine (a closed bezier path: rectangle, rounded-rectangle, oval, polygon, closed path, …); lines, text boxes, images and groups do not. The shapes must overlap or touch (canCombine); for difference B must cut into A (canDifference).
Success — 201 Created. The result's <graphic> dict (same graphicUUID as A).
Errors: 400 — operation not one of the three, or graphics not exactly two UUIDs; 404 — drawing / layer / an operand not found among the layer's top-level graphics; 409 — name taken (and no uniquify); 422 — the two UUIDs are the same graphic, an operand is not a closed path, the shapes do not overlap, or the engine produced no result; 423 — a delete lock on an operand or layerLock (?force=true overrides).
MCP: combine(operation, a, b, name?) — the tool description also answers to merge / add, subtract / cut out, and clip / overlap, and accepts those words as aliases.
DKDGroupThe single most common vector-drawing structural operation: combine several graphics into one addressable object, and dissolve it again. Both drive EazyDraw's own Group / Ungroup engine commands (via a temporary selection inside dispatch_sync(main)), so the tested grouping path — connection handling, layer bookkeeping, undo — is reused rather than reimplemented. Each is one discrete, undoable step ("Group" / "Ungroup").
POST /v1/drawings/{D}/layers/{L}/groups — group (group)Body: { "graphics": [uuid, uuid, …], "name"?: …, "uniquify"?: bool }. Two or more graphic UUIDs, all top-level on layer {L} (v1). The new DKDGroup is created at the grouped graphics' position, becomes the sole selection, and (in a UUID drawing) is auto-named unless a name is given. A supplied name is validated before the group is built, so a clash is a clean 409 (with no half-made group) unless uniquify: true.
Success — 201 Created. Body is the standard <graphic> dict for the new group, including graphicsCount. Its graphicUUID is immediately usable to morph / restyle / export the group as a unit, or to descend into it via the recursive …/graphics GET.
Errors: 400 — graphics missing or fewer than 2 UUIDs; 404 — drawing, layer, or a listed graphic not found among the layer's top-level graphics; 409 — name taken (no uniquify); 422 — fewer than 2 distinct graphics resolved; 423 — the layer is locked (?force=true to override). Groups top-level layer graphics only (a nested member is not addressable here).
POST /v1/drawings/{D}/layers/{L}/graphics/{G_chain}/ungroup — ungroup (ungroup)Dissolves the DKDGroup at {G_chain} back into its members, which return to the layer as independent graphics (the group is removed). v1: top-level groups only ({G_chain} is a single UUID).
Success — 200 OK → { "graphics": [ <graphic>, … ], "count": N } — the freed members (now the selection). Member graphicUUIDs are preserved, so anything you addressed before grouping remains addressable.
Errors: 404 — not found; 422 — the target is not a DKDGroup, or {G_chain} is nested (not top-level); 423 — the layer is locked (?force=true). Member-level locks are not separately enforced — ungroup is a structural change, not a move/resize/delete.
MCP. group(members=[name, …], name?, uniquify?) resolves each member by name (must share a layer, all top-level) and returns the new group; ungroup(name) returns the freed members. Both are intent-named so a client reaches for them directly.
DELETE /v1/drawings/{D}/layers/{L}/graphics — clear a layer (clear_layer)Removes all top-level graphics from layer {L} in one undoable step ("Clear Layer") — the bulk complement to the single-graphic DELETE …/graphics/{G}. Natural for prompt-driven "wipe this layer and start over".
Lock policy is atomic. With no ?force, the layer is scanned first: if the layer itself is locked, or any graphic on it blocks delete (deleteLock / moveLock as the single-delete uses), nothing is removed — 423 Locked. ?force=true clears everything regardless. (This differs from a best-effort "skip the locked ones" — clearing is all-or-nothing so the result is predictable.)
Success — 200 OK → { "deleted": N } (the number removed; 0 if the layer was already empty — the call is idempotent). Internally each graphic is removed via the document's own removeGraphic: inside one undo-hold-off group, so a single Undo restores the whole layer.
Errors: 404 — drawing or layer not found; 423 — locked (see above). MCP clear_layer(layer?, force?) — layer by name or uuid, omitted = the active layer; returns {layer, deleted}.
DKDTextAreaProgrammatic control of text-box content. The target is any DKDTextArea graphic, addressed by the same UUID-chain grammar as the morph/GET/DELETE graphic endpoints (top-level or nested in groups).
Model: a DKDTextArea is a first-class DKDGraphic carrying an NSAttributedString. The intended workflow is template-driven: a designer places a styled placeholder text box (font, size, color, alignment, tab stops all set in the EazyDraw UI); the API then feeds plain text into it. Styling is inherited from the placeholder's run-0 attributes — the API does not require the client to specify appearance. Optional uniform overrides adjust the whole box when needed. There are no per-character / per-range style controls in this version (see "Not exposed by the API").
GET .../graphics/{G_chain}/textReturns the current text and live layout metrics. A shape host resolves to its contained text (the response carries textTarget: "text" or "contained-text"), matching the runs endpoints and Disconnect Text — so any text tool can address the shape it sees. Annotation hosts are *not* resolved here (no layout contract — use .../text/runs or .../annotation). base always includes alignment (default left when the text carries no explicit paragraph style; note a centered-text centers via its host geometry, independent of paragraph alignment).
{
"text": "Quarterly\nSchematic",
"type": "text",
"typeName": "Text",
"layout": {
"bounds": { "x": 100, "y": 120, "width": 180, "height": 48 },
"requiredSize": { "width": 142.0, "height": 44.0 }, // size text needs at the current width
"fits": true, // requiredSize.height <= bounds.height
"stretch": { "x": 1.0, "y": 1.0 }, // glyph stretch from a stretch-mode morph; 1.0 = none
"charCount": 19,
"paragraphCount": 2
},
"base": { // resolved run-0 attributes (the inherited base)
"fontFamily": "Helvetica Neue",
"fontSize": 18.0,
"bold": false,
"italic": false,
"color": "#1A1A1A",
"alignment": "left"
},
"flow": { // text-flow / linked-chain state
"allowTextLink": false, // is the box flow-enabled (the "allow text link" switch)
"linked": false, // part of a linked chain (has a pre or post connector)
"isLead": false, // chain head (no incoming link, has an outgoing one)
"chainLength": 1, // boxes in the chain (1 if not linked)
"chainFits": true // the whole chain holds the text (no box overflows)
}
}
requiredSize is -[DKDTextArea requiredSize:] evaluated at the box's current width — the "give a width, get the needed height" primitive for fitting text to a design area. fits compares that height to the current box height.
flow is the upstream clue that long text can *flow* instead of overflowing: when linked is true, setting text on the lead distributes it across the chain (see Text flow below). chainFits is the chain-wide analog of fits — false means even the whole chain can't hold the text (shrink the font, or enlarge the boxes). allowTextLink mirrors EazyDraw's Text ▸ Allow Text Link switch.
Errors: 400 (missing UUIDs), 401, 404 (chain does not resolve), 422 (the graphic is not a DKDTextArea), 500.
PATCH .../graphics/{G_chain}/textSets text and/or uniform style. A shape host resolves to its contained text, as in GET (response carries textTarget). Body — all fields optional, but at least one of text or a style override (or autoHeight) must be present:
| Key | Type | Effect | ||||
|---|---|---|---|---|---|---|
text | string | Replace the string (may contain \n and \t). Omit to keep the existing string and only restyle. | ||||
fontFamily | string | Font family, via NSFontManager. | ||||
fontSize | number | Point size (> 0). | ||||
bold | bool | Add/remove the bold trait. | ||||
italic | bool | Add/remove the italic trait. | ||||
alignment | string | left \ | center \ | right \ | justified \ | natural. |
kerning | string | default \ | off \ | tight \ | loose (approximate point-based mapping). | |
color | string | #RRGGBB or #RRGGBBAA. | ||||
autoHeight | bool | When true, grow the box height to fit the text at the current width (setTextAreaSize), as a single undo step with the text change. Ignored when the box flows (a linked chain reflows instead of growing). | ||||
allowTextLink | bool | Set/clear the box's flow switch (-[DKDTextArea allowTextLink], EazyDraw's Text ▸ Allow Text Link). With a linked chain present, enabling it makes set-text reflow across the chain. |
Uniform semantics. The new content is built by taking the placeholder's run-0 attributes, applying the supplied overrides, and laying that uniform style across the entire string. A box with mixed runs is flattened to the run-0 style. This is the Level-1 contract; structural rich text is out of scope.
Tabs / columns. Because the run-0 paragraph style (including tab stops) is inherited, \t-delimited input lays out into the columns the template designer set up — tables, invoices, packing lists work with no table API.
Fit policy. Without autoHeight, the text is set at the current box; if it overflows, the response reports fits:false and requiredSize so the client (or Claude, with a PNG render) decides how to fit — resize the box (PATCH bounds) or resend with a smaller fontSize. EazyDraw never silently auto-fits.
Text flow (linked chains). When a box is flow-enabled (allowTextLink) and linked to following boxes, setting text on the lead makes the whole chain reflow: the text is laid out across the linked boxes (each sized to itself) and each box's slice is written back — attributes preserved (the chain shares one text stream, so styling is not lost the way a manual split would lose it). This reuses the same engine as interactive editing (-[DKDTextArea fullReflowText], gated by reflowApplies = allowTextLink && a post-link connector).
the downstream boxes first, then reflows, so repeated fills don't double-count.
flow.chainFits reports whether the whole chain holds the text(the per-box layout.fits will read true for the lead even when the tail overflows, because after reflow the lead only holds its own slice — use chainFits for the real answer).
boxes when the chain overflows (it does not add boxes to the chain). Address the lead box for whole-stream replacement.
DKDTextLinkPath connectors) is done inthe EazyDraw UI today; the API toggles allowTextLink and drives the reflow.
Success — 200 OK. Body is the <text> dict (same shape as GET), reflecting post-change state — so the fit metrics come back in the same call.
Undo. One discrete step named "Set Text" (swapWithUndoDKDContents:); with autoHeight, the height change is grouped into the same step.
Locks. Checked via the standard helper: the layer's layerLock blocks any change; the graphic's sizeLock blocks only when autoHeight would resize. A pure text/style change is not gated by moveLock/sizeLock. 423 Locked with ?force=true to override.
Errors: 400 (missing UUIDs; empty/malformed body; text not a string; nothing actionable supplied), 401, 404, 422 (not a DKDTextArea), 423 (locked), 500.
t = ed.set_text(d, l, g, "Widget\t3\t$9.00\nGrommet\t12\t$1.20", autoHeight=True)
print(t["layout"]["fits"], t["layout"]["requiredSize"])
t = ed.set_text(d, l, g, font_size=10) # restyle only, keep the string
POST / DELETE .../text)EazyDraw's Text ▸ Insert Text and Text ▸ Disconnect Text, as REST verbs on the host graphic's /text sub-resource. Contained text is a DKDTextArea connected to a shape — the label travels with the shape.
POST .../graphics/{G_chain}/text — Insert TextBody: { "text": "<non-empty string>" } plus optional centered (bool, default true) and the Level-1 uniform overrides (fontFamily, fontSize, bold, italic, alignment, kerning, color). The new graphic gets a UUID and an auto-name like any API-introduced graphic.
Two forms (stable type distinguishes them everywhere):
centered | Class | type | Behavior |
|---|---|---|---|
true (default) | DKDCenterText | centered-text | re-centers with the host on move and resize — what a shape label wants |
false | DKDTextArea | text | position-fixed contained text, placed in the upper half of the host's bounds; moves with the host but does not re-center on resize |
Success — 201 Created. Body is the <runs> dict of the new contained text (textTarget: "contained-text"; its type key echoes the form) plus containedTextUUID and the layout block. Undo: one step ("Insert Centered Text" / "Insert Text"). Locks: layerLock; 423 + ?force=true.
Errors: 400 (missing/empty text), 401, 404, 409 (the graphic already has contained text), 422 (the graphic is a text box — use PATCH .../text; the graphic does not accept inserted text; the graphic is nested in a group), 423, 500.
DELETE .../graphics/{G_chain}/text — Disconnect TextAddress either the host shape or the contained text box itself. The text becomes a standalone text box: an API/plain contained text is simply unlinked in place; a UI-typed centered text (DKDCenterText) is replaced by an equivalent plain text box with a new UUID and name (a centered text only exists connected). Contents and styling are preserved either way.
Success — 200 OK. Body is the freed text box's <graphic> dict — use its graphicUUID / nameGraphic to address it afterward. Undo: one step, "Disconnect Text". Locks: layerLock on the host; 423 + ?force=true.
Errors: 400, 401, 404, 422 (no contained text; contained text was empty — unlinked, but no text box produced), 423, 500.
r = ed.insert_text(d, l, shape, text="Pump House", color="#004080")
ed.set_text_attributes(d, l, shape, operations=[{"match": "Pump", "set": {"underline": "single"}}])
box = ed.disconnect_text(d, l, shape) # -> standalone text box <graphic>
.../text/runs, .../text/attributes)Range-level styling for DKDTextArea content. Unlike the Level-1 PATCH .../text (which rebuilds the string as one uniform run), these endpoints are non-destructive: they read and edit the attribute runs of the existing string without touching characters or unrelated attributes.
> Decision record (2026-07-10). This supersedes the earlier "never a > span-offset API" stance. Field experience with Claude as the MCP agent shows > agents reach instinctively for range styling (highlighting words). The > original objection — LLMs are unreliable at character-offset arithmetic — is > mitigated by design: match addressing resolves substrings to ranges > server-side, and GET .../text/runs supplies authoritative offsets so a > client reads offsets rather than computing them.
GET .../graphics/{G_chain}/text/runsReturns the string and its normalized runs (contiguous, non-overlapping — NSAttributedString's native form):
{
"text": "Quarterly Schematic",
"type": "text",
"typeName": "Text",
"charCount": 19,
"runs": [
{ "range": { "location": 0, "length": 10 },
"attributes": {
"fontFamily": "Helvetica Neue", "fontSize": 18.0,
"bold": false, "italic": false,
"color": "#1A1A1A", "alignment": "left"
} },
{ "range": { "location": 10, "length": 9 },
"attributes": {
"fontFamily": "Helvetica Neue", "fontSize": 18.0,
"bold": true, "italic": false,
"color": "#CC0000", "highlightColor": "#FFFF00",
"underline": "single", "underlineColor": "#0000CC",
"alignment": "left"
} }
]
}
Attribute vocabulary (read side is complete from day one; keys camelCase):
| Key | Type | NSAttributedString source | Notes | ||||
|---|---|---|---|---|---|---|---|
fontFamily | string | NSFontAttributeName | family name | ||||
fontSize | number | NSFontAttributeName | point size | ||||
bold / italic | bool | font traits | via NSFontManager | ||||
color | hex | NSForegroundColorAttributeName | always present (default #000000) | ||||
highlightColor | hex | NSBackgroundColorAttributeName | per-glyph background — the UI's Highlight | ||||
strokeColor | hex | NSStrokeColorAttributeName | omitted when unset | ||||
strokeWidth | number | NSStrokeWidthAttributeName | % of point size; negative = stroke and fill | ||||
underline | string | NSUnderlineStyleAttributeName | single \ | double \ | thick (omitted when off) | ||
underlineColor | hex | NSUnderlineColorAttributeName | omitted when unset (defaults to text color) | ||||
strikethrough | string | NSStrikethroughStyleAttributeName | same vocabulary as underline | ||||
strikethroughColor | hex | NSStrikethroughColorAttributeName | omitted when unset | ||||
kerning | number | NSKernAttributeName | points; omitted when default | ||||
baselineOffset | number | NSBaselineOffsetAttributeName | omitted when 0 | ||||
obliqueness / expansion | number | corresponding attributes | omitted when 0 | ||||
alignment | string | paragraph style | paragraph-level (left\ | center\ | right\ | justified\ | natural) |
shadow | object | NSShadowAttributeName | { offset:{x,y}, blur, color }; omitted when no shadow |
Optional channels are omitted when unset, so a run's dict is exactly the styling that is actually in effect. highlightColor, underline, and strikethrough form the emphasis trio — the three ways to call out a range of text.
Text targets. Both runs endpoints resolve the addressed graphic to its text target — a text box is not the only thing that carries text:
| Graphic | Target | textTarget value | layout block |
|---|---|---|---|
DKDTextArea | its own content | "text" | yes |
| path/shape with an annotation in use | the annotation's attributed text | "annotation" | no (no fit contract — text may follow a curve) |
| shape with contained text | the contained DKDTextArea | "contained-text" | yes |
The textTarget key on every runs response says which case applied. This mirrors the drawing engine's own routing (annotation first, contained text second). A graphic with none of these returns 422 "Graphic has no text target". Groups are not resolved (address the member).
Errors: 400, 401, 404, 422 (no text target), 500.
PATCH .../graphics/{G_chain}/text/attributesBody: { "operations": [ <op>, … ] } — an ordered list, applied sequentially to one working copy and committed as a single undo step. Overlapping ranges are welcome: later operations win where they overlap (NSMutableAttributedString semantics, per character).
Each <op> addresses a range one of three ways, and carries set and/or clear:
| Key | Type | Meaning |
|---|---|---|
all | bool | the whole string |
range | {location, length} | explicit character range (from GET .../text/runs) |
match | string | literal substring of the current text (case-sensitive) |
occurrence | int or "all" | with match: which occurrence (1-based, default 1), or every occurrence |
set | dict | attribute keys → values (write vocabulary below) |
clear | array | attribute keys to remove from the range |
Write vocabulary (Phase 2, 2026-07-11 — now the full read vocabulary except paragraph-level alignment):
color, highlightColor, strokeColor (+ strokeWidth),underlineColor, strikethroughColor (hex / number).
underline, strikethrough ("none" \| "single" \|"double" \| "thick"; boolean accepted as sugar — true → single, false → none).
fontFamily (family name via NSFontManager; an unknownfamily leaves the text unchanged, matching the Level-1 override), fontSize (points, > 0), bold, italic (booleans; per-run trait conversion — mixed-font ranges convert each run's own font).
kerning (points; distinct from the Level-1 endpoint'snamed kerning presets), baselineOffset, obliqueness, expansion.
alignment (left \| center \| right \|justified \| natural). Alignment is a paragraph property: addressing part of a paragraph aligns every paragraph the range touches (the operation extends to whole-paragraph bounds). A centered-text graphic also centers via its host geometry, independent of this.
shadow: `{ "offset": { "x", "y" }, "blur","color" } (all fields optional; defaults offset {2,-2}, blur 2, color black). clear: ["shadow"]` removes it.
clear accepts the color, style, number, and shadow keys (font and alignment keys are not clearable — a run always has a font, and a paragraph always has an alignment). This is the full write vocabulary — it now matches the read vocabulary, so a run read from GET .../text/runs can be written back verbatim (see PUT .../text/runs).
PUT .../graphics/{G_chain}/text/runsThe round-trip write: GET .../text/runs → edit → PUT .../text/runs reproduces the edited state. Body:
{ "text": "optional replacement string",
"runs": [ { "range": { "location": 0, "length": 5 },
"attributes": { "color": "#CC0000", "bold": true, … } }, … ] }
runs (required) is the array GET .../text/runs returns — each entry a{ range, attributes }. Every attribute is applied over its run's range, authoritatively (this is a *replace*, not a merge): the string is rebuilt to a uniform base and the runs lay the styling over it in order.
text (optional) replaces the string; each run's range must fall withinit (else 422). Omit to keep the current string.
Success — 200 OK. Body is the <runs> dict (as GET), plus layout for text/contained-text targets, textTarget. Undo: one step, "Text Attributes". Locks: layerLock; 423 + ?force=true.
Errors: 400 (runs missing/not an array; a run without range or attributes; unknown/invalid attribute), 401, 404, 422 (no text target; a run range out of bounds for the string), 423, 500.
backgroundColor is accepted on write as an alias of highlightColor (agents produce both spellings); responses always say highlightColor.
{ "operations": [
{ "all": true, "set": { "color": "#1A1A1A" } },
{ "match": "Schematic", "set": { "highlightColor": "#FFFF00" } },
{ "match": "urgent", "occurrence": "all", "set": { "color": "#CC0000", "underline": "single" } },
{ "range": { "location": 0, "length": 9 }, "clear": ["highlightColor"] }
] }
Semantics.
{ "all": true, "set": { … } } —this is the non-destructive alternative to the Level-1 color override.
set uses replace-within-range per attribute: other attributes and otherranges are untouched.
clear removes the attribute (for underline/strikethrough, equivalentto "none"; clearing color restores default black).
runs, in order.
Success — 200 OK. Body is the <runs> dict (as GET .../text/runs) plus the standard layout block (bounds, requiredSize, fits, …) so fit feedback returns in the same call. Styling never resizes the box (fits reports; EazyDraw never silently auto-fits).
Undo. One discrete step, "Text Attributes", regardless of the number of operations.
Locks. layerLock only (no geometry change). 423 + ?force=true.
Errors: 400 (malformed body: operations missing/empty/not an array; an op with no addressing or with neither set nor clear; unknown attribute key; bad hex; bad style string), 401, 404, 422 (no text target; range out of bounds for the current string; match not found), 423 (locked),
depends on the document's current text is 422.
Annotation targets. When the resolved target is an annotation the same operations apply to the annotation's attributed text (committed through the annotation rebuild path, one undo step); the response carries textTarget: "annotation" and no layout block. This makes annotations first-class styling targets while PATCH .../annotation/text remains the plain-string replacement primitive.
r = ed.get_text_runs(d, l, g)
ed.set_text_attributes(d, l, g, operations=[
{"match": "Schematic", "set": {"highlightColor": "#FFFF00"}},
])
DKDAnnotationA DKDAnnotation is not a graphic; it is owned by a DKDBezier (or subclass), so it is addressed as a sub-resource of its host graphic. These endpoints are plain text only — strings are short and may follow a curve, so there is no size/fit contract; the visual result is confirmed via a PNG export render. For styling an annotation's text (colors, highlight, underline, strikethrough, per range), address the *host graphic* with the rich-text runs endpoints above — they resolve to the annotation automatically (textTarget: "annotation").
GET .../graphics/{G_chain}/annotation{
"type": "rectangle",
"typeName": "Rectangle",
"hasAnnotation": true,
"text": "Section A",
"annotationFormat": "Box", // un-localized DKDAnnotationFormat name
"annotationShow": "Yes" // un-localized DKDAnnotationShow name
}
If the graphic has no annotation (or cannot carry one), hasAnnotation is false and the text/format/show keys are omitted — still 200 OK.
Errors: 400, 401, 404, 500. (No 422: a graphic with no annotation reports hasAnnotation:false rather than erroring.)
PATCH .../graphics/{G_chain}/annotation/textBody: { "text": "<string>" } (required). Sets the annotation's text, inheriting the existing annotation's run-0 attributes, then rebuilds and redisplays it (touchBarSetAnnotationTextWithUndo:). Requires an existing annotation on the target (template-placed); a graphic without one returns 422 "Graphic has no annotation target".
Success — 200 OK. Body is the <annotation> dict (post-change). Undo: one step, "Set Annotation Text". Locks: layerLock only; 423 + ?force=true.
Errors: 400 (missing UUIDs; text missing/not a string), 401, 404, 422 (no annotation target), 423 (locked), 500.
Direct GET of a rendered representation. Path grammar:
/v1/drawings/{D}/export/{format}
/v1/drawings/{D}/layers/{L}/export/{format}
/v1/drawings/{D}/layers/{L}/graphics/{G1}/export/{format}
/v1/drawings/{D}/layers/{L}/graphics/{G1}/graphics/{G2}/export/{format}
/v1/drawings/{D}/layers/{L}/graphics/{G1}/graphics/{G2}/.../graphics/{Gn}/export/{format}
/v1/libraries/{L}/export/{format} (501)
/v1/libraries/{L}/elements/{E}/export/{format} (501)
{format} is one of: native, svg, pdf, png, jpg. Case-insensitive.
format | Content-Type | File extension | Source |
|---|---|---|---|
native | application/json | .ezdjson | DKDFileType_EZDJSON — flat (un-optimized) JSON dictionary. Drawings: full document via [doc documentDictionaryForDocumentIncludeLayers:YES outputFileType:DKDFileType_EZDJSON] with serialization temporarily forced to DKDSerialization_Flat. Graphics: [g propertyListRepresentation], the same dict the file format embeds, JSON-encoded. |
svg | image/svg+xml | .svg | [doc svgDataForDocument]. For graphics, the document's SVG path is invoked while the graphic is temporarily the only selected graphic and exportContents is Graphics_Selected — see "Selection mutation" below. |
pdf | application/pdf | .pdf | Drawings: [doc pdfDataMultiPagination:SinglePagePDFPagination withError:&err]. Graphics: [doc pdfDataWithGraphics:@[g]] (no selection mutation — direct entry point). |
png | image/png | .png | [expCtrlr exportDataWithType:DKDFileType_PNG] with exportContents set per resource (Drawing_Full for drawing/layer, Graphics_Selected with the target selected for graphic). Resolution is pinned to 144 dpi by default (overridable with ?dpi=, see Resolution). PNG color space, alpha, bits-per-color, and antialias settings come from the document's DKDExport defaults. |
jpg | image/jpeg | .jpg | Same as PNG but DKDFileType_JPG. JPG color space and compression come from DKDExport defaults. |
Content-Type set per the table above.Content-Disposition: attachment; filename="<safe-name>.<ext>" so curl -O, browser saves, and requests's iter_content all get a sensible filename. Filename composition:<displayName>.<ext><displayName>-<layerName>.<ext><displayName>-<graphicNameOrUUID>.<ext>/ \ : * ? " < > |) in the filename component are replaced with _.png, jpg, pdf, svg) of a drawing with no visible graphics return the empty page (background per the document's export settings) rather than failing — so a fresh new drawing can be rendered before anything is placed. Graphic- and selection-scoped exports still need content to size themselves from. (DKDExportData previously required at least one graphic on every path; relaxed for ExportContents_Drawing_Full 2026-09-10.)Drawing_Full export mode at the layer endpoint. The bytes returned for /drawings/{D}/export/{f} and /drawings/{D}/layers/{L}/export/{f} are identical for the same document state. Layer-specific filtering (render only the named layer's graphics on a full-drawing canvas) is a future enhancement and would require temporary layer-state mutation.[gView selectedGraphics]clearSelection, then selectGraphics:@[targetGraphic][exp exportContents], set to ExportContents_Graphics_Selected@finallyThis runs inside dispatch_sync(main_queue, ...) so it is atomic against other API calls. UI selection updates may be visible briefly to a user watching the document. Native and PDF for graphics use direct entry points and do not mutate selection.
/v1/libraries/{uuid}/export/{format} and /v1/libraries/{uuid}/elements/{uuid}/export/{format} return 501 Not Implemented.expandFactor is saved, set to dpi/72, and restored around the export). Override with the query string ?dpi=N, clamped to 36..600 (e.g. …/export/png?dpi=300 for print). Vector formats (svg, pdf, native) ignore it.render(drawing, dpi=96) returns the PNG inline to look at — kept light at 96 dpi for the perception loop (bump for fine detail); export_drawing(fmt, path?, drawing?, dpi=144) and its twin save_render(drawing, path?, dpi=144, fmt) write a file to disk — fmt is pdf | svg | png | jpg | ezdjson (ezdjson = native) — and return only the path ({path, fmt, bytes[, dpi]}), so the bytes never enter the context. path may be a file (extension added if missing) or a folder (auto-named inside it). The MCP server writes the file itself, outside the App Store sandbox, so any client-reachable folder works. With no path, save_render auto-names into a render folder (default ~/EazyDraw-Renders, override with the EAZYDRAW_RENDER_DIR env var — point it at a folder the client's filesystem access can read to view the result).DKDExport defaults. A future revision will accept further query-string overrides (e.g. ?colorSpace=srgb).200 — bytes returned with the correct Content-Type.400 Bad Request — {format} is not one of the five recognized values, or (for the recursive graphic endpoint) the UUID chain is empty.404 Not Found — drawing / layer / graphic at the named UUID(s) does not resolve.500 Internal Server Error — export pipeline returned nil bytes.501 Not Implemented — library export endpoints.PATCH /v1/drawings/{D}/layers/{L} and PATCH /v1/drawings/{D}EazyDraw layer visibility is two‑dimensional: each layer has a LayerState (On / Off / Active, exactly one active = the destination for new content), and the document has a LayerSelect mode governing which non‑active layers show. Export is WYSIWYG — graphics gate their own drawing on visibleLayer:, so what renders to PDF = the active layer + (ON layers, only if layerSelect permits). With one layer this collapses and never matters. This is the basis for conditional regions: park an optional clause on its own layer and toggle it.
PATCH /v1/drawings/{D}/layers/{L} { "layerState": "on" | "off" | "active" }
off hides the layer (drops its content from the render); on shows it;active makes it the destination (the previous active becomes on).
422 "make another layer active first". Setting an already‑active layer active is a no‑op. 200 OK — the updated <layer> dict.
PATCH /v1/drawings/{D} { "layerSelect": "active-only" | "show-others" | "select-others" | "show-all" | "select-all" }
active-only → only the active layer shows; show-others → ON layers visible;select-others → ON layers visible and editable; show-all / select-all → include OFF layers. Set show-others/select-others so a layer you turn ON actually appears in the render. 200 OK — the updated <drawing> dict.
Both are undoable (self‑registering inverse + screen refresh) and mark the document dirty. Layer state is not gated by layerLock (visibility is meta, not a content edit).
POST /v1/drawings/{D}/layers/{L}/order — restack a layerMoves a whole layer in the document's front‑to‑back stack — the order the Layers drawer table shows (front layer at the top) and the order layers paint (front on top of the layers beneath it). This restacks the entire layer; POST .../graphics/{G_chain}/order restacks one graphic *within* its layer.
Request body { "to": "front" | "back" | "forward" | "backward" }
front → top of the Layers table, drawn on top of every layer below it(internal index 0).
back → bottom of the table (internal index count − 1).forward → one step toward the front; backward → one step toward the back.Already at the end → clamped (no‑op move, still 200).
Backed by -[DKDDocument moveLayer:toIndex:], which registers its own undo inverse; the handler wraps it in a discrete undo group, refreshes the screen and the Layers drawer popup (dkdAPIRefreshLayerVisibility), and marks the document dirty. Action name "Layer To Front" / "Layer To Back" / "Layer Forward" / "Layer Backward".
> Front/back vs. the internal index. The vocabulary is viewer‑consistent: > *front* = on top = top of the Layers table. Internally that is array index 0, > and the draw loop iterates the layer array in reverse so index 0 paints last > (on top). Callers never see the index — they say front/back/forward/ > backward.
Not gated by layerLock (restacking is document structure, like layer visibility, not a content edit). 200 OK — the updated <layer> dict.
Errors: 400 body missing or to not one of the four words; 404 drawing or layer not found; 500 response build failed.
POST /v1/drawings/{D}/layers — create a layerAdds a new layer at the front of the stack (top of the Layers table) and, by default, makes it the active layer so the next inserted content lands on it. Mirrors the UI New Layer command (-[DKDLayersDataSource addLayerAction]): the new layer inherits the active layer's dkdScale. Use it to stack content — e.g. create a layer for an incoming PDF backdrop, or the layer a template needs above a PDF for its fill slots.
Request body (optional — send {} or no body for defaults):
{
"name": "<string>", // optional; auto-assigned if omitted, de-duplicated either way
"active": <Bool> // optional; default true — make the new layer active
}
Backed by -[DKDDocument insertLayer:atIndex:] (registers its own undo inverse) at index 0, plus dkdAPISetActiveLayer: when active (self‑registering inverse to the prior active); wrapped in a discrete undo group with a screen + Layers‑drawer refresh and dirty mark. Action name "New Layer". The new layer is assigned a layerUUID so it is immediately addressable. Not gated by layerLock.
Success — 201 Created. Body is the new <layer> dict.
Errors: 400 drawing UUID missing, or body present but not a JSON object; 404 drawing not found; 500 response build failed.
PATCH /v1/drawings/{D}/layers/{L} — rename a layer (layerName)The layer PATCH takes layerState (documented under Layer visibility) and/or layerName; at least one is required, and when both are sent the rename is applied first.
{ "layerName": "<new name>", "uniquify": <Bool> } // uniquify default false
Layer names are unique within a drawing. The name goes through the same validator the Layers table uses (-[DKDDocument isAcceptableLayerName:postMessage:], silent here): empty, leading white space, illegal or control characters, the -- duplicate marker, and the reserved names are all 400; a name already in use is 409 unless uniquify is true, which appends -2, -3, … ("Notes" → "Notes-2"). The Paper and Guides layers cannot be renamed (422). Undoable (action name "Rename Layer"); the Layers drawer is refreshed on undo and redo too.
Success — 200 OK. The updated <layer> with the final layerName.
Python: ed.rename_layer(d, l, name="Notes", uniquify=True). MCP: rename_layer(layer, name, uniquify?).
DELETE /v1/drawings/{D}/layers/{L} — delete a layerRemoves the layer and every graphic on it — the Layers table's Delete. Backed by -[DKDDocument removeLayer:], which registers its own inverse (insertLayer:atIndex:), so one Undo brings the layer *and its content* back at the same position; if the deleted layer was active, the layer below (or above, for the top layer) becomes active. Action name "Delete Layer".
Gates: the drawing's only layer and the Paper layer cannot be deleted (422). A layerLock on the layer, or a deleteLock on any graphic on it, is 423 with the usual locks body unless ?force=true.
Success — 200 OK. The pre-delete <layer> dict (graphicsCount tells how much went with it). The UUID no longer resolves afterwards (until an Undo restores it — the same layer object comes back, same UUID).
Python: ed.delete_layer(d, l, force=False). MCP: delete_layer(layer, force?).
GET .../layer-configurations and apply via PATCH /v1/drawings/{D}A layer configuration (DKDLayersConfiguration) is a saved, named snapshot of a whole layer arrangement — each layer's on/off state, hide-dimensions, lock, and color modification, plus the layer order and the document's layerSelect mode. Users build them in the Layers drawer ("draft", "final", "client-review", …) to switch a drawing between presentations. These endpoints let an agent list them and apply one, so a user can set up and *see* a configuration with AI help.
GET /v1/drawings/{D}/layer-configurations → the saved configs:
[
{
"name": "client-review",
"layerSelect": "show-others",
"layers": [
{ "layerName": "Base", "layerState": "On", "hideDimensions": false, "layerLock": false },
{ "layerName": "Markup", "layerState": "Off", "hideDimensions": true, "layerLock": false }
]
}
]
layerState is the un-localized name (On/Off/Active); layerSelect uses the API vocabulary (show-others, …). The layers order is the configuration's layer order.
PATCH /v1/drawings/{D} { "layerConfiguration": "<name>" } — apply it. This is the HTTP analog of the UI's Load Layer Configuration: it calls -[DKDLayersConfiguration applyToDocument:], setting every layer's state / hide-dimensions / lock / color-mod, reordering the layers, and setting layerSelect. It changes the live document (visible on screen) and is undoable — wrapped in a self-registering helper (dkdAPIApplyLayersConfiguration:) that snapshots the current arrangement as a config, registers the inverse, then applies the new one, so undo and redo both restore the full layout. Action name "Apply Layer Configuration". Render afterward (export png) to *see* the result. 200 OK — the updated <drawing>.
layerSelect and layerConfiguration may be sent together (config applied first); at least one is required. 404 if no saved config has that name. (Saving and deleting configurations is not exposed; create them in EazyDraw's Layers palette.)
<layer> shape{
"layerName": "<DKDLayer.layerName>",
"layerState": "<nameForLayerState(state, NO)>",
"graphicsCount": <NSUInteger>
}
layerState values are the un-localized names: NotSet, On, Off, Active. graphicsCount is [layer.layerGraphics count].
If a layer has no UUID at the time it is requested, one is assigned via [layer setLayerUUID:] so subsequent calls are stable.
<graphic> shape (entry in /layers/{uuid}/graphics){
"index": <NSUInteger>,
"graphicUUID": "<DKDGraphic.graphicUUID>",
"type": "<stable kind: text|image|pdf-image|group|rectangle|oval|line|...>",
"typeName": "<localized display name, e.g. Text>",
"nameGraphic": "<DKDGraphic.nameGraphic>",
"hiddenBounds": { "x": <Number>, "y": <Number>, "width": <Number>, "height": <Number> },
"graphicsCount": <NSUInteger>,
"locks": { "deleteLock": <Bool>, "moveLock": <Bool>, "sizeLock": <Bool>, "layerLock": <Bool> }
}
Array order in /graphics is [layer layerGraphics] order; this is the layer z-order / draw order. index is the position in that array (0 is drawn first, highest index is drawn last / on top within the layer).
nameGraphic is omitted if the graphic has no name set.
type is the stable, locale-independent API kind — clients and agents match logic on this, never on an internal class name. Semantic families resolve by class ancestry (text for any DKDTextArea, image for any DKDImage, plus pdf-image and group); every other graphic uses its class name kebab-cased with the DKD prefix dropped (DKDOval → oval, DKDRoundedRectangle → rounded-rectangle). typeName is the localized human label from +[<class> typeNameForMenu] — the same word EazyDraw shows in its menus (e.g. Text, Image) — for display only; do not match on it. (The raw Obj-C class name is never exposed.)
graphicsCount is only present when the graphic is a DKDGroup (or subclass thereof), and reports [[g groupArray] count] — the number of immediate children in the group. Non-group graphics omit the key entirely. The presence of the key is itself the "this is a group" signal; in Python, g.get("graphicsCount") returns None for leaf graphics and an integer for groups. An empty group reports 0.
locks is always present. Three flags come from the graphic's DKDLock (deleteLock, moveLock, sizeLock); the fourth (layerLock) is the containing layer's lock. Clients can read this dict before attempting a mutation to know whether it will be blocked — see Lock policy below. (sizeLock is the general-purpose size lock that the API checks. DKDLock also carries DKDLine Pin flags — LockFlag_Lock_Angle, LockFlag_Lock_Length, LockFlag_Lock_Center — which are intentionally not exposed or enforced by the API; see Pin is not an API lock under Lock policy.)
If a graphic has no UUID at the time it is requested, one is assigned via [g setGraphicUUID:] so subsequent calls are stable.
pages/setup, pages/layout, grids, properties, scale, window return the corresponding model object's propertyListRepresentation (or docWindowConfiguratonDictionary for window) verbatim. Keys and value formats match the on-disk drawing file format.
pages also answers on its own: GET /v1/drawings/{uuid}/pages is the derived page-geometry summary (see page geometry above), while pages/setup and pages/layout remain the verbatim model dicts.
/state — full model state (read backstop)The /state endpoints return an object's complete propertyListRepresentation as JSON — the same dictionary the on-disk file format embeds. They are the backstop: when a structured endpoint doesn't expose a particular attribute, GET the full state and read whatever you need. Keys are EazyDraw on-disk names (PascalCase / mixed), not the camelCase HTTP vocabulary — they are human/AI-readable but not Python-idiomatic.
| Endpoint | Body | Lookup |
|---|---|---|
GET /v1/drawings/{D}/layers/{L}/state | [layer propertyListRepresentation] | 404 Layer not found |
GET /v1/libraries/{L}/state | [lib propertyListRepresentation] | 404 Library not found |
GET /v1/libraries/{L}/elements/{E}/state | [element propertyListRepresentation] | {E} matched case-insensitively against each DKDLibElement.libraryUUID; 404 Library or element not found |
DKDGraphic / DKDGroup / DKDDocument full state is already reachable via GET .../export/native (DKDFileType_EZDJSON), so these three close the remaining gaps (layer, library, library element).
Flat (inline attributes, no hash refs). The dumps resolve attributes inline so a reader never has to chase hash references into an archive store:
propertyListRepresentation serializes graphics with a nil archiveStoreRef, so attributes are already inline — no special handling.DKDLib.propertyListRepresentation builds a hash-ref archive store when its serialization is Optimized. The handler therefore saves the lib's serialization, forces DKDSerialization_Flat, builds the dict, and restores — the same flat-forcing export/native uses on documents.These are GETs: read-only, never blocked by locks, no document mutation.
Binary data → Base64. A library element can carry a button-icon image (DKDImage → NSData), and some graphics embed image data. All API JSON responses funnel through one encoder (_dkd_jsonResponseWithObject:): JSON-safe payloads use NSJSONSerialization unchanged, but a dictionary containing NSData (which NSJSONSerialization rejects) falls back to EazyDraw's jsonDataWithDictionary, which Base64-encodes the bytes — the same representation the on-disk .ezdjson and export/native produce. So image bytes come back as Base64 strings, never an error.
PATCH /v1/drawings/{D}/properties — set document properties> Status: implemented and verified on Version-26 (tested via tools/python/test_properties.py). The API's first non-graphic mutation and the template for read+write on flat (non-hash-optimized) model entities.
Sets document metadata on [doc docProperties]. Merge semantics — only the keys present in the body are changed; omitted keys are left untouched. Companion to GET /v1/drawings/{D}/properties, which returns the same dict shape, so you can read, edit, and send the same dict back.
Request body — any subset of the writable metadata keys. Keys are the verbatim propertyListRepresentation keys (PascalCase, on-disk form), the same vocabulary GET returns. (A clean camelCase external vocabulary with a mapping layer is a known future revision; until then the on-disk keys are the contract.)
| JSON key | JSON type | Model setter |
|---|---|---|
TitleProperty | string | setTitleProperty: |
AuthorsProperty | array of strings | setAuthorsProperty: |
KeywordsProperty | array of strings | setKeywordsProperty: |
DescriptionProperty | string | setDescriptionProperty: |
CommentProperty | string | setCommentProperty: |
OrganizationsProperty | array of strings | setOrganizationsProperty: |
CopyrightProperty | string | setCopyrightProperty: |
ProjectsProperty | array of strings | setProjectsProperty: |
VersionProperty | string | setVersionProperty: |
PATCH /v1/drawings/{D}/properties
{
"TitleProperty": "Quarterly Schematic",
"AuthorsProperty": ["Dave Mattson"],
"CopyrightProperty": "2026"
}
"" (string fields) or [] (array fields).Implementation note — do NOT route through loadPropertyListRepresentation:. The handler must apply changes via the individual setters. DKDProperties.loadPropertyListRepresentation: (DKDProperties.m:418) is a full-replace that resets QuickLook settings and the serialization mode to defaults when their keys are absent — a metadata-only PATCH sent through it would silently clobber QuickLookContent / QuickLookFormat and force Serialization to Flat. Setter-based merge avoids this.
Read-only keys — accepted, ignored. The GET response also carries QuickLookContent, QuickLookPage, QuickLookFormat, QuickLookUseSmallerOf_JPG_PDF, Serialization, and uuid. So a client can GET, edit one metadata field, and PATCH the whole dict back. These six keys are accepted and ignored (not an error, so round-trips don't break) but not written. They are behavior switches rather than metadata: QuickLook configuration; the Optimized↔Flat Serialization toggle (the on-disk attribute-optimization store); and uuid (DKDProperties.uuidProperties, the yes/no for whether the document assigns persistent UUIDs to graphics and layers). They are not writable through the API.
Success — 200 OK. Body is the full updated DKDProperties.propertyListRepresentation (same shape as GET), reflecting post-merge state.
Undo / persistence. Mirror the Properties inspector (DKDPropertiesPanel OK path): snapshot the full propertyListRepresentation before the merge, apply via the setters, snapshot it after, then register a symmetric swap on [doc undoManager] with action name "Change Drawing Properties" — undo restores the before-snapshot, redo the after. Registering the undo marks the document dirty (the panel's immediate-apply toggles also call [doc updateChangeCount:NSChangeDone] explicitly; doing both is harmless). The inspector's -swapProperties:oldProperties: lives on the panel and is not reachable headlessly, so the handler needs an equivalent swap target — recommended: a small DKDDocument-level swap method as the prepareWithInvocationTarget: target. That swap applies the full before/after snapshots via loadPropertyListRepresentation: (safe — complete dicts), which is distinct from the partial PATCH apply that uses the individual setters.
Errors:
400 Bad Request — body empty or not a JSON object; a string field given a non-string; an array field given a non-array or an array containing a non-string element; or an unrecognized key (not one of the nine writable keys or the five accepted-and-ignored keys — this guards against PascalCase typos silently no-op'ing).401 Unauthorized — bearer token missing or wrong.404 Not Found — no open drawing with that UUID. Body: { "error": "Drawing not found" }.500 Internal Server Error — response build failed.No lock checks apply — DKDProperties is document metadata, not a graphic or layer, so there is no 423/force path.
Threading. Lookup, setter application, undo registration, and response build run inside one dispatch_sync(dispatch_get_main_queue(), …) block, like the other mutating handlers.
curl:
curl -i -X PATCH -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"TitleProperty":"Quarterly Schematic","AuthorsProperty":["Dave Mattson"]}' \
http://localhost:52737/v1/drawings/$D/properties
Python:
props = ed.set_properties(d_uuid, {
"TitleProperty": "Quarterly Schematic",
"AuthorsProperty": ["Dave Mattson"],
})
print(props["TitleProperty"])
Every DKDGraphic carries a DKDLock exposing three boolean flags the API enforces (deleteLock, moveLock, sizeLock), and every DKDLayer carries layerLock. These are deliberate user-set protections — set in the EazyDraw UI to keep a graphic or whole layer from being changed accidentally. The API respects them by default and rejects mutations that would violate a lock, returning 423 Locked with a body that explains which lock is in the way:
HTTP/1.1 423 Locked
Content-Type: application/json
{
"error": "Graphic has delete-lock. Retry with ?force=true to override.",
"locks": { "deleteLock": true, "moveLock": false, "sizeLock": false, "layerLock": false }
}
The same locks sub-dict appears in every <graphic> response, so a polite client can read locks before attempting a change and skip the round-trip on known-locked items.
Per-endpoint lock checks:
| Endpoint | Checks |
|---|---|
POST /v1/drawings/{D}/layers/{L}/graphics | destination layer's layerLock |
POST /v1/drawings/{D}/layers/{L}/shapes | destination layer's layerLock |
PATCH .../graphics/{G_chain}/style | graphic's layerLock |
PATCH .../graphics/{G_chain} (bounds) | moveLock (when origin changes), sizeLock (when size changes), graphic's layerLock |
DELETE .../graphics/{G_chain} | deleteLock, graphic's layerLock |
DELETE /v1/drawings/{D}/layers/{L} | the layer's layerLock, and deleteLock on any graphic on it |
PATCH .../graphics/{G_chain}/lock | graphic's layerLock (the lock endpoint is how deleteLock / moveLock / sizeLock are set) |
The PATCH check is granular: a pure translation against a graphic with only sizeLock set is allowed, and a pure resize against a graphic with only moveLock set is allowed. The check fires only when the requested change touches the locked dimension.
Setting locks: GET / PATCH …/graphics/{G_chain}/lock reads and sets the three graphic locks (see its section); layerLock is set in the Layers table only.
Override: every lock-checking endpoint accepts the query parameter ?force=true to bypass. The token is the authorization boundary; force is the explicit "I know what I'm doing" gesture. Use it deliberately in scripts that genuinely need to write through locks (bulk cleanup, migration). The Python client exposes this as a force=False keyword argument:
ed.delete_graphic(d, l, g_uuid) # 423 if delete-locked
ed.delete_graphic(d, l, g_uuid, force=True) # always deletes
ed.morph_graphic(d, l, g_uuid, x=10, y=10, width=20, height=20, force=True)
ed.use_library_element(d, l, lib, el, force=True) # add to a locked layer
GET endpoints are never blocked by locks. Read-only by definition.
Pin is not an API lock. DKDLine (and line-like geometry) supports a UI concept called Pin that constrains interactive endpoint editing along three axes — Angle, Length, and Center — stored on DKDLock as LockFlag_Lock_Angle (0x04), LockFlag_Lock_Length (0x08), and LockFlag_Lock_Center (0x10). These are deliberately not part of API lock enforcement: pinning a line's angle does not make a rotating morph return 423, and pinning its center does not block a move. Pin is an *interactive editing aid* — it shapes how handle drags behave in the EazyDraw UI — not a formal "protect from change" lock like moveLock / sizeLock / deleteLock / layerLock. The API treats Pin as out of scope by design; a script that must respect a line's pin should read it from the graphic's /state and honor it client-side. (Mapping Pin onto API semantics — e.g. should pinned-length veto a non-uniform scale? — is intentionally not enforced by the API; it has no clean, unsurprising answer.)
200 — found and serialized (GETs); or, for POST /v1/drawings, the file was already open.201 Created — POST /v1/drawings opened a new window for a file that wasn't already open.400 Bad Request — for the recursive group-children endpoint, the UUID chain was empty. For POST /v1/drawings and POST /v1/libraries, the body was missing/malformed, the path field was missing/empty, or path was not absolute/tilde-prefixed. For DELETE /v1/drawings/{uuid} and DELETE /v1/libraries/{uuid}, missing UUID in the path (defensive — the regex normally guards this).401 Unauthorized — missing Authorization header or token doesn't match the configured value. Response includes WWW-Authenticate: Bearer realm="EazyDraw". Message: Missing or invalid bearer token.404 — no open drawing with that UUID, no layer with the given UUID inside the drawing, no library with that UUID in either _libMenus or open palette windows, or (for the recursive group-children endpoint) some UUID along the chain did not resolve / pointed at a non-group graphic. For POST /v1/drawings and POST /v1/libraries, the supplied path did not exist on disk. Body is the server's default error HTML with message Drawing not found, Layer not found, Library not found, or Group not found, or graphic is not a group; for POST endpoints the body is JSON { "error": "...", "path": "..." }.422 Unprocessable Entity — POST /v1/drawings or POST /v1/libraries: file exists but NSDocumentController could not open it, or the opened object was not the expected document subclass. JSON body carries the NSError's localizedDescription. For POST /v1/drawings/{D}/layers/{L}/graphics: library element is arrange-tool or attribute-action, or the use action otherwise produced no graphic. For PATCH /v1/drawings/{D}/layers/{L}/graphics/{G_chain}: graphic does not accept scaling and has no conversion class, source bounds are non-positive, or a nested graphic would require a class swap (top-level graphics only).423 Locked — POST /v1/drawings/{D}/layers/{L}/graphics, PATCH .../graphics/{G_chain}, or DELETE .../graphics/{G_chain}: target graphic or its layer is locked against the requested mutation. JSON body: { "error": "...", "locks": { ... } }. Retry with ?force=true to override. See Lock policy above.500 — the response encoder (_dkd_jsonResponseWithObject:) could not produce JSON. Note NSData (e.g. image bytes) is not a 500 — it is Base64-encoded via EazyDraw's jsonDataWithDictionary; a 500 here means even that fallback failed. Message: Failed to serialize JSON response. For POST /v1/drawings / POST /v1/libraries, also: open completion timed out (30s) or response build failed; JSON body.Request handlers run on a background GCD queue. All AppKit / model access is funnelled through dispatch_sync(dispatch_get_main_queue(), …) inside the shared helper _dkd_extractFromDocumentUUID:extractor:, so extractor blocks always run on the main thread and the response is serialized off-main only after the dictionary is built.
These rules apply to all endpoints and JSON keys. Deviations are legacy compatibility, not patterns to copy.
/v1/drawings/{uuid}/layers/{uuid}, never /Layers/./color-modification, not /colorModification or /color_modification./drawings (collection) ↔ /drawings/{uuid} (one). /layers ↔ /layers/{uuid}. The collection segment never appears in the singular form./v1/drawings/{uuid}/layers/{uuid}/scale, never /v1/drawings/{uuid}/scale-of-layer/{uuid}./v1 for all resource endpoints. /status is intentionally unversioned (liveness probe); if version/build semantics ever change, add /v1/status alongside rather than breaking /status.layerName, layerState, graphicsCount, graphicUUID, nameGraphic, hiddenBounds, typeName, displayName (when newly minted).graphicUUID, hiddenBounds, layerName.index, type, status, version, build, drawings, layers, graphics, x, y, width, height."uuid" (lowercase) — used at every level for the resource's own UUID"DisplayName" (PascalCase) — on the <drawing> and <library> dicts"DocumentFileName" (PascalCase) — the file path, on the <drawing> and <library> dictsNew keys at any level must be camelCase even when they sit alongside these.
{ "x", "y", "width", "height" } with NSNumber doubles. This is the API-facing format. The on-disk propertyListRepresentation may continue to use svgStringWithRectangle strings — that is a serialization detail, not the HTTP contract.NotSet, On, Off, Active. Never localized in API responses.type is a stable, locale-independent kebab-case kind (text, centered-text, image, pdf-image, group, oval, …) — match logic on it. typeName is the localized human label (the EazyDraw menu word) and is display-only. The raw Obj-C class name is never exposed in API responses. centered-text (added 2026-07-11) is the contained-text form that re-centers with its host; plain text boxes keep their value — the addition is non-breaking.[NSUUID UUIDString]. Matching against client-supplied UUIDs is case-insensitive.NSNumber unsignedInteger); coordinate/dimension values are doubles.Capabilities of EazyDraw that the API does not cover in this release. Each returns a clear error (usually 422 or 501) rather than a partial result.
/text-runs, set_text_attributes). There is no Markdown or HTML import.PATCH .../text response returns requiredSize/fits, which covers the fit loop; there is no measure-only call./annotation/text endpoint is plain-text only (no uniform overrides); size/fit is not a contract (text may follow a curve). Range styling of annotation text is available via the rich-text runs endpoints, which resolve the host graphic to its annotation (2026-07-10).PATCH .../graphics/{chain} rotation is about each graphic's geometric center. A caller-supplied pivot is not available.POST .../graphics/{uuid}/attributes is implemented for top-level layer graphics; a nested target returns 422 (like the morph nested class-swap limitation)graphicUUID (only the array form is exposed)PATCH /v1/drawings/{uuid}/properties covers the nine metadata fields onlySerialization (Optimized↔Flat) toggle, and the uuid (persistent-UUID yes/no) toggle write — accepted and ignored by the properties PATCHproperties (and the other verbatim-plist sub-resources) currently use on-disk PascalCase keys; the API does not remap them