EazyDraw

← Automation API

EazyDraw Automation API — Reference

The 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 eazydraw package on PyPI provides a Python client and a Model Context Protocol (MCP) server for AI agents over this API.

Machine-readable contract: spec/openapi.yaml (OpenAPI 3.1) describes the same surface. This document is the source of truth and the two are updated together. JSON keys are the actual on-the-wire names (e.g. lowercase uuid, DocumentFileName).

Server

The 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 localhost:52737 (configurable) in the direct-download edition, and a UNIX-domain socket in the App Store edition, which is sandboxed and cannot open a listening port. API Settings shows the port or the socket path in use, and its "Copy Claude desktop config" button emits the matching settings. The socket file is created mode 0600 in the app container (~/Library/Containers/com.dekorra.EazyDraw/Data/api.sock), so only the logged-in user can connect. Clients: curl --unix-socket <path> http://eazydraw/status, the Python client's socket_path= argument, and the MCP server's EAZYDRAW_SOCKET variable (default selects the App Store socket).

Versioning

All resource paths under /v1. The /status endpoint is intentionally unversioned (it describes the running app, not a versioned resource).

Authentication

All endpoints require a bearer token. Clients supply it in an HTTP header on every request:

Authorization: Bearer 7b3f2a9e4c1d8b6a5f0e3c2d1b8a6f4e0123456789abcdef0123456789abcdef
  • Format. 32 random bytes (SecRandomCopyBytes), lower-case hex encoded — 64 characters. Generated client-side never; only EazyDraw produces tokens.
  • Storage. Keychain item, kSecClassGenericPassword, service com.eazydraw.api, account bearer-token. Accessibility kSecAttrAccessibleWhenUnlocked. The token is not stored in the app's plist or any preferences file.
  • Comparison. Constant-time byte compare (DKDAPIKeychain constantTimeEqualsTokenA:tokenB:) on the server. The token never appears in URL query strings — only in the Authorization header — so it stays out of access logs and shell history.
  • No token → server refuses to start. If no token exists in Keychain, applyHTTPServerSettings will not bring the server up and surfaces an NSError directing the user to API Settings → Generate Token. Authenticating an absent endpoint is impossible by construction.
  • Missing or wrong header → 401. Response body is the server's default error HTML with message Missing or invalid bearer token. The response includes WWW-Authenticate: Bearer realm="EazyDraw" so HTTP clients know what scheme to use.
  • Single token. One configured token at a time, no scopes, no expiry. Regenerating in API Settings invalidates the old token immediately — in-flight clients begin receiving 401 until they update.

Python example

import requests
session = requests.Session()
session.headers["Authorization"] = f"Bearer {token}"   # token from API Settings → Copy
r = session.get("http://localhost:52737/v1/drawings")
r.raise_for_status()
print(r.json())

Threat model

The token protects against:

  • Other local processes or other user accounts on the same machine driving EazyDraw via the API.
  • Browser-side requests from a page tricked into hitting http://localhost:<port>. CORS doesn't cover this fully (extensions, misconfigurations); the bearer header a malicious page can't supply without already having stolen the token.
  • An off-host exposure later if BindToLocalhost is ever turned off (e.g. for testing) — auth is the same regardless of bind address.

The token does not protect against:

  • Code running as your user that can read your Keychain — that code can read the token directly. Localhost APIs cannot escape this in general.
  • Token leakage via shell history, log files, or accidental commits — keep tokens out of URLs and out of repo-tracked configuration files.
  • MITM on the wire when going off-host — out of scope while we are localhost-only. TLS becomes the answer if/when that changes.

Endpoints

