Implement penpot_add_image with media upload #200

Closed
opened 2026-09-14 23:11:41 +00:00 by david · 1 comment
Owner

Summary

Implement penpot_add_image: upload a local raster/SVG file to the Penpot file as a media object and stage an image shape that references it, so images are real, movable, resizable design objects rather than an external link.

Background

Depends on: #199

The composition set is incomplete without images: a screenshot reconstruction or a screen spec routinely needs a photo, logo or raster asset placed inside a frame. The shape side reuses the shape builders from the frame/rect steps; the new machinery is the media upload, which is the first multipart request in this extension and the first time a shape references something other than a library asset.

What is known and what must be verified:

  • Media upload is a multipart command. The candidate is upload-file-media-object with multipart field names in kebab-case (file-id, file) — this extension already learned that Penpot's multipart endpoints use kebab-case field names where JSON bodies accept camelCase (confirmed for import-binfile on 2.17).
  • The upload returns a media object (with an id and dimensions) which the image shape then references. The exact response field names and the shape's reference field (mediaId vs a metadata object carrying {id, width, height}) must be confirmed against the instance's OpenAPI spec and a live upload before finalising — record the result in extensions/penpot/findings.md.
  • Image shapes otherwise use the same shape payload as every other shape (selrect, points, transform, transformInverse, frameId/parentId).

Fail loudly rather than guessing: if the spec does not match the assumed command or field names, adapt the implementation and record the difference; do not ship a shape the server rejects.

Documentation Required

A separate process downloads these into the listed folders before this issue is implemented. Check the folders for the actual reference material before starting.

docs/reference/penpot-api/

docs/reference/nodejs/

docs/reference/pi-coding-agent/ and docs/reference/typebox/

Implementation Details

Create extensions/penpot/src/media.ts (upload helper) and extensions/penpot/src/tools/image.ts, and register the tool.

src/media.ts

  • uploadMedia({ fileId, path, name? }):
    1. Read the file and reject anything over a sane size cap with a clear message (do not attempt a huge upload).
    2. Build a FormData with kebab-case field names (file-id, and file as a Blob with a filename), matching the multipart convention already used by the library import path.
    3. POST to the media command confirmed from the spec, with Authorization: Token <token> (multipart requests must not set a JSON Content-TypeFormData supplies its own boundary).
    4. Normalise the response to { mediaId, width, height, name }, verifying which of those fields the server actually returned.
    5. Return a categorised error on failure, including the raw body excerpt when the response is not JSON.
  • Detect obvious non-images (by extension/magic bytes) and refuse with a clear message rather than uploading something Penpot will reject or render as blank.

src/tools/image.tspenpot_add_image

Parameters: file_id (required), path (absolute or repo-relative path to the image, required), name, x, y, width/height (optional — when only one is given, derive the other from the image's aspect ratio; when neither is given, use the image's natural size), parent_id, page_id, keep_aspect_ratio (boolean, default true), and child-layout hints (layout_item_h_sizing/layout_item_v_sizing/layout_item_absolute).

  • Stage an add-obj image shape referencing the uploaded media, with geometry computed from the parameters and the image's intrinsic size.
  • Return the shape id and the media id in details.
  • The upload itself is an immediate HTTP call (media is not a staged change), so: perform it only after the designated-target guard has authorised the file, and report clearly that the upload happened even if the later commit is discarded — a stray media object is harmless but should not be a surprise.

Acceptance Criteria

  • The media command and its parameter/response field names are confirmed against the instance's OpenAPI spec and a real upload, and the result is recorded in extensions/penpot/findings.md.
  • The upload uses a multipart FormData with kebab-case field names and no JSON Content-Type header.
  • Oversized files and obviously non-image files are refused locally with actionable messages, with no request sent.
  • The staged image shape carries the complete shape payload plus a media reference the server accepts.
  • width/height derivation honours keep_aspect_ratio; omitting both uses the intrinsic size.
  • The upload only happens for a file that passes the designated-target guard, and the result says explicitly that media was uploaded.
  • Commit verification (from the earlier step) recognises the image shape id.
  • Unit tests cover geometry/aspect-ratio derivation, the size and file-type guards, and multipart body construction, with no network access.

