penpot_add_text stages zero-area auto-width text, producing files that fail update-file with NaN points #252

Closed
opened 2026-09-17 03:06:13 +00:00 by david · 1 comment
Owner

Summary

penpot_add_text stages text shapes with width: 0 / height: 0 and growType: "auto-width" whenever the caller omits dimensions. Penpot performs no text layout server-side — the client computes it — so the degenerate geometry is committed and persisted as-is. The first client-side interaction with one of these shapes produces NaN points, the server's post-apply assertion rejects the change with HTTP 500, and the frontend keeps the failed change queued and retries on every load. The file then appears permanently broken in the editor and can only be recovered out-of-band via the backend nREPL.

Confirmed against a real file on the live instance (Penpot 2.17.2, penpot.excelera.net), captured in the debug bundle penpot-bug-1e70e922-REPORT.md.

Impact

  • One shape wedges the whole file. A single zero-area text object makes every subsequent POST /api/main/methods/update-file fail with 500, blocking all further authoring of that file.
  • Scale observed: 150 of 441 shapes in one generated screen were degenerate (:type :text, :grow-type :auto-width, w0 h0). The page labels were correct; only geometry was missing.
  • Recovery requires server access. The file had to be normalised through the backend nREPL (app.srepl.main/update-file!); there is no client-side remedy while the bad change is queued.
  • This is a known, shipped gap. It was recorded as a limitation rather than fixed — see extensions/penpot/findings.md:614-618: "No auto-measurement is performed, so geometry staged from the tools can differ from what the UI would compute." A regression test currently asserts the buggy behaviour.

Evidence

From penpot-bug-1e70e922-REPORT.md (file id 1e70e922-ca4f-4d1c-a99a-59327eaf57e8, revn 10, 441 shapes, 150 zero-size):

Stored shape (the shape that broke the save):

{:name "header/month-july-label" :type :text :grow-type :auto-width
 :id #uuid "43bc7ae4-8ff4-49eb-9c9e-391b6da59f67"
 :x 697 :y 97 :width 0 :height 0
 :selrect {:x 697 :y 97 :width 0 :height 0 :x1 697 :y1 97 :x2 697 :y2 697}
 :points ["697,97" "697,97" "697,97" "697,97"]      ; all four identical
 :content {... :text "July 2026" :font-id "sourcesanspro" :font-size "13" ...}}

Server rejection:

POST /api/main/methods/update-file?id=1e70e922-…   → 500
E app.http.errors  hint="data assertion error"
  clojure.lang.ExceptionInfo: invalid shape found '43bc7ae4-8ff4-49eb-9c9e-391b6da59f67' (changes.cljc:475)
  :in [:points 2]  :schema :app.common.geom.point/point
  :value #app.common.geom.point.Point{:x ##NaN, :y ##NaN}
  :in [:points 3]  :schema :app.common.geom.point/point
  :value #app.common.geom.point.Point{:x ##NaN, :y ##NaN}