MethodPathBody shape (200)
GET/status{ status, version, build, transport, port | socket }
GET/v1/drawings{ drawings: [ <drawing> ] }
POST/v1/drawings<drawing> — opens the file at the supplied path (asCopy for an untitled copy), or { "new": true } creates a blank drawing, optionally with a page setup (201); sent once the drawing is ready
POST/v1/measure-text{requiredSize, lineCount, lineHeight, firstBaseline, ascent, descent} — measure text with no drawing
DELETE/v1/drawings/{uuid}<drawing> — closes the drawing (200 OK)
POST/v1/drawings/{uuid}/save<drawing> — save in place, or Save As to path (the drawing is then bound to the file)
GET/v1/drawings/{uuid}/undo{canUndo, canRedo, undoActionName, redoActionName} — what Undo / Redo would do
POST/v1/drawings/{uuid}/undo / /v1/drawings/{uuid}/redo{operation, actionName, canUndo, canRedo, …} — step the undo stack one entry
GET/v1/drawings/{uuid}/selection{graphics, count} — the graphics selected in the window
PUT/v1/drawings/{uuid}/selection{graphics, count, requested} — replace the selection (empty array clears it)
GET/v1/libraries{ libraries: [ <library> ] }
POST/v1/libraries<library> — opens the library file at the supplied path
DELETE/v1/libraries/{uuid}<library> — closes the open palette window for the library (200 OK)
GET/v1/libraries/{uuid}<library>
GET/v1/libraries/{uuid}/elements[ <element> ]
GET/v1/libraries/{uuid}/stateDKDLib.propertyListRepresentation (forced flat) — full library state
GET/v1/libraries/{uuid}/elements/{uuid}/stateDKDLibElement.propertyListRepresentation — full element state
GET/v1/drawings/{uuid}<drawing>
PATCH/v1/drawings/{uuid}<drawing> — set layerSelect and/or apply a named layerConfiguration
GET/v1/drawings/{uuid}/layer-configurations[ <layer-configuration> ] — the drawing's saved layer arrangements
GET/v1/drawings/{uuid}/pages{ pageSize, drawingSize, pagesAcross, pagesDown, orientation, margins, paperSize, units, … } — page geometry
PATCH/v1/drawings/{uuid}/pagesthe GET /pages dict — set paper (name or custom size), orientation, margins, page grid (merge; one undo step)
GET/v1/drawings/{uuid}/graphics?name=…{graphics, count} — every graphic with that name, on any layer or in a group, with layerUUID and chain
GET/v1/drawings/{uuid}/pages/setupDKDPagesSpec.propertyListRepresentation
GET/v1/drawings/{uuid}/pages/layoutDKDPrintInfo.propertyListRepresentation
GET/v1/drawings/{uuid}/gridsDKDGridPair.propertyListRepresentation
GET/v1/drawings/{uuid}/propertiesDKDProperties.propertyListRepresentation
PATCH/v1/drawings/{uuid}/propertiesDKDProperties.propertyListRepresentation — set metadata (merge)
GET/v1/drawings/{uuid}/scale[[doc activeLayer] dkdScale].propertyListRepresentation
GET/v1/drawings/{uuid}/window[doc docWindowConfiguratonDictionary]
GET/v1/drawings/{uuid}/viewport{visibleRect, zoom} — what the user is looking at (on- vs off-screen)
GET/v1/drawings/{uuid}/layers[ <layer> ]
POST/v1/drawings/{uuid}/layers<layer> — create a layer at the front, active by default (201)
GET/v1/drawings/{uuid}/layers/{uuid}<layer>
PATCH/v1/drawings/{uuid}/layers/{uuid}<layer> — set layer state (on / off / active) and/or rename (layerName)
DELETE/v1/drawings/{uuid}/layers/{uuid}<layer> — delete the layer and its graphics (pre-delete dict)
POST/v1/drawings/{uuid}/layers/{uuid}/order<layer> — restack the layer: front / back / forward / backward
GET/v1/drawings/{uuid}/layers/{uuid}/scale[layer dkdScale].propertyListRepresentation
GET/v1/drawings/{uuid}/layers/{uuid}/color-modification[layer layerColorMod].propertyListRepresentation
GET/v1/drawings/{uuid}/layers/{uuid}/state[layer propertyListRepresentation] — full layer state (flat)
GET/v1/drawings/{uuid}/layers/{uuid}/graphics[ <graphic> ]
DELETE/v1/drawings/{uuid}/layers/{uuid}/graphics{deleted} — clear the layer (remove all its graphics; atomic on locks)
POST/v1/drawings/{uuid}/layers/{uuid}/graphics<graphic> — places a library element on the layer (201)
POST/v1/drawings/{uuid}/layers/{uuid}/imagesinsert content at front of layer — raster <graphic>+image, or PDF {pageCount, graphics[]} (201)
POST/v1/drawings/{uuid}/layers/{uuid}/shapes<graphic> — create a parametric shape: rectangle, rounded-rectangle, oval, polygon, text-box, line, diagram-box (201)
POST/v1/drawings/{uuid}/layers/{uuid}/shapes (shape:"path")<graphic> — create an editable cubic Bézier path from a node list (201)
POST/v1/drawings/{uuid}/layers/{uuid}/shapes (shape:"fit")<graphic> — fit a Bézier through sparse waypoints (Catmull-Rom + per-node corners/KB; smooth→continuous-bezier, cornered→bezier) (201)
POST/v1/drawings/{uuid}/layers/{uuid}/shapes (shape:"conduit")<graphic> — a band of fixed thickness along a centerline path (cap + join appearance) (201)
POST/v1/drawings/{uuid}/layers/{uuid}/groups<graphic> — group 2+ top-level graphics into a new DKDGroup (201)
POST/v1/drawings/{uuid}/layers/{uuid}/combine<graphic> — path boolean of two closed shapes: union / difference (A−B) / intersection (201)
POST/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/ungroup{graphics, count} — ungroup a DKDGroup back into its members (200)
GET/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/path (or nested){path, editable} — read a graphic's editable bézier path (nodes)
POST/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/duplicate (or nested){graphics} — duplicate a graphic (optional offset / count); fresh UUID + name each (201)
PUT/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/image (or nested)<graphic> + image meta — fill a named placeholder slot with an image
POST/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/flip (or nested)<graphic> — flip (mirror) horizontal / vertical / mirror
POST/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/order (or nested)<graphic> — z-order: front / back / forward / backward
POST/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/move-to-layer (or nested)<graphic> — move the graphic to another layer
POST/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/attributes<graphic> — apply a library attribute-action transfer (200)
PATCH/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/style (or nested)<graphic> + style — set solid fill / stroke (color & style)
GET/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/style (or nested){fill, stroke} — read current fill / stroke
GET/v1/dash-patterns{dashPatterns} — the builtin named dash catalog (name + pattern)
PATCH/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/dash (or nested){dash, dashable} — set the line dash (builtin name / PDF pattern / none)
GET/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/dash (or nested){dash, dashable} — read the current line dash
PATCH/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/shadow (or nested){shadow, shadowable} — set the drop shadow (color/drop/angle/blur, or none)
GET/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/shadow (or nested){shadow, shadowable} — read the current drop shadow
PATCH/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/gradient (or nested){gradient, gradientable} — set the gradient fill (linear/radial stops, or none)
GET/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/gradient (or nested){gradient, gradientable} — read the current gradient fill
GET/v1/pattern-sets{sets} — the builtin pattern/texture catalog (named sets of named tiles)
PATCH/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/pattern (or nested){pattern, patternable} — set the pattern fill (builtin set+name, or none)
GET/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/pattern (or nested){pattern, patternable} — read the current pattern fill
GET/v1/hatch-patterns{hatchPatterns} — the builtin vector-hatch catalog (named hatches)
PATCH/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/hatch (or nested){hatch, hatchable} — set the vector hatch fill (builtin name + angle/scale/double, or none)
GET/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/hatch (or nested){hatch, hatchable} — read the current vector hatch fill
GET/v1/arrow-forms{arrowForms} — the builtin arrow-head catalog (named forms)
PATCH/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/arrow (or nested){arrow, arrowable} — set the line-end arrow (builtin form + ends/size/angle, or none)
GET/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/arrow (or nested){arrow, arrowable} — read the current line-end arrow
GET/v1/crossover-styles{crossoverStyles, positions, directions} — the builtin crossover catalog
PATCH/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/crossover (or nested){crossover, crossoverable} — set the crossover symbol (style + position/direction, or none)
GET/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/crossover (or nested){crossover, crossoverable} — read the current crossover
PATCH/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/brush (or nested){brush, brushable} — set a variable-line-width stroke (width profile or artistic outline, or none)
GET/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/brush (or nested){brush, brushable} — read the current brush
PATCH/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/conduit (or nested){conduit, conduitable} — change a conduit's spec (thickness / join / caps; only given fields)
GET/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/conduit (or nested){conduit, conduitable} — read a conduit's spec
POST/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/offset (or nested){graphics} — parallel-offset a path left / right / both by distance (201)
PATCH/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/name (or nested)<graphic> — set a graphic's name (409 if taken; uniquify to auto-suffix)
GET/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/lock (or nested){locks, accepts} — which locks are set and which the graphic can take
PATCH/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/lock (or nested)<graphic> — set / clear deleteLock, moveLock, sizeLock (Format ▸ Lock)
PATCH/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid} (or nested)<graphic> — set absolute bounds (position + size) and/or rotation
GET/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid} (or nested)<graphic> — single graphic dict
DELETE/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid} (or nested)<graphic> — pre-delete dict; removes the graphic
GET/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/graphics[ <graphic> ] — children of the named DKDGroup
GET/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/graphics/{uuid}/graphics (and deeper)[ <graphic> ] — children of the deepest named group
GET/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/text (or nested)<text> — DKDTextArea content + layout metrics
PATCH/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/text (or nested)<text> — set text + uniform style overrides
PATCH/v1/drawings/{uuid}/layers/{uuid}/texts{results, count, failed} — set many text boxes in one call, one undo step
POST/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/text/measure (or nested)measure text in the box without changing it: {requiredSize, fits, lineCount, …}
GET/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/text/runs (or nested)<runs> — text + normalized attribute runs
PUT/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/text/runs (or nested)<runs> — replace styling wholesale from a runs array (round-trip)
PATCH/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/text/attributes (or nested)<runs> — apply range attribute operations (non-destructive)
POST/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/text (or nested)<runs> — Insert Text: create contained text on a shape (201)
DELETE/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/text (or nested)<graphic> — Disconnect Text: detach contained text into a standalone box
GET/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/annotation (or nested)<annotation> — host graphic's annotation state
PATCH/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/annotation/text (or nested)<annotation> — set annotation plain text
GET/v1/drawings/{uuid}/export/{format}bytes — full drawing rendered; ?page=N for one page (pdf/png/jpg)
GET/v1/drawings/{uuid}/layers/{uuid}/export/{format}bytes — that layer alone on the page (see notes)
GET/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/export/{format} (any nesting depth)bytes — single graphic at its bounds
GET/v1/libraries/{uuid}/export/{format} / /v1/libraries/{uuid}/elements/{uuid}/export/{format}501 Not Implemented

UUID matching is case-insensitive against [doc documentUUID], [layer layerUUID], and [graphic graphicUUID].

Graphics by name. Any {uuid} segment that names a graphic may instead be name:<nameGraphic> (exact, case-sensitive; URL-encode it, e.g. name:county%20seat). It resolves within its container at that level of the chain: the layer's top level, or the group named by the previous segment. A name held by more than one graphic in that container is 409 (address it by UUID); a name containing / cannot be used in a path. To find a graphic anywhere in the drawing, GET /v1/drawings/{D}/graphics?name=… (see below).

Chains are containment, not lists. …/graphics/{A}/graphics/{B} means B inside group A. To act on two sibling graphics, make two calls; a sibling given as the second segment is 404. The Python client's chain methods (delete_graphic(du, lu, a, b), graphic, morph_graphic, set_text, …) build exactly this path from their UUID arguments.

Recursive group traversal

The path grammar for descending into nested groups is:

/v1/drawings/{D}/layers/{L}/graphics/{G1}/graphics(/{Gn}/graphics)*

Each {Gn} after the first must be a child of the preceding group's groupArray. Every {Gn} along the chain — including the last one whose children are returned — must resolve to a DKDGroup (or subclass). Nesting is unlimited; the same shape applies at every depth.

The terminal segment is always /graphics, which returns the array of immediate children. To check whether a graphic has children before recursing, look at graphicsCount in the parent's listing: present (any value, including 0) ⇒ it is a group and the recursive endpoint will resolve; absent ⇒ it is a leaf graphic and the recursive endpoint will 404. An empty group correctly returns [], distinct from a 404 for "not a group".

<drawing> shape

{
  "uuid":             "<DKDDocument documentUUID>",
  "DisplayName":      "<NSDocument displayName>",
  "DocumentFileName": "<doc.fileURL.path or empty string>",
  "layerSelect":      "active-only | show-others | select-others | show-all | select-all",
  "pageSize":         { "width": 612, "height": 792 },
  "drawingSize":      { "width": 612, "height": 792 }
}

layerSelect is the document's layer‑visibility mode (DKDDocument.layerSelect, reported as a kebab name; advanced directional modes report other). See Layer visibility below.

pageSize is one page and drawingSize the whole page grid (pages across × pages down), both in internal points with the origin at the top-left of page 1 — enough to centre content without a second call. The full page geometry (orientation, margins, units) is GET /v1/drawings/{D}/pages. The same dict is returned by GET /v1/drawings (the list), GET /v1/drawings/{uuid}, and the open / new / close responses.

If a document has no UUID at the time it is requested via /v1/drawings or /v1/drawings/{uuid}, one is assigned via [docProperties setAPI_UUID:YES] so subsequent calls are stable.