Test Plan

node --test extensions/penpot/src/media.test.ts extensions/penpot/src/tools/image.test.ts

Live validation (requires PENPOT_URL/PENPOT_TOKEN, scratch file):

  1. Authorise the scratch file and stage a frame plus one PNG inside it; commit.
  2. In the Penpot UI, confirm the image renders, is selectable as an image object, and is named as requested.
  3. Re-run with keep_aspect_ratio: true and a single dimension; confirm the rendered aspect ratio matches the source image.
  4. Attempt an oversized file and a non-image file; confirm both are refused with clear messages and no upload occurs.
  5. Re-read the file and confirm the media object exists in the file's media collection once per upload.
## Summary Implement `penpot_add_image`: upload a local raster/SVG file to the Penpot file as a media object and stage an `image` shape that references it, so images are real, movable, resizable design objects rather than an external link. ## Background **Depends on:** #199 The composition set is incomplete without images: a screenshot reconstruction or a screen spec routinely needs a photo, logo or raster asset placed inside a frame. The shape side reuses the shape builders from the frame/rect steps; the new machinery is the **media upload**, which is the first multipart request in this extension and the first time a shape references something other than a library asset. What is known and what must be verified: - Media upload is a multipart command. The candidate is `upload-file-media-object` with multipart field names in **kebab-case** (`file-id`, `file`) — this extension already learned that Penpot's multipart endpoints use kebab-case field names where JSON bodies accept camelCase (confirmed for `import-binfile` on 2.17). - The upload returns a media object (with an id and dimensions) which the image shape then references. The exact response field names and the shape's reference field (`mediaId` vs a `metadata` object carrying `{id, width, height}`) **must be confirmed against the instance's OpenAPI spec and a live upload before finalising** — record the result in `extensions/penpot/findings.md`. - Image shapes otherwise use the same shape payload as every other shape (`selrect`, `points`, `transform`, `transformInverse`, `frameId`/`parentId`). Fail loudly rather than guessing: if the spec does not match the assumed command or field names, adapt the implementation and record the difference; do not ship a shape the server rejects. ## Documentation Required A separate process downloads these into the listed folders before this issue is implemented. Check the folders for the actual reference material before starting. **`docs/reference/penpot-api/`** - `<PENPOT_URL>/api/main/doc/openapi.json` — search for the media upload command(s) (e.g. `upload-file-media-object`, `get-file-object-thumbnails`) and read the exact parameters and response fields. This is the authoritative source for this step. - https://raw.githubusercontent.com/penpot/penpot/2.17.2/common/src/app/common/types/shape.cljc — the `image` shape variant and its media reference fields. - https://raw.githubusercontent.com/penpot/penpot/2.17.2/common/src/app/common/types/file.cljc — the file's `media` collection, so the tool can confirm the upload landed where expected. - https://help.penpot.app/user-guide/import-export/ — the user-facing import behaviour (accepted formats, expected result) to align the tool with the UI. - https://help.penpot.app/user-guide/design-systems/assets/ — how images appear in the file's assets list. **`docs/reference/nodejs/`** - https://nodejs.org/api/buffer.html — reading the image file into a `Blob` for the multipart body. - https://nodejs.org/api/globals.html#class-formdata and https://nodejs.org/api/globals.html#class-blob — the global `FormData`/`Blob` used for the upload (no third-party dependency). - https://nodejs.org/api/fs.html — `readFile`/`stat` for reading and size-checking the input file. **`docs/reference/pi-coding-agent/`** and **`docs/reference/typebox/`** - https://pi.dev/docs/latest/extensions — tool registration and structured `details`. - https://github.com/sinclairzx81/typebox — numeric/boolean parameter schemas for dimensions and aspect-ratio handling. ## Implementation Details Create `extensions/penpot/src/media.ts` (upload helper) and `extensions/penpot/src/tools/image.ts`, and register the tool. ### `src/media.ts` - `uploadMedia({ fileId, path, name? })`: 1. Read the file and reject anything over a sane size cap with a clear message (do not attempt a huge upload). 2. Build a `FormData` with **kebab-case** field names (`file-id`, and `file` as a `Blob` with a filename), matching the multipart convention already used by the library import path. 3. POST to the media command confirmed from the spec, with `Authorization: Token <token>` (multipart requests must **not** set a JSON `Content-Type` — `FormData` supplies its own boundary). 4. Normalise the response to `{ mediaId, width, height, name }`, verifying which of those fields the server actually returned. 5. Return a categorised error on failure, including the raw body excerpt when the response is not JSON. - Detect obvious non-images (by extension/magic bytes) and refuse with a clear message rather than uploading something Penpot will reject or render as blank. ### `src/tools/image.ts` — `penpot_add_image` Parameters: `file_id` (required), `path` (absolute or repo-relative path to the image, required), `name`, `x`, `y`, `width`/`height` (optional — when only one is given, derive the other from the image's aspect ratio; when neither is given, use the image's natural size), `parent_id`, `page_id`, `keep_aspect_ratio` (boolean, default `true`), and child-layout hints (`layout_item_h_sizing`/`layout_item_v_sizing`/`layout_item_absolute`). - Stage an `add-obj` image shape referencing the uploaded media, with geometry computed from the parameters and the image's intrinsic size. - Return the shape id and the media id in `details`. - The upload itself is an immediate HTTP call (media is not a staged change), so: perform it **only** after the designated-target guard has authorised the file, and report clearly that the upload happened even if the later commit is discarded — a stray media object is harmless but should not be a surprise. ## Acceptance Criteria - [ ] The media command and its parameter/response field names are confirmed against the instance's OpenAPI spec and a real upload, and the result is recorded in `extensions/penpot/findings.md`. - [ ] The upload uses a multipart `FormData` with kebab-case field names and no JSON `Content-Type` header. - [ ] Oversized files and obviously non-image files are refused locally with actionable messages, with no request sent. - [ ] The staged image shape carries the complete shape payload plus a media reference the server accepts. - [ ] `width`/`height` derivation honours `keep_aspect_ratio`; omitting both uses the intrinsic size. - [ ] The upload only happens for a file that passes the designated-target guard, and the result says explicitly that media was uploaded. - [ ] Commit verification (from the earlier step) recognises the image shape id. - [ ] Unit tests cover geometry/aspect-ratio derivation, the size and file-type guards, and multipart body construction, with no network access. ## Test Plan ```bash node --test extensions/penpot/src/media.test.ts extensions/penpot/src/tools/image.test.ts ``` Live validation (requires `PENPOT_URL`/`PENPOT_TOKEN`, scratch file): 1. Authorise the scratch file and stage a frame plus one PNG inside it; commit. 2. In the Penpot UI, confirm the image renders, is selectable as an image object, and is named as requested. 3. Re-run with `keep_aspect_ratio: true` and a single dimension; confirm the rendered aspect ratio matches the source image. 4. Attempt an oversized file and a non-image file; confirm both are refused with clear messages and no upload occurs. 5. Re-read the file and confirm the media object exists in the file's media collection once per upload.
david closed this issue 2026-09-15 03:45:03 +00:00
Author
Owner

pi-loop opened and merged a pull request for this issue: #233

pi-loop opened and merged a pull request for this issue: https://git.excelera.net/david/pi-extensions-and-skills/pulls/233
Sign in to join this conversation.
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
david/pi-extensions-and-skills#200
No description provided.