Two further facts from the report that matter for the diagnosis:

  • Zero is not the trigger; zero is the setup. The stored file validates cleanly (Penpot's own validate-file → 0 errors, validate-file-schema → OK, 0 NaN anywhere). The failing client change carried :y nil / :width nil for the :auto-width text, and the point computation turned nil into NaN. Our tool commits the degenerate geometry that makes the client's next layout pass produce that nil/NaN — the 500 is downstream of our output.
  • No other file was affected. All 11 other files on the instance contained zero zero-size text shapes, so this is unique to this tool's output, not the instance.

Root cause

The staged payload is ours, and it matches the stored pathosis exactly.

extensions/penpot/src/tools/text.ts:301-310:

// Omitted dimensions are left at 0; the growType below tells Penpot
// to size the box from the content in that axis.
width: params.width ?? 0,
height: params.height ?? 0,
frameId: parentId,
parentId,
...
growType: growTypeFor(params),   // text.ts:193-197 → "auto-width" when width omitted

extensions/penpot/src/shapes.ts:443-470 then derives the collapsing geometry:

  • pointsFrom(697, 97, 0, 0) → all four corners are 697,97 (shapes.ts:395-406)
  • selrectFrom(697, 97, 0, 0) → zero-area selrect (shapes.ts:410-426)

growType: "auto-width" is correct Penpot semantics if and only if a client layout pass will follow and write back a real box. For a staged add-obj via update-file there is no such pass, so the zero persists. The report's own conclusion agrees: "the shapes were created without a completed text layout pass ... Two things worth fixing on the tool side: set/verify real geometry (or force a text re-layout) before/after adding text, and never send nil for :width/:height/:y."

The second half of that recommendation is already satisfied — we emit numeric 0, never nil. The first half is the defect.

Affected code

Every text path routed through baseShape is affected, not just penpot_add_text:

Path Where Notes
penpot_add_text src/tools/text.ts:301-310 primary source; omitting width/height stages 0/0
SVG-imported text src/tools/svg.ts:663-670, :718 growType: "fixed" but geometry comes from the SVG bounds, which can be zero for a text node with no explicit dimensions
Library-artifact text src/libraryArtifact.ts:506 shape.growType = node.growType ?? "auto-width"; current spec nodes carry real width/height, but the default is the same footgun
Geometry derivation src/shapes.ts:395-426, :443-470 pointsFrom/selrectFrom/baseShape have no zero-area or finite-number invariant
Staging → write src/tools/commit.ts:456-528 no pre-flight validation of staged shape geometry before update-file

Precedent to follow

extensions/penpot/src/tools/image.ts:85-118 already solves the same class of problem for images:

 * A supplied dimension must be positive; a `0` (allowed by the schema's
 * `minimum: 0`) is treated as "not supplied", so it falls back to the
 * intrinsic axis instead of staging a degenerate zero-sized box.

Text should behave the same way, but it has no intrinsic size to fall back on — so it needs a local estimate (see below).

Proposed guards

1. Invariant in baseShape (the choke point) — required

After deriving selrect/points, refuse the shape when:

  • any of x, y, width, height is non-finite (NaN, Infinity), for all shape types; and
  • width <= 0 || height <= 0 for type: "text" (and type: "image", which already guards upstream).

This single check covers penpot_add_text, SVG-imported text, and library-artifact text. Throw with an actionable message naming the shape and the missing dimension rather than staging a degenerate box.

2. Don't let an omitted size become a stored zero (text.ts) — required

Treat a supplied 0 as "not supplied" (matching image.ts's positiveDimension), and when a dimension is genuinely omitted, compute a conservative estimate instead of 0:

  • height = line-height × font-size × paragraph-count
  • width = longest-line character count × font-size × ~0.58, floored at roughly 8 × 16

Keep growType: "auto-width" / "auto-height" so Penpot still recomputes on first client layout; the estimate only ensures the committed box is non-degenerate. This mirrors the heuristic that successfully repaired the real file (report §7).

Between the preview (src/tools/commit.ts:456) and the update-file send (:528), scan staged add-obj/mod-obj shapes for text with zero/non-finite geometry and refuse with an actionable message, leaving the changeset staged. Defence in depth against any path not yet audited.

4. Make the schema and tests honest — required

  • src/tools/text.ts schema: width/height currently allow minimum: 0. Switch to exclusiveMinimum: 0 or document that 0 is treated as omitted.
  • src/tools/text.test.ts:350-361 currently asserts the bug (change.obj.width === 0, height === 0). Update it to assert non-degenerate geometry.
  • Add a baseShape unit test for the text zero-area refusal and the non-finite refusal.

Acceptance criteria

  • penpot_add_text with no width/height stages a text shape whose width and height are both strictly positive, with growType still auto-width, and selrect/points derived from that non-zero box.
  • penpot_add_text with an explicit width: 0 behaves as "omitted" and produces a positive estimate, not a zero box (or is refused with a clear message — decide and document).
  • baseShape throws for a text shape with width <= 0 or height <= 0, and throws for any shape type with a non-finite x/y/width/height.
  • No staged add-obj/mod-obj for a text shape can reach penpot_commit with zero-area geometry; a pre-flight refusal names the offending shape and leaves the changeset staged.
  • The SVG-import text path and the library-artifact text path cannot stage a zero-area text shape.
  • A committed auto-width text shape reads back from get-file with non-zero :width/:height and four distinct :points.
  • Tool descriptions (penpot_add_text, README table) state that omitted dimensions are estimated, not left at zero.
  • findings.md's "No auto-measurement is performed" gap is updated to reflect the guard (measured locally to a conservative estimate; Penpot still re-lays-out on open).

Test plan

  • Unit: baseShape refuses zero-area text; refuses NaN/Infinity on any type; accepts a positive text box.
  • Unit: the text geometry estimator is deterministic and positive for single-line, multi-line, empty-ish, CJK and long-string inputs, and never returns 0.
  • Unit: penpot_add_text (no dimensions, one dimension, both dimensions, width: 0) produces the expected positive geometry and growType.
  • Unit: commit pre-flight refuses a hand-built zero-area staged text change and leaves the changeset intact.
  • Regression: the updated text.test.ts case that previously asserted width === 0 now asserts a positive width/height.
  • Live (opt-in, PENPOT_URL + PENPOT_TOKEN + scratch file): stage one auto-width text with no dimensions, penpot_commit, re-read via get-file, assert non-zero :width/:height and four distinct :points; then open the file in the browser and confirm an edit saves without a 500.
  • Live containment: reproduce the original failure shape by hand (stage w0 h0 auto-width text outside the guard), confirm the guard blocks it before update-file is called.

Out of scope / notes

  • Not a Penpot bug we can fix here. The report notes the server could reject nil geometry with an actionable message instead of degrading to NaN, and/or clamp :auto-width text to a minimum box. That is an upstream suggestion, not this issue.
  • Do not reintroduce the derived-attribute trap. The report's gotcha #2 is specific to the nREPL repair path (:ignore-geometry? true emits :points/:selrect as operations, tripping the change validator). Our tools send complete shape objects and must keep letting baseShape derive geometry.
  • Source material. penpot-bug-1e70e922-REPORT.md and its bundle (penpot-bug-1e70e922-raw.bin, …-decoded.transit.json, …-zero-size-shapes.edn) live outside this repo in ~/Projects/penpot-skill/; the pre-fix raw blob sha256 is 4b23873c8947e4547268cd9a47d0b4feb75d07399c2e057af1053b2834ce983e and is the baseline to compare against.
## Summary `penpot_add_text` stages text shapes with `width: 0` / `height: 0` and `growType: "auto-width"` whenever the caller omits dimensions. Penpot performs **no text layout server-side** — the client computes it — so the degenerate geometry is committed and persisted as-is. The first client-side interaction with one of these shapes produces `NaN` points, the server's post-apply assertion rejects the change with HTTP 500, and the frontend keeps the failed change queued and retries on every load. The file then appears permanently broken in the editor and can only be recovered out-of-band via the backend nREPL. Confirmed against a real file on the live instance (Penpot 2.17.2, `penpot.excelera.net`), captured in the debug bundle `penpot-bug-1e70e922-REPORT.md`. ## Impact - **One shape wedges the whole file.** A single zero-area text object makes every subsequent `POST /api/main/methods/update-file` fail with 500, blocking all further authoring of that file. - **Scale observed:** 150 of 441 shapes in one generated screen were degenerate (`:type :text`, `:grow-type :auto-width`, `w0 h0`). The page labels were correct; only geometry was missing. - **Recovery requires server access.** The file had to be normalised through the backend nREPL (`app.srepl.main/update-file!`); there is no client-side remedy while the bad change is queued. - **This is a known, shipped gap.** It was recorded as a limitation rather than fixed — see `extensions/penpot/findings.md:614-618`: *"No auto-measurement is performed, so geometry staged from the tools can differ from what the UI would compute."* A regression test currently **asserts** the buggy behaviour. ## Evidence From `penpot-bug-1e70e922-REPORT.md` (file id `1e70e922-ca4f-4d1c-a99a-59327eaf57e8`, revn 10, 441 shapes, 150 zero-size): Stored shape (the shape that broke the save): ```clojure {:name "header/month-july-label" :type :text :grow-type :auto-width :id #uuid "43bc7ae4-8ff4-49eb-9c9e-391b6da59f67" :x 697 :y 97 :width 0 :height 0 :selrect {:x 697 :y 97 :width 0 :height 0 :x1 697 :y1 97 :x2 697 :y2 697} :points ["697,97" "697,97" "697,97" "697,97"] ; all four identical :content {... :text "July 2026" :font-id "sourcesanspro" :font-size "13" ...}} ``` Server rejection: ``` POST /api/main/methods/update-file?id=1e70e922-… → 500 E app.http.errors hint="data assertion error" clojure.lang.ExceptionInfo: invalid shape found '43bc7ae4-8ff4-49eb-9c9e-391b6da59f67' (changes.cljc:475) :in [:points 2] :schema :app.common.geom.point/point :value #app.common.geom.point.Point{:x ##NaN, :y ##NaN} :in [:points 3] :schema :app.common.geom.point/point :value #app.common.geom.point.Point{:x ##NaN, :y ##NaN} ``` Two further facts from the report that matter for the diagnosis: - **Zero is not the trigger; zero is the setup.** The stored file validates cleanly (Penpot's own `validate-file` → 0 errors, `validate-file-schema` → OK, 0 NaN anywhere). The failing *client change* carried `:y nil` / `:width nil` for the `:auto-width` text, and the point computation turned `nil` into `NaN`. Our tool commits the degenerate geometry that makes the client's next layout pass produce that `nil`/`NaN` — the 500 is downstream of our output. - **No other file was affected.** All 11 other files on the instance contained zero zero-size text shapes, so this is unique to this tool's output, not the instance. ## Root cause The staged payload is ours, and it matches the stored pathosis exactly. `extensions/penpot/src/tools/text.ts:301-310`: ```ts // Omitted dimensions are left at 0; the growType below tells Penpot // to size the box from the content in that axis. width: params.width ?? 0, height: params.height ?? 0, frameId: parentId, parentId, ... growType: growTypeFor(params), // text.ts:193-197 → "auto-width" when width omitted ``` `extensions/penpot/src/shapes.ts:443-470` then derives the collapsing geometry: - `pointsFrom(697, 97, 0, 0)` → all four corners are `697,97` (`shapes.ts:395-406`) - `selrectFrom(697, 97, 0, 0)` → zero-area selrect (`shapes.ts:410-426`) `growType: "auto-width"` is correct Penpot semantics **if and only if** a client layout pass will follow and write back a real box. For a staged `add-obj` via `update-file` there is no such pass, so the zero persists. The report's own conclusion agrees: *"the shapes were created without a completed text layout pass ... Two things worth fixing on the tool side: set/verify real geometry (or force a text re-layout) before/after adding text, and never send nil for `:width`/`:height`/`:y`."* The second half of that recommendation is already satisfied — we emit numeric `0`, never `nil`. The first half is the defect. ## Affected code Every text path routed through `baseShape` is affected, not just `penpot_add_text`: | Path | Where | Notes | |---|---|---| | `penpot_add_text` | `src/tools/text.ts:301-310` | primary source; omitting `width`/`height` stages `0/0` | | SVG-imported text | `src/tools/svg.ts:663-670`, `:718` | `growType: "fixed"` but geometry comes from the SVG bounds, which can be zero for a text node with no explicit dimensions | | Library-artifact text | `src/libraryArtifact.ts:506` | `shape.growType = node.growType ?? "auto-width"`; current spec nodes carry real `width`/`height`, but the default is the same footgun | | Geometry derivation | `src/shapes.ts:395-426`, `:443-470` | `pointsFrom`/`selrectFrom`/`baseShape` have no zero-area or finite-number invariant | | Staging → write | `src/tools/commit.ts:456-528` | no pre-flight validation of staged shape geometry before `update-file` | ### Precedent to follow `extensions/penpot/src/tools/image.ts:85-118` already solves the same class of problem for images: ``` * A supplied dimension must be positive; a `0` (allowed by the schema's * `minimum: 0`) is treated as "not supplied", so it falls back to the * intrinsic axis instead of staging a degenerate zero-sized box. ``` Text should behave the same way, but it has no intrinsic size to fall back on — so it needs a local estimate (see below). ## Proposed guards ### 1. Invariant in `baseShape` (the choke point) — required After deriving `selrect`/`points`, refuse the shape when: - any of `x`, `y`, `width`, `height` is non-finite (`NaN`, `Infinity`), for **all** shape types; and - `width <= 0 || height <= 0` for `type: "text"` (and `type: "image"`, which already guards upstream). This single check covers `penpot_add_text`, SVG-imported text, and library-artifact text. Throw with an actionable message naming the shape and the missing dimension rather than staging a degenerate box. ### 2. Don't let an omitted size become a stored zero (`text.ts`) — required Treat a supplied `0` as "not supplied" (matching `image.ts`'s `positiveDimension`), and when a dimension is genuinely omitted, compute a conservative estimate instead of `0`: - `height = line-height × font-size × paragraph-count` - `width = longest-line character count × font-size × ~0.58`, floored at roughly `8 × 16` Keep `growType: "auto-width"` / `"auto-height"` so Penpot still recomputes on first client layout; the estimate only ensures the committed box is non-degenerate. This mirrors the heuristic that successfully repaired the real file (report §7). ### 3. Pre-flight the staged batch in `penpot_commit` — recommended Between the preview (`src/tools/commit.ts:456`) and the `update-file` send (`:528`), scan staged `add-obj`/`mod-obj` shapes for text with zero/non-finite geometry and refuse with an actionable message, leaving the changeset staged. Defence in depth against any path not yet audited. ### 4. Make the schema and tests honest — required - `src/tools/text.ts` schema: `width`/`height` currently allow `minimum: 0`. Switch to `exclusiveMinimum: 0` or document that `0` is treated as omitted. - `src/tools/text.test.ts:350-361` **currently asserts the bug** (`change.obj.width === 0`, `height === 0`). Update it to assert non-degenerate geometry. - Add a `baseShape` unit test for the text zero-area refusal and the non-finite refusal. ## Acceptance criteria - [ ] `penpot_add_text` with no `width`/`height` stages a text shape whose `width` and `height` are both strictly positive, with `growType` still `auto-width`, and `selrect`/`points` derived from that non-zero box. - [ ] `penpot_add_text` with an explicit `width: 0` behaves as "omitted" and produces a positive estimate, not a zero box (or is refused with a clear message — decide and document). - [ ] `baseShape` throws for a `text` shape with `width <= 0` or `height <= 0`, and throws for any shape type with a non-finite `x`/`y`/`width`/`height`. - [ ] No staged `add-obj`/`mod-obj` for a text shape can reach `penpot_commit` with zero-area geometry; a pre-flight refusal names the offending shape and leaves the changeset staged. - [ ] The SVG-import text path and the library-artifact text path cannot stage a zero-area text shape. - [ ] A committed auto-width text shape reads back from `get-file` with non-zero `:width`/`:height` and four distinct `:points`. - [ ] Tool descriptions (`penpot_add_text`, README table) state that omitted dimensions are estimated, not left at zero. - [ ] `findings.md`'s "No auto-measurement is performed" gap is updated to reflect the guard (measured locally to a conservative estimate; Penpot still re-lays-out on open). ## Test plan - [ ] Unit: `baseShape` refuses zero-area text; refuses `NaN`/`Infinity` on any type; accepts a positive text box. - [ ] Unit: the text geometry estimator is deterministic and positive for single-line, multi-line, empty-ish, CJK and long-string inputs, and never returns `0`. - [ ] Unit: `penpot_add_text` (no dimensions, one dimension, both dimensions, `width: 0`) produces the expected positive geometry and `growType`. - [ ] Unit: commit pre-flight refuses a hand-built zero-area staged text change and leaves the changeset intact. - [ ] Regression: the updated `text.test.ts` case that previously asserted `width === 0` now asserts a positive width/height. - [ ] Live (opt-in, `PENPOT_URL` + `PENPOT_TOKEN` + scratch file): stage one auto-width text with no dimensions, `penpot_commit`, re-read via `get-file`, assert non-zero `:width`/`:height` and four distinct `:points`; then open the file in the browser and confirm an edit saves without a 500. - [ ] Live containment: reproduce the original failure shape by hand (stage `w0 h0` auto-width text outside the guard), confirm the guard blocks it before `update-file` is called. ## Out of scope / notes - **Not a Penpot bug we can fix here.** The report notes the server could reject `nil` geometry with an actionable message instead of degrading to `NaN`, and/or clamp `:auto-width` text to a minimum box. That is an upstream suggestion, not this issue. - **Do not reintroduce the derived-attribute trap.** The report's `gotcha #2` is specific to the nREPL repair path (`:ignore-geometry? true` emits `:points`/`:selrect` as operations, tripping the change validator). Our tools send complete shape objects and must keep letting `baseShape` derive geometry. - **Source material.** `penpot-bug-1e70e922-REPORT.md` and its bundle (`penpot-bug-1e70e922-raw.bin`, `…-decoded.transit.json`, `…-zero-size-shapes.edn`) live outside this repo in `~/Projects/penpot-skill/`; the pre-fix raw blob sha256 is `4b23873c8947e4547268cd9a47d0b4feb75d07399c2e057af1053b2834ce983e` and is the baseline to compare against.
david closed this issue 2026-09-17 04:41:02 +00:00
Author
Owner

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

pi-loop opened and merged a pull request for this issue: https://git.excelera.net/david/pi-extensions-and-skills/pulls/265
Sign in to join this conversation.
No milestone
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#252
No description provided.