Open drawings never share a UUID (uniquify on open). A file copy made outside EazyDraw (Finder duplicate, backup restore) carries the same stored UUID as its source. When such a file is opened while another open document already holds that UUID, the newly opened document is assigned a fresh UUID at load time — the earlier-opened document keeps the stored one — so /v1/drawings/{uuid} addressing is always unambiguous. The fresh UUID persists when that document is next saved. A file opened with no collision always keeps its stored UUID. The open response then carries "documentUUIDReassigned": true (also on an open-as-copy), so a client knows the stored UUID was replaced.

GET /v1/drawings/{D}/viewport — what the user is looking at

Returns the drawing window's current visible region and zoom, so a client can talk about what the user actually sees instead of narrating graphics scrolled out of view.

{ "visibleRect": { "x": 0, "y": 0, "width": 612, "height": 460 }, "zoom": 175.0 }

visibleRect is in internal points — the same space and Y-down orientation as a graphic's hiddenBounds, and already zoom-adjusted (zoom in → a smaller visibleRect). So a graphic is on screen iff its hiddenBounds intersects visibleRect; one whose bounds fall outside (e.g. below the visible area) is scrolled out of view. zoom is the percent (100 = 1:1). The visible rect comes from the scroll view's documentVisibleRect — the same value updateOnScreenGraphics uses internally.

GET /v1/drawings/{D}/pages — page geometry

The page as a coordinate frame: what an agent needs to place content on it. No query parameters.

{
  "pageSize":           { "width": 612, "height": 792 },   // one page, points, oriented
  "pagesAcross":        1,
  "pagesDown":          1,
  "numberAcrossFirst":  true,                              // page order: fill a row, then go down
  "drawingSize":        { "width": 612, "height": 792 },   // pagesAcross × pagesDown pages
  "orientation":        "portrait",                        // "portrait" | "landscape"
  "paperName":          "na-letter",                       // when the print info has one
  "paperSize":          { "width": 648, "height": 828 },   // the printed sheet: page + margins on a real printer
  "printerName":        "Office LaserJet",
  "virtualPrinter":     false,                             // true: page = paper, margins do not shrink it
  "margins":            { "left": 18, "right": 18, "top": 18, "bottom": 18 },  // printer margins, points
  "units":              "Inches",                          // document units (active layer's scale)
  "unitsAbbreviation":  "in",
  "pointsPerUnit":      72,
  "pageSizeInUnits":    { "width": 8.5, "height": 11 },
  "drawingSizeInUnits": { "width": 8.5, "height": 11 }
}

Sizes in points are in the internal points space — the same frame as a graphic's hiddenBounds and the viewport's visibleRect: page 1's top-left corner is (0, 0), Y grows downward, and the pages tile across then down, so the whole drawing is drawingSize with its origin at (0, 0). The centre of a one-page drawing is therefore (pageSize.width / 2, pageSize.height / 2). margins are the print margins from DKDPrintInfo (not enforced on drawing). units is the un-localized name (Inches, Centimeters, Points, …) from DKDUnits, taken from the active layer's scale; pointsPerUnit converts.

Coordinates, margins and export. Every position the API reads or writes (hiddenBounds, shape bounds, morph, insert x/y) is in this page frame: page 1's top-left corner is (0, 0) whatever the margins. On a real printer the EazyDraw page is the printable area and paperSize is the sheet (page + margins); printing places the page inside the margins. A PDF export is the page itself, edge to edge, on every printer: margins never shift exported content. (Before this release a real-printer drawing exported onto the whole sheet, the page inset by the left/top margin.) For a form, PATCH /pages with a paperSize makes page = paper.

Sources: DKDDocument pageSize / documentSize / pageSizeInDocumentUnits, DKDPagesSpec pagesAcross / pagesDown, DKDPrintInfo orientation / paperName / margins. The raw model dicts remain at pages/setup and pages/layout. 404 when the drawing is not open.

MCP: get_page(drawing?) returns this dict verbatim.

MCP convenience tool. place_on_page(name, anchor="center", dx=0, dy=0, respect_margins=True) uses this geometry to move a named graphic to a page position: anchor is center, top, bottom, left, right, or a corner (top-left, top-right, bottom-left, bottom-right). The frame is the drawing area inset by the print margins (respect_margins=False uses the paper edge); dx/dy then offset from the anchor in points (dy grows downward). The graphic keeps its size, moveLock/layerLock are honored (force=True overrides), and the new bounds are returned. It is built on GET /pages plus the morph endpoint; there is no separate wire endpoint.

PATCH /v1/drawings/{D}/pages — page setup

Sets the paper, orientation, margins and page grid. Merge semantics: only the keys sent change. One undo step, "Change Page Setup". Returns the GET /pages dict after the change.

{
  "paperSize":         { "width": 701, "height": 596 },   // custom paper, points — or:
  "paperName":         "US Letter",                       // one of the printer's papers
  "orientation":       "landscape",                       // "portrait" | "landscape"
  "margins":           { "left": 0, "top": 0 },           // any of left, right, top, bottom
  "pagesAcross":       1,                                 // 1–100
  "pagesDown":         2,                                 // 1–100
  "numberAcrossFirst": true                               // page order
}
  • paperSize makes the page exactly that size: the drawing switches to the Prepress virtual printer with a Custom paper (what an imported PDF or SVG gets), so margins do not shrink the page and virtualPrinter reads true. Orientation follows the shape; an explicit orientation swaps width and height to match. 1–14400 points each.
  • paperName must be one of the current printer's paper names (GET /pages reports the current one); an unknown name is 422 and the message lists the valid names. Switching between a screen-size paper and a print paper resets the margins and orientation to the printer's defaults, as the Page Setup panel does; margins given in the same call win.
  • paperName and paperSize together are 400, as are an unknown key, a non-numeric or negative margin, and a page count outside 1–100.

Paper first, then orientation, then margins, then the grid. The view, rulers and the Pages panel follow. new_drawing / POST /v1/drawings with "new": true takes the same keys, so a form master can be built from nothing.

Python: set_page_setup(du, paper_size=(701, 596), pages_down=2, margins={...}). MCP: set_page_setup(...); new_drawing(name, paper_size=, pages_across=, pages_down=, margins=).

GET /v1/drawings/{D}/graphics?name=… — find graphics by name

Every graphic whose nameGraphic equals name, on every layer and inside groups (depth first):

{ "graphics": [ { …<graphic>…, "layerUUID": "…", "layerName": "Fields",
                  "chain": ["<group uuid>", "<graphic uuid>"] } ],
  "count": 1 }

chain is the UUID path from the layer's top level down to the graphic, ready for …/graphics/{chain…}. Names are unique among a UUID drawing's top-level graphics (see Graphic naming model), but graphics inside groups and in older drawings can share a name, hence the list. 400 without name; 404 when the drawing is not open. Often simpler: put name:<name> straight into a path.

Python: graphics_by_name(du, name) (list), graphic_by_name(du, name) (one; 404 / 409 otherwise), and name_ref(name) for a path segment.

POST /v1/drawings — open a file, or create a blank one

Create a blank drawing — send { "new": true } (optionally with a "name") instead of a path:

{ "new": true, "name": "Site Plan" }

This is File ▸ New: NSDocumentController openUntitledDocumentAndDisplay: puts a new untitled drawing on screen with the user's default page setup (one page, one active layer, the default units). name only titles the window (NSDocument setDisplayName:); the drawing has no file (DocumentFileName is "") and is unsaved until the user saves it — autosave-in-place applies only to documents that already have a file, so API edits to a new drawing live in the window until then. Response is 201 Created with the <drawing> dict (including pageSize, so the caller can place content immediately). 500 if the document controller could not create the document. When new is true the path field is ignored.

The body may also carry the page setup keys of PATCH /pages (paperSize, paperName, orientation, margins, pagesAcross, pagesDown, numberAcrossFirst); they are part of the new drawing, not an undo step. A bad value is the same 400 / 422 as PATCH /pages, and no window is left open.

MCP: new_drawing(name?, paper_size?, pages_across?, pages_down?, margins?) — the answer to "create a new drawing"; it also becomes the active drawing.

Open a file — send a path:

Opens any file type EazyDraw can read (drawings in .ezdraw, .ezddata, .ezdjson; importable .svg, .pdf, .dxf, image formats, etc. — anything the app's CFBundleDocumentTypes declares). The result is a drawing window on screen, the same as a File → Open from the menu.

Import sizes the page to the content. When the file is an importable (non-EazyDraw) type, opening creates a new drawing whose page geometry matches the content: a PNG/JPEG/TIFF becomes a one-page drawing the size of the image; an SVG a page the size of the artwork; a multi-page PDF a drawing with one page per PDF page, at the PDF's page size (DKDDocBitmapImageOperation / DKDDocPDFOperation / DKDDocSVGOperation). This is the open as a new drawing path; the complement — dropping content into an existing drawing without changing its page geometry — is POST /v1/drawings/{D}/layers/{L}/images.

Request body (Content-Type: application/json recommended; the handler reads the body bytes directly so the header is not strictly required):

{
  "path": "/absolute/path/to/file.ezdraw"
}

path is a required string. Tilde expansion is performed (~/Documents/... is fine). Relative paths are rejected — there's no well-defined "current directory" for the API.

Open as a copy — add "asCopy": true:

{ "path": "~/Forms/VS-4 master.ezdraw", "asCopy": true }

Opens an untitled duplicate of the file (NSDocumentController duplicateDocumentWithContentsOfURL:copying:YES …) with a fresh document UUID (documentUUIDReassigned: true; layer and graphic UUIDs and names are kept, so names stay the stable handle). The file on disk is never touched or marked edited — the way to fill a master form, since a drawing opened normally autosaves its edits into its file. Always 201; DocumentFileName is "" until the copy is saved somewhere.

Ready when it answers. The response is not sent until the drawing's window is on screen, its visible layers have finished loading, and any layers that were hidden when it was saved are loaded too (they otherwise arrive lazily seconds later); the dict carries "ready": true. A drawing that is still not ready after about 10 seconds is answered anyway with "ready": false. No polling is needed after open_drawing; the same applies to { "new": true }.

Success responses carry the same <drawing> dict shape /v1/drawings/{uuid} returns, so the response is immediately usable in subsequent GETs:

  • 201 Created — file was not previously open; a new drawing window is now on screen.
  • 200 OK — file was already open in EazyDraw; the existing window is returned (no new window is created, no in-flight changes are disturbed). Matches NSDocumentController openDocumentWithContentsOfURL:display:completionHandler: semantics: documentWasAlreadyOpen == YES.

Error responses are JSON { "error": "...", "path": "..." } (path included when relevant):

  • 400 Bad Request — empty body, body is not JSON, body is not a JSON object, missing/empty path field, or path is not absolute / tilde-prefixed.
  • 404 Not Found — path resolves but no file exists there.
  • 422 Unprocessable Entity — file exists but NSDocumentController could not open it (unknown type, corrupt, unsupported version). The NSError's localizedDescription is returned in error. Sandboxed (App Store) build: a permission failure (NSFileReadNoPermissionError / EACCES) is the container boundary, not a bad file — a path-based open never gets the security-scoped access the Open panel mints. The response then adds "reason": "sandbox" and "reachable": [ … ], the folders the API can open from (the app container's Documents and the iCloud Drive container's Documents, when present), and the error text says to copy the file there or open it from EazyDraw's File menu (files the user opened are always addressable via GET /v1/drawings). The direct build never sets these keys.
  • 500 Internal Server Error — open completion did not fire within 30 seconds, or returned a document that was not a DKDDocument, or response build failed. The 30-second timeout is generous for typical drawings; extremely large drawings can theoretically exceed it.

Threading note. The handler dispatches openDocumentWithContentsOfURL:display:completionHandler: onto the main thread and waits on a dispatch_semaphore for the async completion. The request thread blocks until the window is up. Typical opens complete in well under a second; the 30-second timeout exists only as a safety net.

Python example:

import requests
session = requests.Session()
session.headers["Authorization"] = f"Bearer {token}"

r = session.post("http://localhost:52737/v1/drawings",
                 json={"path": "~/Documents/sketch.ezdraw"})
r.raise_for_status()
drawing = r.json()
print(drawing["uuid"], "was already open" if r.status_code == 200 else "newly opened")

DELETE /v1/drawings/{uuid} — close a drawing

Closes the drawing identified by {uuid} and removes its window from the screen. No request body, no query parameters.

Success — 200 OK. Body is the <drawing> dict for the drawing that was just closed. The dict is built before [doc close] is called so the response is complete. The UUID will no longer resolve in subsequent GETs.

Errors:

  • 400 Bad Request — missing UUID in path (should not happen given the regex, but handled defensively).
  • 401 Unauthorized — missing/bad bearer token.
  • 404 Not Found — no open drawing matches the supplied UUID. Body: { "error": "Drawing not found" }.
  • 500 Internal Server Error — response build failed.

macOS autosave note. DKDDocument autosaves changes in place (the standard modern NSDocument behavior). Edits made between open and close are typically already on disk by the time the close request runs. The API therefore does not offer a "close without saving" mode — there is nothing reliable to discard. To roll a drawing back to a prior state, use EazyDraw's File → Revert To before closing. An earlier revision of this endpoint accepted ?force=true to gate dirty-document behavior; testing showed the dirty check is essentially never true under autosave-in-place, so the flag has been removed.

Threading. The walk-and-close runs entirely inside one dispatch_sync(dispatch_get_main_queue(), ...) block: find the window, build the dict, and call [doc close] atomically with respect to other API requests. [doc close] is synchronous — when it returns, the window controllers are gone and the document is removed from NSDocumentController.

curl:

curl -i -X DELETE -H "Authorization: Bearer $TOKEN" \
  http://localhost:52737/v1/drawings/$UUID

Python:

r = session.delete(f"http://localhost:52737/v1/drawings/{uuid}")
r.raise_for_status()

POST /v1/drawings/{D}/save — save, or Save As

Saves the drawing. With no body (or no path) it saves in place, which needs a drawing that already has a file — a blank drawing from POST /v1/drawings {"new": true} gets 422 until it is saved with a path. With a path it is Save As: the drawing is bound to that file from then on (and autosaves there, as any file-backed drawing does).

Request body (optional):

{
  "path":      "<absolute path, or ~/…>",   // Save As; omit to save in place
  "overwrite": <Bool>                        // default false
}

The extension chooses the format: .ezdjson (EazyDraw JSON, the default for new drawings) or .ezdraw / the binary form; no extension appends the drawing's current one; any other extension is 400. If a different file already exists at path the call is 409 unless overwrite is true (saving over the drawing's own file is not a conflict). The save runs through NSDocument's saveToURL:ofType:forSaveOperation:completionHandler: on the main thread; the request waits for the completion (60 s timeout).

Success — 200 OK. Body is the <drawing> dict, DocumentFileName now set to the saved path.

Errors: 400 bad path / extension; 404 drawing not found; 409 file exists (see above); 422 untitled drawing saved without a path, or the document refused the save — in the sandboxed build a path outside the app's own folders is a 422 with "reason": "sandbox" and "reachable" listing the folders it can save into (the same shape as the open error).

Python: ed.save_drawing(d, path="~/Documents/plan.ezdjson"), ed.save_drawing(d) in place. MCP: save_drawing(path?, overwrite?).

Undo and redo — GET / POST /v1/drawings/{D}/undo, POST /v1/drawings/{D}/redo

Every mutating API call runs inside its own discrete undo group (see Threading), so one POST …/undo reverses exactly one API call (or one user step, if that is what is on top of the stack). Redo replays the last undone step. Text editing in progress is ended first.

  • GET …/undo → { "canUndo", "canRedo", "undoActionName", "redoActionName" } — what the next Undo / Redo would do (the action names the Edit menu would show, e.g. "Set Text", "Lock", "Delete Layer"). Read-only.
  • POST …/undo / POST …/redo → the same four keys after the step, plus "operation": "undo" | "redo" and "actionName", the name of the step that was just reversed / replayed. No body.

Errors: 404 drawing not found; 422 nothing to undo / redo.

Python: ed.undo_state(d), ed.undo(d), ed.redo(d). MCP: undo(), redo().

Selection — GET / PUT /v1/drawings/{D}/selection

The selection in the drawing's window: what the user is pointing at ("make these blue"), or what a script wants to show the user ("here is the graphic I mean").

  • GET …/selection → { "graphics": [ … ], "count": N } in selection order. Each entry is the standard <graphic> dict plus layerUUID and layerName, so it can be addressed without a second lookup.
  • PUT …/selection with { "graphics": [ graphicUUID, … ] } replaces the selection with those top-level graphics (any layer); an empty array clears it. The response is the resulting selection, as GET returns it, plus requested (how many UUIDs were sent): a graphic the view would not select — its layer is out of reach under the current layerSelect mode — is simply absent, so compare count with requested. Undoable in the usual UI sense (a selection change registers with the undo manager only when the document is edited, as it does from the mouse).

Errors: 400 body / array shape; 404 drawing not found, or a UUID that is not a top-level graphic of any layer (nothing is changed in that case).

Python: ed.selection(d), ed.set_selection(d, [uuid, …]). MCP: get_selection(), select(names).

<library> shape

{
  "uuid":             "<DKDLib.libraryUUID>",
  "DisplayName":      "<DKDLib.titleLib>",
  "DocumentFileName": "<DKDLib.fullFilePathLib>",
  "inMenu":           <Bool>,
  "paletteOpen":      <Bool>,
  "elementsCount":    <NSUInteger>
}

elementsCount is [[lib libCollectionElement] count] — the number of DKDLibElement items in the library's root collection. Mirrors graphicsCount on <layer> (libraries have no layer level, so this sits directly on the library).

The <library> shape mirrors the <drawing> shape's first three keys (same key names — uuid, DisplayName, DocumentFileName — for consistency across resource types) and adds two state booleans:

  • inMenu — true when the library is in _libMenus, the AppDelegate's array of active menu-system libraries (built-ins the user hasn't deactivated, plus user-added libraries currently on the Library menu).
  • paletteOpen — true when at least one window's controller is a DKDLibPalette whose dkdLib matches this library. Library palette windows are NSPanels and may be hidden when EazyDraw is not the active app; this flag reflects controller presence and is not filtered by [NSWindow isVisible], so the result is stable regardless of EazyDraw's foreground state.

A library can have any combination of the two flags set: in-menu only, palette-open only, or both. The collection is deduplicated so each library appears at most once. Dedup keys, in order of preference: pointer identity of the DKDLib instance, then fullFilePathLib string equality.

The 6 built-in libraries (Math, Charting, Stellate, Tools, Technical, CharacterBuilderLibrary) are returned only when active in the menu system; deactivated built-ins do not appear unless their palette window is open.

If a library has no UUID at the time it is requested, one is assigned via [lib setLibraryUUID:] so subsequent calls are stable.

POST /v1/libraries — open a library file

Opens an EazyDraw library file (.ezdrawlib, .ezddatalib, .ezdrawjsonlib) and brings up its palette window. The handler is structurally identical to POST /v1/drawings — NSDocumentController openDocumentWithContentsOfURL:display:completionHandler: routes by UTI to DKDLibDocument, whose -makeWindowControllers instantiates the DKDLibPalette and shows it.

Request body: same shape as POST /v1/drawings.

{
  "path": "/absolute/path/to/my.ezdrawlib"
}

path is required; tilde-expanded; absolute (or tilde-prefixed). Relative paths are rejected.

Success responses return the same <library> dict shape as /v1/libraries entries:

  • 201 Created — file was not previously open; a new palette window is now on screen.
  • 200 OK — file was already open in EazyDraw (matched by URL); the existing palette is returned. Same documentWasAlreadyOpen semantics as POST /v1/drawings.

The returned <library> dict reflects current state:

  • inMenu: true only if the library happens to already be in _libMenus (the active menu-system list). Opening via POST /v1/libraries does not add the library to the menu — the API only opens the palette. Use the existing Library menu UI ("Add to Menu") if menu membership is desired.
  • paletteOpen: true (the palette is what we just opened or refocused).

Errors: same matrix as POST /v1/drawings:

  • 400 — missing/malformed body, missing/empty path, relative path.
  • 404 — file does not exist on disk at the supplied path.
  • 422 — file exists but NSDocumentController couldn't open it (wrong UTI, corrupt library, or the opened object wasn't a DKDLibDocument). JSON body carries the NSError's localizedDescription.
  • 500 — open completion timed out (30s safety net) or response build failed.

curl:

curl -i -X POST -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"path":"~/Library/Application Support/EazyDraw/MyMenuLibraries/Math.ezdrawlib"}' \
  http://localhost:52737/v1/libraries

Python:

r = session.post("http://localhost:52737/v1/libraries",
                 json={"path": "~/Documents/widgets.ezdrawlib"})
r.raise_for_status()
lib = r.json()
print(lib["uuid"], "newly opened" if r.status_code == 201 else "already open")

DELETE /v1/libraries/{uuid} — close a library palette

Closes the open palette window for the library identified by {uuid} and removes it from the screen. No request body, no query parameters.

Scope. This endpoint operates only on the palette window. It does not mutate _libMenus (the active menu-system list). Menu membership is user-managed state — added or removed via the EazyDraw Library menu UI — and the API does not touch it. If you DELETE a library that has both a menu entry and an open palette, only the palette is closed; the library remains accessible via the menu and continues to appear in GET /v1/libraries with inMenu: true, paletteOpen: false.

Lookup. The handler resolves {uuid} against both sources in order: _libMenus first, then open palette windows. Once the DKDLib is found, the matching palette is located by pointer equality first, then by fullFilePathLib equality — the path fallback handles the case where a menu entry and a palette-only entry refer to the same file as two different DKDLib instances (the same dedup logic GET /v1/libraries uses to coalesce them into one response entry).

Idempotent. Calling DELETE on a library that has no palette open (menu-only) is a no-op and returns 200 OK with the dict. Safe to retry.

Success — 200 OK. Body is the <library> dict for the library, built before [libDoc close] is called, so the paletteOpen field reflects pre-close state. The UUID continues to resolve via subsequent GETs as long as the library remains in _libMenus.

Errors:

  • 400 — missing UUID in path (defensive).
  • 401 — auth.
  • 404 — {uuid} did not match any library in either _libMenus or any open palette. Body: { "error": "Library not found" }.
  • 500 — response build failure.

macOS autosave note. Same as for DELETE /v1/drawings/{uuid}: DKDLibDocument autosaves in place, so any edits made via the palette flow to disk before close. No force/dirty machinery. To roll a library back, use Revert To.

Threading. Lookup, dict build, and [libDoc close] all run inside one dispatch_sync(dispatch_get_main_queue(), ...) block — atomic against other API requests. libDoc is obtained via [palette document] (the standard NSWindowController accessor).

curl:

curl -i -X DELETE -H "Authorization: Bearer $TOKEN" \
  http://localhost:52737/v1/libraries/$UUID

Python:

r = session.delete(f"http://localhost:52737/v1/libraries/{uuid}")
r.raise_for_status()

<element> shape (entry in /v1/libraries/{uuid}/elements)

Every element entry carries these common fields:

{
  "index":       <NSUInteger>,
  "uuid":        "<DKDLibElement.libraryUUID>",
  "elementType": "graphic" | "create-tool" | "arrange-tool" | "attribute-action",
  "nameElement": "<DKDLibElement.nameElement>"   // omitted if nil
}

elementType is the API-facing kebab-case kind, not the internal DKDLib*Element class name. The four values map to the underlying DKDLibElement subclasses (and the attribute-action exception) as follows:

elementType: "graphic"

The element is a DKDLibGraphicElement whose embedded graphic is not an attribute-transfer template. Adds the same per-graphic fields used in /v1/drawings/{D}/layers/{L}/graphics, with two omissions specific to library graphics:

{
  ...common fields...,
  "elementType":   "graphic",
  "graphicUUID":   "<inner DKDGraphic.graphicUUID>",
  "type":          "<stable kind: text|image|pdf-image|group|...>",
  "typeName":      "<localized display name, e.g. Text>",
  "nameGraphic":   "<...>",        // omitted if nil
  "graphicsCount": <NSUInteger>    // present iff inner graphic is DKDGroup
}

Omitted compared to the layer-level graphic shape: hiddenBounds — not meaningful in a library context.

The element-level index here is the position in the library's elements array. The graphic itself does not get a separate index field; it has only one slot inside the element.

elementType: "attribute-action"

The element is a DKDLibGraphicElement whose embedded graphic represents an attribute-transfer template rather than a drawable shape. Detected as: graphic is not a DKDGroup, has a non-nil dkdTransfer, and that transfer has at least one of these scope flags set: brushScopeTransfer, shadowScopeTransfer, gradientScopeTransfer, hatchScopeTransfer, dashesScopeTransfer, patternScopeTransfer, arrowsScopeTransfer, colorAndStyleScopeTransfer, dimensionScopeTransfer.

{
  ...common fields...,
  "elementType": "attribute-action"
}

Currently only the kind label is exposed. A future endpoint will report which transfer scopes are active for a given attribute-action element.

elementType: "create-tool"

The element is a DKDLibCreateToolElement — a graphic-creation tool. Adds the API type token of the graphic the tool produces:

{
  ...common fields...,
  "elementType": "create-tool",
  "toolType":    "<stable kind the tool creates, e.g. rectangle>"
}

elementType: "arrange-tool"

The element is a DKDLibArrangeToolElement — an arrange/align/distribute action tool. Adds the localized tool name resolved via [DKDArrangePalette arrageToolNameWithTag:] from the element's arrangeButtonTag:

{
  ...common fields...,
  "elementType": "arrange-tool",
  "toolName":    "<localized arrange-tool title, e.g. \"Align Left Edges\">"
}

If a DKDLibElement is encountered that is none of the three known subclasses, elementType falls back to the kebab-cased class name (the DKD prefix dropped) so the response remains well-formed.

If an element has no UUID at the time it is requested, one is assigned via [el setLibraryUUID:] so subsequent calls are stable.

POST /v1/drawings/{D}/layers/{L}/graphics — place a library element

Adds a new graphic to the layer by invoking the library element's use action — the same operation the Use button in a DKDLibPalette performs. The source is identified by libraryUUID + elementUUID in the body.

Request body:

{
  "libraryUUID": "<DKDLib.libraryUUID>",
  "elementUUID": "<DKDLibElement.libraryUUID>"
}

Both fields are required, non-empty strings. The library is located by walking _libMenus first, then open palette windows (same dual-source lookup as GET /v1/libraries).

Allowed element types:

elementType (from GET /v1/libraries/{L}/elements)Behavior
graphicA copy of the library's embedded graphic is placed on the target layer.
create-toolA 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-toolRejected with 422. Arrange tools don't place graphics.
attribute-actionRejected 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 shape

Creates 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.

shapeGeometryParams
rectanglebounds—
rounded-rectangleboundscornerRadius
ovalbounds—
arcboundsstartAngle, endAngle (degrees, 0 = right / 3 o'clock), arcType (arc / pie), clockwise (default true) — circular arc inscribed in the box
polygonboundssides (≥3), orientation (degrees) — regular, inscribed in the box
text-boxboundsstring
linestart, end—
polylinepoints— (connected straight segments)
pathnodesclosed — an editable cubic Bézier path (see Curve Tools)
diagram-boxboundsfigure, 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} ] }

Curve Tools — editable Bézier paths

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).

Node model

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"
}
  • Handles are absolute coordinates — the source of truth, and what guarantees the round-trip.
  • A handle coincident with 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.
  • Client shortcut (not the wire): the Python client's 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.
  • Open-path caveat: 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):

  • No corners anywhere → a smooth DKDContinuousBezier (type: "continuous-bezier") — editing in EazyDraw keeps it smooth.
  • Any corner → a generalized 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).

Conduit — a thick band along a path

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 graphic

Returns 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 graphic

Removes 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).

The chain is containment. …/graphics/{A}/graphics/{B} deletes B inside group A — not "A and B". To delete two siblings, make two calls; a sibling passed as the second segment is 404. (Python: delete_graphic(du, lu, a, b) builds exactly that path.)

Container handling:

  • Top-level layer graphic (chain length == 1): removed via [doc removeGraphic:atIndex:] which handles connection cleanup, contained text pairs, layer panel sync, and undo registration.
  • Nested in a group (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 graphic

Duplicates 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_01", the app's uniqueName() convention; then _02, …).

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 transfer

{G} takes a name: segment like every other graphic segment (fixed 2026-10-03: the handler had its own uuid-only lookup and answered 404 to a name; it now uses the shared resolver, so an ambiguous name is 409 and the 404 text is the resolver's).

Applies 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 & stroke

Sets 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 & stroke

Returns 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 catalog

Lists 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 dash

Sets 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 dash

Returns { "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 shadow

Sets 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 translucent
    • drop — offset distance, points
    • angle — 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 shadow

Returns { "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 fill

Sets 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 fill

Returns { "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 catalog

Returns { "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 fill

Sets 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 fill

Returns { "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 catalog

Returns { "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 fill

Sets 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 fill

Returns { "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 catalog

Returns { "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 arrow

Puts 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 head
    • size (optional) — the head size
    • angle (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 head
    • shift (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 arrow

Returns { "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 catalog

Returns 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 symbol

Sets 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 modes
    • size — { "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 crossover

Returns { "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.

Brush — variable-line-width stroke (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 graphic

Sets 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.

Graphic naming model

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.

  • UUID drawing → every graphic introduced to it (created, pasted, duplicated, or placed via the API) gets both a unique UUID and a unique name. A unique user-/client-supplied name is respected; a graphic with no name is auto-named from its localized typeNameForMenu ("Rectangle", "Text", …); and any name that collides is uniquified with the app's _01, _02, … suffix ("Rectangle_01") — so a paste/duplicate (which copies the source's name) becomes "ceo-photo_01", not a second "ceo-photo". (Layer names uniquify differently: -2, -3, see Rename.) 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).
  • Legacy drawing (no 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 locks

The 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-order

Changes 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 layer

Moves 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 graphic

Mirrors 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, or a PDF with "page" — the graphic's hiddenBounds origin
  "page":        <Number>,    // PDF only — 1-based: insert just this page
  "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.

  • Success 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 in the drawing's own page order (numberAcrossFirst): page i → drawing page i, so in a 1 × 2 drawing page 2 lands below page 1. (Until this release across-first numbering put page 2 beside page 1, off the page.) Pages past the current grid extend beyond it — growing the page count is the caller's call (PATCH /pages). 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.

One page of a PDF — "page": N (1-based) brings in only that page, as one graphic under the plain name, with its top-left corner at x/y when given, else at page 1's corner (0, 0). pageCount still reports the whole PDF; a page past the end is 422.

  • Success 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 slot

The 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 graphic

Sets 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
  "pivot": { "x": <Number>, "y": <Number> },   // optional (2026-10-03); turn about this point instead
  "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. pivot (2026-10-03): { "x", "y" } in document points turns the graphic about that point instead of its center — a hand about its hinge, a label about a hub, a corner held in place (the Orient / Distribute Along pivot; morphSelectedGraphicsWithTransform:pivotPoint:includeText:, one undo step). pivot without a non-zero rotation is 400, as is a pivot that is not {x, y} numbers. Class swap and the returned axis-aligned hiddenBounds are as for a center rotation. Python: morph_graphic(..., rotation=90, pivot=(x, y)). MCP: rotate(name, degrees, pivot=[x, y]) or rotate(name, degrees, pivot_anchor="top-left") (a point on the graphic's bounds: center, the four corners, the four edge midpoints).

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:

  • Scaling: a DKDCircle asymmetrically scaled converts to DKDOval (acceptsScalingTransform / conversionClassForScaling / convertForScaling).
  • Rotation: a graphic with no angle parameter converts before rotating — e.g. a 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 / intersection

Path 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
}
operationEngine callResult
unionkeepInsideA NO, insideB NOone outline covering both shapes
differencekeepInsideA NO, insideB YESA minus B — B is cut out of A (order matters)
intersectionkeepInsideA YES, insideB YESonly 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.

Group / ungroup — DKDGroup

The 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}.

Text endpoints — DKDTextArea

Programmatic 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}/text

Returns 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). A centered-text centers its box via the host geometry; its paragraph alignment is a separate property, and Insert Text gives the centered form center unless the body names one (2026-10-03).

{
  "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,
    "lineCount":    2,                                     // lines the text needs at the box's width
    "lineHeight":   22.0,                                  // first line fragment height
    "firstBaseline": 17.0,                                 // box top down to the first baseline, as drawn
    "ascent":       16.6,                                  // first run's font
    "descent":      4.3
  },
  "base": {                          // resolved run-0 attributes (the inherited base)
    "fontFamily": "Helvetica Neue",
    "fontSize":   18.0,
    "bold":       false,
    "italic":     false,
    "color":      "#1A1A1A",
    "alignment":  "left",
    "verticalAlignment": "top"       // top | center | bottom (box attribute)
  },
  "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. The box always lays out at its own width, wherever it sits on the page (a box ending within 36 pt of the drawing's right edge used to be measured narrower and wrap early).

Line height and baseline. A text box's minimum height is one line of its base font, never more than 15 pt: 8 pt type sits in a box about 10 pt tall (the minimum used to be a fixed 15 pt at every size). lineHeight, firstBaseline, ascent and descent are the first line as drawn — vertical alignment and a stretch-mode morph's stretch.y included — so text lands on a printed line by arithmetic: y = lineY − firstBaseline (place_text_on_line in the Python client does this).

Empty boxes. For an empty box base and the metrics describe the style the next text will get: the style the box had when it was emptied (see below), else the drawing's default text style — never the system UI font.

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}/text

Sets 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:

KeyTypeEffect
textstringReplace the string (may contain \n and \t). Omit to keep the existing string and only restyle.
fontFamilystringFont family, via NSFontManager.
fontSizenumberPoint size (> 0).
boldboolAdd/remove the bold trait.
italicboolAdd/remove the italic trait.
alignmentstringleft | center | right | justified | natural.
kerningstringdefault | off | tight | loose (approximate point-based mapping).
colorstring#RRGGBB or #RRGGBBAA.
autoHeightboolWhen 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).
allowTextLinkboolSet/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.
verticalAlignmentstringtop | center | bottom: where the text block sits in a box taller than its text. A box attribute (kept when the text changes, saved with the drawing, drawn in PDF / PNG / SVG export). Ignored for contained text (the host centres it) and linked flow. Not in the Text menu yet.

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.

Emptied boxes keep their style. text: "" leaves the box remembering the style it had — and any override sent with the empty text — the way a text view keeps its typing attributes; the next fill starts from it with no style keys. The same holds when a user empties a box in the app. The remembered style is saved with the drawing (EmptyTextAttributes in both .ezdraw and .ezdjson) and is undone and redone with the text.

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).

  • Setting text on the lead replaces the whole flowed stream: the handler clears the downstream boxes first, then reflows, so repeated fills don't double-count.
  • The response's 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).
  • Text distributes across the existing linked boxes; it does not create new boxes when the chain overflows (it does not add boxes to the chain). Address the lead box for whole-stream replacement.
  • Linking boxes themselves (drawing the DKDTextLinkPath connectors) is done in the 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

PATCH /v1/drawings/{D}/layers/{L}/texts — set many boxes in one call

A whole form in one round trip:

{ "items": [
    { "name": "county", "text": "Loudoun" },
    { "graphic": "name:case no", "text": "CL-2026-1234", "fontSize": 10 },
    { "graphic": "7A1C…", "text": "Prince William", "verticalAlignment": "center" },
    { "graphic": ["<group uuid>", "<child uuid>"], "text": "…" }
] }

Each item addresses a box on layer L by graphic (a UUID, name:<name>, or a chain array) or by name, and carries the PATCH …/text keys. All items are applied in one main-thread pass as one undo step, "Set Texts". Locks are checked per item (?force=true applies to all). The response is always 200 (404 only when the drawing or layer is missing), one result per item in order:

{ "results": [ { "name": "county", "status": 200, "text": <text dict> },
               { "graphic": "name:case no", "status": 404, "error": "Graphic not found at the given UUID chain" } ],
  "count": 2, "failed": 1 }

A failed item (400 bad keys, 404, 409 ambiguous name, 422 not text, 423 locked) does not stop the others. At most 1000 items.

Python: set_texts(du, lu, {"county": "Loudoun", "case no": {"text": "…", "font_size": 10}}) or a list of item dicts. MCP: set_texts(values) — names resolved across layers.

Measuring text — POST …/text/measure and POST /v1/measure-text

"Would this fit?" without throwaway boxes.

  • POST /v1/drawings/{D}/layers/{L}/graphics/{G_chain}/text/measure — body { text?, fontFamily?, fontSize?, bold?, italic?, alignment?, kerning? }, measured with the box's own style and width; the box is not changed (no undo, no redraw). text omitted measures the current text. Returns { requiredSize, fits, lineCount, lineHeight, firstBaseline, ascent, descent, bounds }.
  • POST /v1/measure-text — no drawing: { text, fontFamily?, fontSize?, bold?, italic?, alignment?, kerning?, width? }. Unset style keys take the app's default text style; width in points wraps (omitted or 0: one line per paragraph). Returns { requiredSize, lineCount, lineHeight, firstBaseline, ascent, descent } as a box of that width with no outline and the default line padding would report them.

Python: measure_in_box(du, lu, g, font_size=12), measure_text(text, font_family=, font_size=, width=). MCP: measure_in_box, measure_text.

Contained text — Insert / Disconnect (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 Text

Body: { "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):

centeredClasstypeBehavior
true (default)DKDCenterTextcentered-textre-centers with the host on move and resize — what a shape label wants
falseDKDTextAreatextposition-fixed contained text, placed in the upper half of the host's bounds; moves with the host but does not re-center on resize

Paragraph alignment of the centered form (2026-10-03). centered: true with no alignment in the body gets center paragraph alignment — the same rule as the app's Text ▸ Insert Centered Text, which centers the paragraph unless the text carries its own alignment. Name alignment to override ("left", "right", …). Before this the API form carried no paragraph style and a "centered" label sat at the left edge of its shape. centered: false is unchanged (natural / left).

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 Text

Address 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>

Rich text — attribute runs (.../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/runs

Returns 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):

KeyTypeNSAttributedString sourceNotes
fontFamilystringNSFontAttributeNamefamily name
fontSizenumberNSFontAttributeNamepoint size
bold / italicboolfont traitsvia NSFontManager
colorhexNSForegroundColorAttributeNamealways present (default #000000)
highlightColorhexNSBackgroundColorAttributeNameper-glyph background — the UI's Highlight
strokeColorhexNSStrokeColorAttributeNameomitted when unset
strokeWidthnumberNSStrokeWidthAttributeName% of point size; negative = stroke and fill
underlinestringNSUnderlineStyleAttributeNamesingle | double | thick (omitted when off)
underlineColorhexNSUnderlineColorAttributeNameomitted when unset (defaults to text color)
strikethroughstringNSStrikethroughStyleAttributeNamesame vocabulary as underline
strikethroughColorhexNSStrikethroughColorAttributeNameomitted when unset
kerningnumberNSKernAttributeNamepoints; omitted when default
baselineOffsetnumberNSBaselineOffsetAttributeNameomitted when 0
obliqueness / expansionnumbercorresponding attributesomitted when 0
alignmentstringparagraph styleparagraph-level (left|center|right|justified|natural)
shadowobjectNSShadowAttributeName{ 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:

GraphicTargettextTarget valuelayout block
DKDTextAreaits own content"text"yes
path/shape with an annotation in usethe annotation's attributed text"annotation"no (no fit contract — text may follow a curve)
shape with contained textthe 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/attributes

Body: { "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:

KeyTypeMeaning
allboolthe whole string
range{location, length}explicit character range (from GET .../text/runs)
matchstringliteral substring of the current text (case-sensitive)
occurrenceint or "all"with match: which occurrence (1-based, default 1), or every occurrence
setdictattribute keys → values (write vocabulary below)
cleararrayattribute keys to remove from the range

Write vocabulary (Phase 2, 2026-07-11 — now the full read vocabulary except paragraph-level alignment):

  • Colors — color, highlightColor, strokeColor (+ strokeWidth), underlineColor, strikethroughColor (hex / number).
  • Style switches — underline, strikethrough ("none" \| "single" \| "double" \| "thick"; boolean accepted as sugar — true → single, false → none).
  • Fonts — fontFamily (family name via NSFontManager; an unknown family 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).
  • Numbers — kerning (points; distinct from the Level-1 endpoint's named kerning presets), baselineOffset, obliqueness, expansion.
  • Paragraph (Phase 3) — 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 (Phase 3) — 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/runs

The 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 within it (else 422). Omit to keep the current string.
  • Ranges may overlap; later runs win (as with operations).

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.

  • Whole-box restyle without flattening: { "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 other ranges are untouched.
  • clear removes the attribute (for underline/strikethrough, equivalent to "none"; clearing color restores default black).
  • Match ranges are resolved against the current string as each operation 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),

  1. The 400/422 split: what is checkable without the document is 400; what

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"}},
])

Annotation endpoints — DKDAnnotation

A 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/text

Body: { "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.

Export endpoints

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 → on-the-wire mapping

formatContent-TypeFile extensionSource
nativeapplication/json.ezdjsonDKDFileType_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.
svgimage/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.
pdfapplication/pdf.pdfDrawings: [doc pdfDataMultiPagination:SinglePagePDFPagination withError:&err] — one PDF page per drawing page, each exactly the drawing's page size (print margins are not added). Graphics: [doc pdfDataWithGraphics:@[g]] (no selection mutation — direct entry point).
pngimage/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.
jpgimage/jpeg.jpgSame as PNG but DKDFileType_JPG. JPG color space and compression come from DKDExport defaults.

Response headers

  • 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:
    • drawing: <displayName>.<ext>
    • layer: <displayName>-<layerName>.<ext>
    • graphic: <displayName>-<graphicNameOrUUID>.<ext>
  • Path-unsafe characters (/ \ : * ? " < > |) in the filename component are replaced with _.

Limitations and behavior

  • Background follows the format asked for (2026-10-03). A raster export reads the background choice saved for its own format — png the PNG choice, jpg the JPEG choice — pinned for the call like exportContents and the render DPI. It used to read the choice of whatever format the Export panel last showed, so a png render could take the PDF setting ("No (Black) Background") and, with Alpha = None, come back solid black. In the same pass, the two "No (…) Background" choices now matte onto the named color (white or black) whenever the bitmap has no alpha channel (Alpha = None / Skip First / Skip Last, and every JPEG), instead of leaving zero bytes that render as black.
  • A blank drawing renders. Full-drawing exports (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.)
  • Layer endpoint renders that layer alone (2026-10-03). /drawings/{D}/layers/{L}/export/{f} for svg, pdf, png, jpg is the page (Drawing_Full area) carrying only the named layer's graphics: for the call the document's Layer Select mode is set to show every layer that is on, every other layer is turned off, and the named layer is turned on whatever its state; all of it is restored before the response and no undo step is registered. native is still the whole file. Before this the layer bytes were identical to the drawing's.
  • Selection mutation for graphic raster/SVG. PNG, JPG, and SVG of a single graphic are produced by:
    1. Save current [gView selectedGraphics]
    2. clearSelection, then selectGraphics:@[targetGraphic]
    3. Save current [exp exportContents], set to ExportContents_Graphics_Selected
    4. Call the export
    5. Restore both, in @finally

This 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.

  • Library export is not available. /v1/libraries/{uuid}/export/{format} and /v1/libraries/{uuid}/elements/{uuid}/export/{format} return 501 Not Implemented.
  • Resolution (raster). PNG/JPG renders are pinned to a predictable 144 dpi by default regardless of the document's own export setting (the 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.
  • Background and alpha — ?background=, ?alpha= (2026-10-03; png, jpg; drawing, layer and graphic endpoints). background is white | black | none | color (the drawing's page color); alpha is none | premultiplied (png only; jpg never has alpha). Each is pinned on the export settings for the call, like dpi, and restored; the Export panel's values stand when a key is absent or unrecognized (the same leniency as dpi). A none background is clear only with an alpha channel; without one it is matted white (the matte rule below). Python: export_drawing(du, "png", background="none", alpha="premultiplied"). MCP: render always asks for white + premultiplied, so the agent's picture is the page whatever the panel holds; export_drawing / export_layer / export_graphic pass the caller's values.
  • No-alpha PNG is the page's size (2026-10-03). The Alpha = None / Skip paths used to lay the pixels inside a one-pixel frame of the background color, so a 612 × 792 page exported at 614 × 794; the image is now cropped to the page, the size the alpha path has always produced.
  • One page — ?page=N (drawing endpoint; pdf, png, jpg). Exports just page N (1-based, the drawing's page order). PDF: a one-page PDF of that page. PNG / JPG: that page drawn at dpi (JPG on white). 422 past the last page, 400 for another format; the filename gains -pageN. Python: export_drawing(du, "pdf", page=2). MCP: export_drawing(fmt, page=).
  • Page size. A PDF page is exactly the drawing's page, edge to edge, whatever the print margins (they apply to printing only) and whatever the default printer. Each page is drawn by its own PDF operation on that page's rectangle and the pages are joined in order, so no printer's paper list or printable area is involved. (Earlier versions printed to a file through the default printer: a page that matched one of its papers took that paper's printable area, so an A4 page came out shifted down by the printer's hardware margin and clipped, and US Letter was exported 1 pt oversize to avoid the same.)
  • MCP render / export tools. Intent-named tools front this so a client doesn't bloat its context with a big image: render(drawing, dpi=96) returns the PNG inline to look at — kept light at 96 dpi for the perception loop (bump for fine detail), always white background + alpha channel; export_layer(layer, fmt, …) and export_graphic(name, fmt, …) write one layer or one graphic (both take background / alpha); 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).
  • Other export parameters not exposed. Color space, SVG profile, SVG glyph mode, JPG compression, PNG alpha and bits-per-color all use the document's current DKDExport defaults. A future revision will accept further query-string overrides (e.g. ?colorSpace=srgb).

Status codes

  • 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.

Layer visibility — 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).
  • The active layer's on/off can't be changed (it's always shown/editable) — 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 layer

Moves 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 layer

Adds 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 layer

Removes 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?).

Layer configurations — 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.

Sub-resource bodies

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.

EndpointBodyLookup
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:

  • Layer and element: a bare propertyListRepresentation serializes graphics with a nil archiveStoreRef, so attributes are already inline — no special handling.
  • Library: 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 keyJSON typeModel setter
TitlePropertystringsetTitleProperty:
AuthorsPropertyarray of stringssetAuthorsProperty:
KeywordsPropertyarray of stringssetKeywordsProperty:
DescriptionPropertystringsetDescriptionProperty:
CommentPropertystringsetCommentProperty:
OrganizationsPropertyarray of stringssetOrganizationsProperty:
CopyrightPropertystringsetCopyrightProperty:
ProjectsPropertyarray of stringssetProjectsProperty:
VersionPropertystringsetVersionProperty:
PATCH /v1/drawings/{D}/properties
{
  "TitleProperty":   "Quarterly Schematic",
  "AuthorsProperty": ["Dave Mattson"],
  "CopyrightProperty": "2026"
}
  • Omit a key to leave it unchanged. Clear a field by sending "" (string fields) or [] (array fields).
  • Array fields must be JSON arrays whose elements are all strings.

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"])

Lock policy

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:

EndpointChecks
POST /v1/drawings/{D}/layers/{L}/graphicsdestination layer's layerLock
POST /v1/drawings/{D}/layers/{L}/shapesdestination layer's layerLock
PATCH .../graphics/{G_chain}/stylegraphic'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}/lockgraphic'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.)

Status codes

  • 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.

Threading

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.

Naming conventions (authoritative)

These rules apply to all endpoints and JSON keys. Deviations are legacy compatibility, not patterns to copy.

URL paths

  • All lowercase. No PascalCase or camelCase in path segments. Example: /v1/drawings/{uuid}/layers/{uuid}, never /Layers/.
  • kebab-case for compound words. Multi-word path segments are joined with hyphens. Example: /color-modification, not /colorModification or /color_modification.
  • Plural collection nouns; UUID for the singular item. /drawings (collection) ↔ /drawings/{uuid} (one). /layers ↔ /layers/{uuid}. The collection segment never appears in the singular form.
  • Sub-resources hang off the singular item. /v1/drawings/{uuid}/layers/{uuid}/scale, never /v1/drawings/{uuid}/scale-of-layer/{uuid}.
  • Versioning prefix /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.
  • name: addresses a graphic by name. Wherever a graphic {uuid} segment goes, name:<nameGraphic> (URL-encoded) may stand in; it is the only non-UUID item segment. Layers and drawings are addressed by UUID only.
  • Collection-wide actions use the plural sub-resource. PATCH …/layers/{uuid}/texts sets many boxes, next to the per-graphic …/graphics/{uuid}/text. Server-wide utilities with no resource are verb phrases under /v1: /v1/measure-text.

JSON response keys

  • camelCase for all new keys: layerName, layerState, graphicsCount, graphicUUID, nameGraphic, hiddenBounds, typeName, displayName (when newly minted).
  • Keys match the EazyDraw model property names when the key reflects a model attribute: graphicUUID, hiddenBounds, layerName.
  • Single-word keys are lowercase: index, type, status, version, build, drawings, layers, graphics, x, y, width, height.
  • Legacy keys retained for stability (do not propagate the style — these predate the convention):
    • "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> dicts New keys at any level must be camelCase even when they sit alongside these.

Value formats

  • Bounds and rects in JSON use the sub-dictionary form { "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.
  • Layer-state strings are the un-localized names: NotSet, On, Off, Active. Never localized in API responses.
  • Graphic 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.
  • UUIDs are uppercase hex with dashes, as produced by [NSUUID UUIDString]. Matching against client-supplied UUIDs is case-insensitive.
  • Numbers: counts and indices are plain JSON numbers (NSNumber unsignedInteger); coordinate/dimension values are doubles.

Not exposed by the API

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. (Brought current 2026-10-03: measure-only text — measure_text / measure_in_box — the dpi override, layer-scoped export and the rotation pivot have all landed since this list was first written.)

  • Structured text import — the text endpoints work on whole boxes plus the run-level endpoints (/text-runs, set_text_attributes). There is no Markdown or HTML import.
  • Annotation styling — the /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).
  • Class swap for nested graphics during morph (only top-level class swap is supported, for both scale and rotation; a nested graphic that would need a class change returns 422)
  • Attribute-action transfer onto a nested (grouped) graphic — POST .../graphics/{uuid}/attributes is implemented for top-level layer graphics; a nested target returns 422 (like the morph nested class-swap limitation)
  • Per-graphic resources addressable by graphicUUID (only the array form is exposed)
  • iOS / visionOS — the API is macOS only.
  • Library element export — returns 501.
  • Export parameters beyond dpi, background and alpha (color space, SVG profile/glyphs, JPG compression, PNG bits-per-color) — those come from the document's current export settings; there are no query-string overrides for them
  • Document properties write — PATCH /v1/drawings/{uuid}/properties covers the nine metadata fields only
  • QuickLook settings, the Serialization (Optimized↔Flat) toggle, and the uuid (persistent-UUID yes/no) toggle write — accepted and ignored by the properties PATCH
  • camelCase JSON key mapping layer — properties (and the other verbatim-plist sub-resources) currently use on-disk PascalCase keys; the API does not remap them