Compare commits

...
Author SHA1 Message Date
Andrey Antukh 8b2991e13b Merge branch 'develop' into niwinz-performance-tests
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
2026-08-07 19:11:36 +02:00
Álvaro Tejero-CanteroandAndrey Antukh b5bec4f983 🐛 Declare new shape attributes in schemas to match stored files (#11125)
* 🐛 Declare the shape attributes stored files carry

`schema:shape-attrs` is the shape model as *declared*, and it has fallen
behind the `Shape` record. Three record fields are absent from it:
`rotation`, `flip-x` and `flip-y` are therefore present on every shape
that exists and declared nowhere. `rotation` is already named twice in
this namespace, in `allowed-shape-attrs`, and once in
`app.common.types.shape.attrs/editable-attrs`, so the schema is
demonstrably the odd one out rather than the data being unusual.

Nothing complains, because the maps are open: an undeclared key
validates fine. What breaks is everything that reads the model *from the
schema* rather than from a live value, such as the generative tests'
shape generator, the generated OpenAPI surface, and any consumer
reflecting over `schema:shape-attrs`.

Whether an entry is optional, nilable, or both is decided by the record
rather than by taste. `app.common.record/defrecord` cannot remove a base
field: its `without` assocs nil and its `containsKey` answers true
whatever the field holds, on both platforms. So a `Shape` base field is
always present, and nil is how that field says "unset". Every other key
lives in the `$extmap`, disappears on dissoc, and is dropped by
`setup-shape` when a caller passes nil. Base fields are therefore
nilable, and the rest are optional.

Declared here, measured over a 305-shape corpus:

- `rotation`, `flip-x` and `flip-y`, record fields present on every
  shape, nilable for the reason above: `make-minimal-shape` gives the
  two flip fields no default, so they are nil on all 305. Optional as
  well, unlike the geometry below, because `schema:shape-generic-attrs`
  has a second job: `check-shape-generic-attrs` validates partial update
  payloads with it, such as the `{:blocked true}` that
  `app.main.data.workspace/update-shape` passes, and a required key here
  would reject every such payload.
- `hide-in-viewer`, moved out of `schema:frame-attrs`, because circles,
  rects and texts carry it too, 197 shapes.
- `svg-attrs`, `svg-defs`, `svg-transform` and `svg-viewbox`, the SVG
  provenance an import leaves behind, 101 shapes and 63 for the
  transform. Typed `:map` rather than more precisely on purpose: legacy
  files hold `svg-transform` as a plain `{:a … :f}` map rather than a
  `::gmt/matrix` record, and `svg-viewbox` as either a `::grc/rect`
  record or a plain map, so a tighter schema would reject files that are
  otherwise valid.
- `use-for-thumbnail` on frames. The model has long had it:
  `app.common.files.migrations` renames `:use-for-thumbnail?` to it and
  `app.common.logic.libraries` reads it. This schema had not declared
  it.
- `rx` and `ry` on rects and circles, the legacy radii SVG import parses
  off the element and migration 0003 assocs as `0`. Superseded by `r1`
  to `r4`, but stored files carry them.
- `content` on svg-raw. `shapes-builder/create-raw-svg` sets it and
  `allowed-svg-attrs` names it. Typed `[:or :map :string]`, because a
  bare text node arrives as the string itself: `<text>hi</text>` becomes
  one svg-raw for the element and another for `"hi"`, and
  `shapes-builder/parse-svg-element` carries a FIXME about exactly that.

`schema:nilable-geom-attrs` is new, for bool and path. Those two are the
only shape types whose geometry can be nil: `make-minimal-shape` gives
`x`, `y`, `width` and `height` a default for every other type and skips
those two, whose extent their content and `selrect` imply instead. The
four keys stay required, as they already are in the other seven
branches, and only the nil is new.

**Do not make the analogous change to `ctf/schema:file`.** That map
carries `:backend`, `:comment-thread-seqn` and `:ignore-sync-until`,
none of which the schema declares, and declaring them breaks saving:
`app.binfile.common/update-file!` derives its UPDATE column list from a
file map's keys, and the `file` table has no `backend` column, it being
synthesized on read. Measured at 185 failures, mostly `rpc-file-test`.
Whether a schema serving as both read description and write contract is
itself a defect is a real design question, and a separate one. The
`check-shape-generic-attrs` case above is a second instance of it.

Adding entries changes what `shape-generator` produces, so generative
tests begin exercising code paths with these attributes present. That is
where a problem would surface. With this applied the common suite is
1142 tests and 24702 assertions on the Clojure side, 992 tests and 24017
assertions on the ClojureScript side, no failures on either.

AI-assisted-by: mixed models

*  Align shape generator with declared schema and add key-presence test

shape-generator now selects geometry attrs per-type: nilable-geom-attrs
for bool/path, shape-geom-attrs for everything else, and always merges
them. This removes the dead attrs2 generation for bool/path and the
implicit dependency on create-shape adding nil defaults for missing
base record fields.

The new shape-generator-key-presence test asserts that generated shapes
carry the required keys: rotation, flip-x, flip-y on all shapes and x,
y, width, height on bool/path, even when nilable.

AI-assisted-by: longcat-2.0-free

* 🐛 Sample 200 shapes in the key-presence test, not 10

`sg/sample` hands its options to `malli.generator/sample`, which reads
`:size`. `:num` is test.check's option. It is correct for the
`smt/check!` call directly above, where it came from, but `sg/sample`
ignores it and falls back to its default of 10.

Ten samples leave the bool and path assertions vacuous about one run in
fourteen. Simulated over 200 draws of 10, 14 contained no bool and no
path at all, and the median draw held 2. Those four assertions defend
exactly the keys this branch made required, so a run that skips them
silently is the one case worth not missing.

The assertion count shows the arithmetic. The test contributed 42 with
`:num`, which is 10 shapes times 3 keys plus 3 bool-or-path shapes times
4 keys, and contributes 756 with `:size`. The common suite goes from
1143 tests and 24744 assertions to 1143 tests and 25458 assertions, no
failures either way.

AI-assisted-by: mixed models

---------

Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-08-07 14:20:24 +02:00
Elena Torró 30bc2a4bc3 🔧 Add FF to enable wasm export at team level (#11130) 2026-08-07 12:36:52 +02:00
Alejandro Alonso 43b12bc4b9 Soft-drain GPU mid-walk on progressive Partials (#11127)
Release packs far more cheap Current draws (e.g. fills_none paths)
into one Partial than debug; a single end-of-Partial
flush_and_submit then stalls the browser. Soft-flush every N walker
nodes (and on Partial yield) keeps ops buffers bounded while Full
still submits via present_frame.
2026-08-07 09:45:39 +02:00
Andrey Antukh e1c51442cd Merge remote-tracking branch 'origin/staging' into develop 2026-08-07 09:10:25 +02:00
Andrey Antukh 88697794ce Merge remote-tracking branch 'origin/staging' into develop 2026-08-06 20:55:34 +02:00
Elena Torró 38b990ef90 🔧 Add exporter headless backend (#10875)
*  Add headless wasm render backend to the exporter

* ♻️ Move render-wasm bridge to common and split wasm builds

* 🔧 Upload builtin font variants in the wasm exporter

* ♻️ Move shared font and resources utils out of render_wasm

*  Fetch only the exported roots in the wasm exporter

*  Bound save_layer rects in the vector export path
2026-08-06 16:13:06 +02:00
Alejandro Alonso a76401596e Skip imperceptible shadows and simplify low-scale strokes (#11102)
*  Skip drop shadows that are imperceptible at current scale

Filter drop shadows by on-screen footprint (stricter for recursive
shapes) so overview HQ avoids expensive blur passes that barely show.

*  Simplify Path and Bool strokes at low scale

At overview zooms, Inner/Outer strokes fall back to Center and
dash/dotted styles become solid when the pattern is subpixel.
Strokes are never skipped so stroke-only icons stay visible.

*  Drain GPU work on partial render frames

Partial frames only flushed the Backbuffer, so tile GPU commands
queued until present_frame's flush_and_submit and stalled the
browser on large files. Submit the context each partial frame
without presenting Target or re-composing the tile atlas.

*  Prefer direct painting when effects are imperceptible

Skip the Fills/Strokes layered path when drop/inner shadows would
not paint at the current scale, and allow stroke-only shapes
(fills_none) on the direct path. Apply the same footprint LOD to
inner-shadow painting.
2026-08-06 15:58:11 +02:00
Belén Albeza de8d8ca401 🐛 Fix serialization of constraints (#11108) 2026-08-06 15:49:04 +02:00
Álvaro Tejero-Cantero 314a2a245f 📚 Fix the devenv backend-flags instructions (#11077)
The section pointed at `docker/devenv/docker-compose.yaml`, which #9906
deleted when it split the devenv compose into `docker-compose.infra.yml`
and `docker-compose.main.yml`. The same page names both replacements in
its architecture section, so only this one was missed.

Setting PENPOT_FLAGS in the container environment would not have worked
anyway: `backend/scripts/_env` expands the inherited value before its own
list, so its flags win. Document the mechanism that does work, the
gitignored `backend/scripts/_env.local` that `start-dev` sources right
after `_env`, and the left-to-right last-wins rule that lets an override
switch off a flag `_env` enables.
2026-08-06 14:14:44 +02:00
Andrey Antukh 614d619173 Merge remote-tracking branch 'origin/staging' into develop 2026-08-06 13:31:29 +02:00
Belén Albeza 2392015c63 🐛 Fix microinteractions on text shape selrects for autowidth/autoheight (#11068) 2026-08-06 12:40:57 +02:00
Alejandro Alonso 11fc090bc4 Expand direct shape painting and skip empty drop-shadow blits (#11100)
* ♻️ Extract apply_clip_stack_to_surfaces helper

Share the layered-path clip loop so the Current-surface direct
path can reuse the same hard-clip stack without duplication.

*  Expand direct shape painting onto Current

Allow clip stacks, frames, non-identity transforms, and SrcOver
opacity on the Current-surface fast path; skip empty non-masked
groups. Avoids Fills/Strokes blits for common shapes.

*  Skip empty drop-shadow blits; warm DropShadows once

Early-out drop-shadow composite when a shape has no visible
shadows, and touch DropShadows→Current once per tile instead
of per shape to keep flush_and_submit cheap.
2026-08-06 12:31:49 +02:00
Elena Torró 10a2c19f92 🔧 Improve text editor selection and tab conversion (#11071) 2026-08-06 09:43:02 +02:00
Alejandro Alonso 4b413299c2 Clear dirty flags after tile surface reset (#11095)
Marking intermediate surfaces dirty after clearing them on tile
context switch made the first stack composite blit empty
Fills/Strokes/shadows into Current. Dirty means content to
composite, so clear the flags after the clear instead.
2026-08-06 09:09:09 +02:00
Andrey Antukh 31c9ab4701 Merge remote-tracking branch 'origin/staging' into develop 2026-08-06 09:05:48 +02:00
Alejandro Alonso 8b64b0f84f Fix progressive render budget when timestamp is stale (#11094)
Pass performance.now from finalize/debounce and re-anchor the WASM
budget if the stamp is 0 or already past max_blocking_time, so HQ
tiles are not yielded after a few nodes with almost no real work.
2026-08-06 09:00:45 +02:00
Alejandro Alonso 649f4bebef Merge remote-tracking branch 'origin/staging' into develop 2026-08-06 08:38:15 +02:00
Andrey Antukh 5b26913cd3 Merge remote-tracking branch 'origin/staging' into develop 2026-08-05 17:30:41 +02:00
Andrey Antukh 36e76da26c Revert "🐛 Fix text creating on draft.js (#11086)"
This reverts commit 6df045b194.
2026-08-05 17:30:36 +02:00
Alejandro Alonso 35bdcde183 Avoid per-tile image_snapshot when filling atlases (#11093)
Copy Current into DocAtlas and the tile atlas with Surface::draw
instead of image_snapshot_with_bounds, matching the interactive
path and removing a GPU sync stall on every completed tile.
2026-08-05 17:12:09 +02:00
Eva Marco 6df045b194 🐛 Fix text creating on draft.js (#11086) 2026-08-05 13:10:21 +02:00
Belén Albeza 1b26b69b25 🐛 Fix Firefox not inserting emoji from MacOS Character Viewer (#11072) 2026-08-05 12:53:20 +02:00
Andrey Antukh 6f2bfb617c Merge remote-tracking branch 'origin/staging' into develop 2026-08-05 10:16:06 +02:00
David Barragán Merino 34702fd46b 🐳 Remove the configuration of the admin-console from Nginx if it is not enabled 2026-08-04 20:32:17 +02:00
Filip SajdakandAndrey Antukh 648c8e2152 🐛 Keep svg-raw children as uuids on binfile import (#10837)
Importing a .penpot file left every svg-raw subtree broken: the parent's
:shapes vector came back holding plain strings instead of uuids, so the
child ids no longer resolved against the page objects map. The next
persisted change touching that page then failed referential integrity
validation with :child-not-found, surfaced to the client as an HTTP 400
:referential-integrity error, which in practice bricks the file.

An svg-raw shape can be a container: importing an SVG builds a tree of
svg-raw shapes, and cfh/group-like-shape? explicitly treats an svg-raw
with children as group-like. But schema:svg-raw-attrs was an empty map.
Frame, group and bool all declare :shapes as a vector of uuid; svg-raw
did not, so the JSON decoder used by binfile had no type information for
those ids and left them as strings.

Declare :shapes on schema:svg-raw-attrs, optional because a leaf svg-raw
shape has no children, so the child ids decode back to uuids.
Closes #10496.

Signed-off-by: Filip Sajdak <filip.sajdak@siili.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-08-04 16:49:12 +02:00
7ae57a035f 🐛 Position overlays by frame selrect, not filter-inflated bounds (#10454)
calc-overlay-position measured the destination overlay frame with its full
object bounds (get-object-bounds) while measuring the relative-to frame with
its selrect. Object bounds include padding for shadows, blur, outer strokes
and overflowing children, so centered/right/bottom overlays were shifted by
half that extra padding when the overlay frame had such effects (the overlay
appeared offset, e.g. a bit to the left).

Use the destination frame selrect (the visible frame box) instead, which
matches the sibling helper calc-overlay-pos-initial and the viewer, which
reserves the bounds size and re-aligns the selrect separately. The now unused
geom.shapes.bounds require is removed.

Adds a regression test asserting calc-overlay-position returns the same
position with and without a bounds-inflating drop shadow on the destination
frame.

Fixes #9048

Signed-off-by: Filip Sajdak <filip.sajdak@siili.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-08-04 16:42:55 +02:00
Elena Torró 14a6ea5c52 🔧 Support text style shortcuts (#11002) 2026-08-04 15:30:26 +02:00
Andrey Antukh ca29f734c7 Merge remote-tracking branch 'origin/staging' into develop 2026-08-04 15:08:55 +02:00
David Barragán Merino 0811b1cda6 🔧 Generate the Docker image for the admin console by creating a tag 2026-08-04 11:59:45 +02:00
Andrey Antukh 6e843faba3 Merge remote-tracking branch 'origin/staging' into develop 2026-08-03 18:36:27 +02:00
Belén Albeza c6c8a38544 🐛 Fix not being able to add multiple fills to text spans (v3) (#10988) 2026-08-03 17:11:59 +02:00
Elena Torró 0fed63eeb3 🐛 Fix text replacement on selection and text offsets (#10983)
* 🐛 Fix text not being replaced when there is a selection

* 🐛 Fix text editor offsets on transformed text
2026-08-03 15:22:49 +02:00
Andrey Antukh 0835c51e11 Merge branch 'develop' into niwinz-performance-tests 2026-07-22 11:37:28 +02:00
Andrey Antukh 501284169c Merge branch 'develop' into niwinz-performance-tests 2026-07-20 10:20:05 +02:00
Andrey Antukh 8e739e01e1 Merge branch 'develop' into niwinz-performance-tests
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
2026-07-08 12:15:31 +02:00
Andrey Antukh 05fa07f911 Add k6 performance test suite for backend
- run.sh CLI orchestrator with per-script defaults and env-var override
- Shared penpot-client.js library (JSON RPC, cookie auth, tagged metrics)
- Scripts: lifecycle, workspace-open, workspace-edit, concurrent-edit,
  media-upload, font-upload, file-size-matrix, compare-results
- Concurrent-edit supports same-file and multi-file modes via shared teams
- CI workflow (perf-regression) comparing baseline vs PR branch
- k6 binary installed in devenv Dockerfile
2026-06-25 11:29:37 +02:00
112 changed files with 7474 additions and 743 deletions

No files matched your search

+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
with:
gh_ref: "develop"
build-admin-console-docker:
build-docker-admin-console:
uses: ./.github/workflows/build-docker-admin-console.yml
secrets: inherit
with:
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
with:
gh_ref: "staging"
build-admin-console-docker:
build-docker-admin-console:
uses: ./.github/workflows/build-docker-admin-console.yml
secrets: inherit
with:
+12 -2
View File
@@ -20,10 +20,18 @@ jobs:
with:
gh_ref: ${{ github.ref_name }}
build-docker-admin-console:
uses: ./.github/workflows/build-docker-admin-console.yml
secrets: inherit
with:
gh_ref: ${{ github.ref_name }}
notify:
name: Notifications
runs-on: ubuntu-24.04
needs: build-docker
needs:
- build-docker
- build-docker-admin-console
steps:
- name: Notify Mattermost
uses: mattermost/action-mattermost-notify@ae31bb6f9e26a54336e79696f108a2c91cf55b4e # v2.1.0
@@ -37,7 +45,9 @@ jobs:
publish-final-tag:
if: ${{ !contains(github.ref_name, '-RC') && !contains(github.ref_name, '-alpha') && !contains(github.ref_name, '-beta') && contains(github.ref_name, '.') }}
needs: build-docker
needs:
- build-docker
- build-docker-admin-console
uses: ./.github/workflows/release.yml
secrets: inherit
with:
+201
View File
@@ -0,0 +1,201 @@
name: "CI: Performance Regression"
defaults:
run:
shell: bash
on:
pull_request:
paths:
- 'backend/src/**'
- 'common/src/**'
types:
- opened
- synchronize
- ready_for_review
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
perf-regression:
if: ${{ !github.event.pull_request.draft }}
name: "Performance Regression Check"
runs-on: penpot-runner-02
container:
image: penpotapp/devenv:latest
volumes:
- /var/cache/github-runner/m2:/root/.m2
- /var/cache/github-runner/gitlib:/root/.gitlibs
services:
postgres:
image: postgres:17
env:
POSTGRES_USER: penpot
POSTGRES_PASSWORD: penpot
POSTGRES_DB: penpot
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: valkey/valkey:9
env:
PENPOT_DATABASE_URI: "postgresql://postgres/penpot"
PENPOT_DATABASE_USERNAME: penpot
PENPOT_DATABASE_PASSWORD: penpot
PENPOT_REDIS_URI: "redis://redis/1"
PENPOT_FLAGS: "enable-demo-users enable-backend-api-doc"
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Install k6
run: |
curl -sSL https://dl.k6.io/key.gpg | gpg --dearmor -o /usr/share/keyrings/k6-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/k6-archive-keyring.gpg] https://dl.k6.io/deb stable main" | tee /etc/apt/sources.list.d/k6.list
apt-get update
apt-get install -y k6
- name: Save performance suite
run: cp -r backend/performance /tmp/performance
- name: Cache Maven dependencies
uses: actions/cache@v4
with:
path: |
~/.m2
~/.gitlibs
key: ${{ runner.os }}-m2-${{ hashFiles('backend/deps.edn', 'common/deps.edn') }}
restore-keys: |
${{ runner.os }}-m2-
# -------------------------------------------------------------------------
# Run performance tests on BASE branch (before change)
# -------------------------------------------------------------------------
- name: Checkout base branch
run: |
git fetch origin ${{ github.event.pull_request.base.ref }}
git checkout origin/${{ github.event.pull_request.base.ref }}
- name: Restore performance suite (base)
run: cp -r /tmp/performance backend/performance
- name: Start backend (base branch)
working-directory: backend
run: |
clojure -M:dev -m app.main &
# Wait for backend to be ready
for i in $(seq 1 30); do
if curl -s http://localhost:6060/api/rpc/command/get-profile > /dev/null 2>&1; then
echo "Backend ready"
break
fi
echo "Waiting for backend... ($i/30)"
sleep 2
done
- name: Run performance tests (baseline)
working-directory: backend/performance
run: |
mkdir -p results/baseline
./run.sh smoke
./run.sh lifecycle -v 5 -n 10
cp -r results/latest/* results/baseline/ 2>/dev/null || true
- name: Save baseline results
run: cp -r backend/performance/results /tmp/results-baseline
- name: Stop backend
run: |
pkill -f "app.main" || true
sleep 2
- name: Clean untracked files
run: git clean -fd
# -------------------------------------------------------------------------
# Run performance tests on PR branch (after change)
# -------------------------------------------------------------------------
- name: Checkout PR branch
run: |
git checkout ${{ github.event.pull_request.head.sha }}
- name: Restore baseline results
run: cp -r /tmp/results-baseline backend/performance/results
- name: Start backend (PR branch)
working-directory: backend
run: |
clojure -M:dev -m app.main &
# Wait for backend to be ready
for i in $(seq 1 30); do
if curl -s http://localhost:6060/api/rpc/command/get-profile > /dev/null 2>&1; then
echo "Backend ready"
break
fi
echo "Waiting for backend... ($i/30)"
sleep 2
done
- name: Run performance tests (current)
working-directory: backend/performance
run: |
mkdir -p results/current
./run.sh smoke
./run.sh lifecycle -v 5 -n 10
# Copy results
cp -r results/latest/* results/current/ 2>/dev/null || true
- name: Stop backend
run: |
pkill -f "app.main" || true
sleep 2
# -------------------------------------------------------------------------
# Compare results
# -------------------------------------------------------------------------
- name: Compare results
working-directory: backend/performance
run: |
BASELINE=$(find results/baseline -name "k6-summary.json" | head -1)
CURRENT=$(find results/current -name "k6-summary.json" | head -1)
if [ -z "$BASELINE" ] || [ -z "$CURRENT" ]; then
echo "Warning: Could not find k6 summary files"
echo "Baseline: $BASELINE"
echo "Current: $CURRENT"
exit 0
fi
echo "Comparing:"
echo " Baseline: $BASELINE"
echo " Current: $CURRENT"
echo ""
node scripts/compare-results.cjs "$BASELINE" "$CURRENT" --threshold 20
# -------------------------------------------------------------------------
# Upload artifacts
# -------------------------------------------------------------------------
- name: Upload results
if: always()
uses: actions/upload-artifact@v4
with:
name: performance-results
path: backend/performance/results/
retention-days: 30
+4
View File
@@ -50,6 +50,7 @@ opencode.json
/backend/target/
/backend/experiments
/backend/scripts/_env.local
/backend/performance/results/
/bundle*
/clj-profiler/
/common/coverage
@@ -58,6 +59,8 @@ opencode.json
/docker/images/bundle*
/exporter/target
/exporter/.shadow-cljs
/exporter/resources/wasm/
/exporter/src/app/wasm/shared.js
/frontend/.storybook/preview-body.html
/frontend/.storybook/preview-head.html
/frontend/playwright-report/
@@ -105,3 +108,4 @@ opencode.json
/.ci-logs
/.codex/
/tools/__pycache__
/performance/results/
+128
View File
@@ -0,0 +1,128 @@
# Penpot Performance Tests
k6-based load and performance test suite for the Penpot backend. Measures HTTP RPC latency, throughput, and error rates under synthetic user load.
## Prerequisites
- **k6** — Install from https://k6.io/docs/get-started/installation/ (also included in `devenv` image)
- **Running Penpot backend** — Local devenv (`http://localhost:6060`) or a remote instance
## Quick Start
```bash
# Smoke test — 1 VU, 1 iteration, demo mode
./run.sh smoke
# Full lifecycle with 10 VUs, 5 iterations each
./run.sh lifecycle -v 10 -n 5
# Use registration flow instead of demo profiles
./run.sh lifecycle -m register -v 5 -n 1
# Point to a remote backend
./run.sh lifecycle -u https://penpot.example.com
# Show all options
./run.sh help
```
## Commands
| Command | Description |
|---|---|
| `smoke` | 1 VU, 1 iteration smoke test of the lifecycle flow |
| `lifecycle` | Full user lifecycle (register → CRUD → delete) |
| `workspace-open` | Read-heavy: repeatedly open a file (get-file, libraries, thumbnails) |
| `workspace-edit` | Write-heavy: repeatedly edit a file (get-file + update-file loop) |
| `media-upload` | Upload images of varying sizes (direct + chunked) |
| `font-upload` | Upload fonts via chunked upload + create-font-variant |
| `concurrent-edit` | Concurrent editing: same-file or multi-file mode |
| `file-size-matrix` | Measure latency vs file size (10, 100, 500, 1000 shapes) |
| `compare` | Compare two k6 JSON results for regression |
| `all` | Run all scenarios together (orchestrator) |
| `clean` | Remove test results |
## Options
| Flag | Env Variable | Default | Description |
|---|---|---|---|
| `-u URL` | `PENPOT_BASE_URL` | `http://localhost:6060` | Penpot backend URL |
| `-v NUM` | — | per-script default | Number of virtual users |
| `-n NUM` | — | per-script default | k6 iterations |
| `-d DUR` | `PENPOT_DURATION` | k6 default | Test duration (e.g. `30s`, `5m`, `2h`) |
| `-m MODE` | `PENPOT_REGISTER_MODE` | `demo` | Register mode: `demo` or `register` |
| `-k PATH` | `K6` | `k6` | Path to k6 binary |
### Concurrent-edit / file-size-matrix options
| Flag | Env Variable | Default | Description |
|---|---|---|---|
| `--mode MODE` | `PENPOT_EDIT_MODE` | `same-file` | `same-file` or `multi-file` |
| `--files NUM` | `PENPOT_FILE_COUNT` | `1` | Number of files for multi-file mode |
| `--vus-per-file NUM` | `PENPOT_VUS_PER_FILE` | `1` | VUs per file for multi-file mode |
| `--edit-iterations NUM` | `PENPOT_EDIT_ITERATIONS` | `10` | Per-VU edit loop iterations |
`--edit-iterations` controls the per-VU edit loop in both `concurrent-edit` and `file-size-matrix`. It is **independent** of `-n` (which controls k6's shared-iterations executor).
### Register Modes
- **`demo`** (default): Uses the `create-demo-profile` RPC endpoint. Requires the `demo-users` feature flag to be enabled on the backend. Fastest for testing.
- **`register`**: Uses the full two-step registration flow (`prepare-register-profile` + `register-profile`). Works without any feature flags but is slower.
## Examples
```bash
# Same-file concurrent edit: 5 VUs editing the same file
./run.sh concurrent-edit --mode same-file -v 5 -n 10 --edit-iterations 20
# Multi-file concurrent edit: 3 files, 4 VUs each
./run.sh concurrent-edit --mode multi-file --files 3 --vus-per-file 4 -n 10
# File size matrix: 50 iterations per size tier
./run.sh file-size-matrix --edit-iterations 50
# Duration-based test: 5 VUs for 30 seconds
./run.sh lifecycle -v 5 -d 30s
# Run all scenarios with 50 VUs
./run.sh all -v 50
# Compare baseline vs current results
./run.sh compare results/baseline/20250625-120000-lifecycle/k6-summary.json \
results/current/20250625-130000-lifecycle/k6-summary.json
```
## Shared Client (`lib/penpot-client.js`)
The shared client module wraps the Penpot backend RPC API using plain JSON (not Transit). Key features:
- **JSON transport**: Uses `Content-Type: application/json` for POST bodies and `Accept: application/json` (or `_fmt=json` for GET) for responses.
- **Cookie-based auth**: k6 automatically manages session cookies per VU.
- **Session headers**: Generates `x-session-id` and `x-external-session-id` UUIDs per VU.
- **Tagged metrics**: Every request is tagged with `rpc_command` for k6 metric slicing.
## Results
Test results are written to `results/<timestamp>/` as JSON. k6 also prints a summary to stdout with percentile breakdowns per RPC command.
## Thresholds
Each script includes built-in thresholds that cause k6 to exit with a non-zero code if exceeded:
- `http_req_duration p95 < 5000ms` (global)
- `http_req_failed < 1%` (global)
- Per-command thresholds for login, profile, project, file, and update operations
## Adding New Flows
1. Create `scripts/<flow-name>.js`
2. Import the shared client: `import { createClient } from "../lib/penpot-client.js";`
3. Implement the flow using the client methods
4. Add a command in `run.sh`
## Architecture Notes
- The backend supports both Transit JSON and plain JSON. This test suite uses **plain JSON** for simplicity (no Transit encoder needed in k6).
- JSON request keys are in **kebab-case** (matching Clojure conventions). JSON response keys are in **camelCase** (backend's default JSON encoding).
- `update-file` sends the `id` parameter both in the query string and in the POST body, matching the frontend's behavior.
- The backend uses optimistic concurrency control (`revn`) for file updates. The test retries once on conflict.
+621
View File
@@ -0,0 +1,621 @@
// Penpot k6 HTTP Client
//
// Shared module that wraps the Penpot backend RPC API using plain JSON.
// The backend supports `application/json` request bodies (kebab-case keys)
// and `application/json` responses (camelCase keys) via Accept header or _fmt=json.
//
// Authentication is cookie-based: login-with-password sets a session cookie,
// and all subsequent requests include it automatically via the k6 cookie jar.
import http from "k6/http";
import { uuidv4 } from "https://jslib.k6.io/k6-utils/1.4.0/index.js";
// ---------------------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------------------
/**
* Creates a new Penpot client instance.
*
* @param {string} baseUrl - The base URL of the Penpot backend (e.g., "http://localhost:6060")
* @returns {object} Client instance with RPC methods
*/
export function createClient(baseUrl) {
// Per-VU session identifiers — consistent across all requests within one VU iteration
const sessionId = uuidv4();
const externalSessionId = uuidv4();
const defaultHeaders = {
"Accept": "application/json",
"x-session-id": sessionId,
"x-external-session-id": externalSessionId,
"x-event-origin": "perf-test",
"x-client": "penpot-perf/1.0",
};
// k6 automatically manages cookies per VU when `cookies` are returned by the server.
// We use the default cookie jar which is per-VU.
/**
* Make an RPC call to the Penpot backend.
*
* GET requests: params go as query parameters, response is JSON via _fmt=json.
* POST requests: params go as JSON body, response is JSON via Accept header.
*
* @param {string} method - HTTP method ("GET" or "POST")
* @param {string} command - RPC command name (e.g., "login-with-password")
* @param {object} params - Parameters for the RPC call
* @param {object} [opts] - Additional options
* @param {string} [opts.tag] - k6 metric tag for this request
* @returns {object} k6 Response object with parsed JSON body
*/
function rpc(method, command, params = {}, opts = {}) {
const url = `${baseUrl}/api/main/methods/${command}`;
const tag = opts.tag || command;
const tags = {
rpc_command: tag,
};
if (method === "GET") {
// GET requests: params go as query string, add _fmt=json for JSON response
const queryParams = { ...params, _fmt: "json" };
const qs = Object.entries(queryParams)
.filter(([, v]) => v !== undefined && v !== null)
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
.join("&");
const fullUrl = qs ? `${url}?${qs}` : url;
return http.get(fullUrl, {
headers: defaultHeaders,
tags,
});
} else {
// POST requests: params go as JSON body
const headers = {
...defaultHeaders,
"Content-Type": "application/json",
};
return http.post(url, JSON.stringify(params), {
headers,
tags,
});
}
}
/**
* Login with email and password.
* Returns the profile data on success. The session cookie is stored
* automatically by k6's cookie jar.
*
* @param {string} email
* @param {string} password
* @returns {object} Parsed response { status, body }
*/
function login(email, password) {
const res = rpc("POST", "login-with-password", {
email,
password,
});
return {
status: res.status,
body: res.status === 200 ? res.json() : null,
raw: res,
};
}
/**
* Get the current user's profile (requires prior login).
*
* @returns {object} Parsed response { status, body }
*/
function getProfile() {
const res = rpc("GET", "get-profile");
return {
status: res.status,
body: res.status === 200 ? res.json() : null,
raw: res,
};
}
/**
* Get all teams for the current user.
*
* @returns {object} Parsed response { status, body }
*/
function getTeams() {
const res = rpc("GET", "get-teams");
return {
status: res.status,
body: res.status === 200 ? res.json() : null,
raw: res,
};
}
/**
* Create a new team.
*
* @param {string} name - Team name
* @returns {object} Parsed response { status, body }
*/
function createTeam(name) {
const res = rpc("POST", "create-team", { name });
return {
status: res.status,
body: res.status === 200 ? res.json() : null,
raw: res,
};
}
/**
* Get projects for a team.
*
* @param {string} teamId - Team UUID
* @returns {object} Parsed response { status, body }
*/
function getProjects(teamId) {
const res = rpc("GET", "get-projects", { "team-id": teamId });
return {
status: res.status,
body: res.status === 200 ? res.json() : null,
raw: res,
};
}
/**
* Create a new project.
*
* @param {string} teamId - Team UUID
* @param {string} name - Project name
* @returns {object} Parsed response { status, body }
*/
function createProject(teamId, name) {
const res = rpc("POST", "create-project", {
"team-id": teamId,
name,
});
return {
status: res.status,
body: res.status === 200 ? res.json() : null,
raw: res,
};
}
/**
* Create a new file in a project.
*
* @param {string} projectId - Project UUID
* @param {string} name - File name
* @returns {object} Parsed response { status, body }
*/
function createFile(projectId, name) {
const res = rpc("POST", "create-file", {
"project-id": projectId,
name,
});
return {
status: res.status,
body: res.status === 200 ? res.json() : null,
raw: res,
};
}
/**
* Get a file by ID.
*
* @param {string} fileId - File UUID
* @returns {object} Parsed response { status, body }
*/
function getFile(fileId) {
const res = rpc("GET", "get-file", {
id: fileId,
});
return {
status: res.status,
body: res.status === 200 ? res.json() : null,
raw: res,
};
}
/**
* Get libraries used by a file.
*
* @param {string} fileId - File UUID
* @returns {object} Parsed response { status, body }
*/
function getFileLibraries(fileId) {
const res = rpc("GET", "get-file-libraries", {
"file-id": fileId,
});
return {
status: res.status,
body: res.status === 200 ? res.json() : null,
raw: res,
};
}
/**
* Get object thumbnails for a file.
*
* @param {string} fileId - File UUID
* @returns {object} Parsed response { status, body }
*/
function getFileObjectThumbnails(fileId) {
const res = rpc("GET", "get-file-object-thumbnails", {
"file-id": fileId,
});
return {
status: res.status,
body: res.status === 200 ? res.json() : null,
raw: res,
};
}
/**
* Get file data for thumbnail generation.
*
* @param {string} fileId - File UUID
* @returns {object} Parsed response { status, body }
*/
function getFileDataForThumbnail(fileId) {
const res = rpc("GET", "get-file-data-for-thumbnail", {
"file-id": fileId,
});
return {
status: res.status,
body: res.status === 200 ? res.json() : null,
raw: res,
};
}
/**
* Update a file with changes.
*
* The backend uses optimistic concurrency control via `revn`.
* If a conflict occurs (status 400 with :revn-conflict), the caller
* should retry with the latest revn from getFile().
*
* @param {string} fileId - File UUID
* @param {number} revn - Current file revision number
* @param {number} vern - Current file version number
* @param {string} sessionId - Client session ID (UUID)
* @param {Array} changes - Array of change objects
* @returns {object} Parsed response { status, body }
*/
function updateFile(fileId, revn, vern, changesSessionId, changes) {
const params = {
id: fileId,
revn: revn,
vern: vern,
"session-id": changesSessionId,
origin: "workspace",
"created-at": new Date().toISOString(),
"commit-id": uuidv4(),
changes: changes,
};
// update-file uses POST with id also as query param (per frontend convention)
const url = `${baseUrl}/api/main/methods/update-file?id=${encodeURIComponent(fileId)}`;
const headers = {
...defaultHeaders,
"Content-Type": "application/json",
};
const res = http.post(url, JSON.stringify(params), {
headers,
tags: { rpc_command: "update-file" },
});
let body = null;
try {
if (res.body && res.body.length > 0) {
body = res.json();
}
} catch (e) {
// body may not be JSON
}
return {
status: res.status,
body: body,
raw: res,
};
}
/**
* Upload a file media object using direct multipart upload.
*
* @param {string} fileId - File UUID
* @param {Uint8Array} fileBytes - The file content
* @param {string} fileName - The file name
* @param {string} mimeType - MIME type (e.g., "image/png")
* @returns {object} Parsed response { status, body }
*/
function uploadFileMediaObjectDirect(fileId, fileBytes, fileName, mimeType) {
const url = `${baseUrl}/api/main/methods/upload-file-media-object`;
const headers = {
...defaultHeaders,
// No Content-Type — k6 sets it automatically for multipart/form-data
};
const formData = {
"file-id": fileId,
"is-local": "true",
name: fileName,
content: http.file(fileBytes, fileName, mimeType),
};
const res = http.post(url, formData, {
headers,
tags: { rpc_command: "upload-file-media-object" },
});
return {
status: res.status,
body: res.status === 200 ? res.json() : null,
raw: res,
};
}
// -----------------------------------------------------------------------
// Chunked upload
// -----------------------------------------------------------------------
/**
* Create an upload session for chunked uploads.
*
* @param {number} totalChunks - Number of chunks
* @returns {object} { status, sessionId }
*/
function createUploadSession(totalChunks) {
const res = rpc("POST", "create-upload-session", {
"total-chunks": totalChunks,
});
const body = res.status === 200 ? res.json() : null;
return {
status: res.status,
sessionId: body ? body.sessionId : null,
raw: res,
};
}
/**
* Upload a single chunk within an upload session.
*
* @param {string} sessionId - Upload session UUID
* @param {number} index - Chunk index (0-based)
* @param {Uint8Array} chunkBytes - The chunk content
* @param {string} fileName - Original file name
* @param {string} mimeType - MIME type
* @returns {object} Parsed response { status, body }
*/
function uploadChunk(sessionId, index, chunkBytes, fileName, mimeType) {
const url = `${baseUrl}/api/main/methods/upload-chunk`;
const headers = {
...defaultHeaders,
};
const formData = {
"session-id": sessionId,
index: String(index),
content: http.file(chunkBytes, fileName, mimeType),
};
const res = http.post(url, formData, {
headers,
tags: { rpc_command: "upload-chunk" },
});
return {
status: res.status,
body: res.status === 200 ? res.json() : null,
raw: res,
};
}
/**
* Assemble all uploaded chunks into a final media object.
*
* @param {string} sessionId - Upload session UUID
* @param {string} fileId - File UUID
* @param {string} name - Media object name
* @param {boolean} isLocal - Whether the object is local to the file
* @param {string} mimeType - MIME type (e.g., "image/png")
* @returns {object} Parsed response { status, body }
*/
function assembleFileMediaObject(sessionId, fileId, name, isLocal, mimeType) {
const res = rpc("POST", "assemble-file-media-object", {
"session-id": sessionId,
"file-id": fileId,
name: name,
"is-local": isLocal,
mtype: mimeType,
});
return {
status: res.status,
body: res.status === 200 ? res.json() : null,
raw: res,
};
}
// -----------------------------------------------------------------------
// Smart upload — picks direct or chunked based on file size
// -----------------------------------------------------------------------
// Chunk size threshold: files larger than this use chunked upload.
// The actual chunk size is irrelevant to the backend; this controls
// which upload path is exercised.
const CHUNK_SIZE = 50 * 1024; // 50 KB
/**
* Upload a file media object, automatically selecting direct or chunked
* upload based on file size.
*
* Files <= CHUNK_SIZE use direct multipart upload.
* Files > CHUNK_SIZE use chunked upload (create-upload-session →
* upload-chunk × N → assemble-file-media-object).
*
* @param {string} fileId - File UUID
* @param {Uint8Array} fileBytes - The file content
* @param {string} fileName - The file name
* @param {string} mimeType - MIME type (e.g., "image/png")
* @returns {object} Parsed response { status, body }
*/
function uploadFileMediaObject(fileId, fileBytes, fileName, mimeType) {
if (fileBytes.byteLength <= CHUNK_SIZE) {
return uploadFileMediaObjectDirect(fileId, fileBytes, fileName, mimeType);
}
// Chunked upload path
const totalChunks = Math.ceil(fileBytes.byteLength / CHUNK_SIZE);
const sessionRes = createUploadSession(totalChunks);
if (sessionRes.status !== 200) {
return { status: sessionRes.status, body: null, raw: sessionRes.raw };
}
const uploadSessionId = sessionRes.sessionId;
for (let i = 0; i < totalChunks; i++) {
const start = i * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, fileBytes.byteLength);
const chunk = fileBytes.slice(start, end);
const chunkRes = uploadChunk(uploadSessionId, i, chunk, fileName, mimeType);
if (chunkRes.status !== 200) {
return { status: chunkRes.status, body: null, raw: chunkRes.raw };
}
}
return assembleFileMediaObject(uploadSessionId, fileId, fileName, true, mimeType);
}
/**
* Delete a file.
*
* @param {string} fileId - File UUID
* @returns {object} Parsed response { status }
*/
function deleteFile(fileId) {
const res = rpc("POST", "delete-file", { id: fileId });
return {
status: res.status,
raw: res,
};
}
/**
* Delete a project.
*
* @param {string} projectId - Project UUID
* @returns {object} Parsed response { status }
*/
function deleteProject(projectId) {
const res = rpc("POST", "delete-project", { id: projectId });
return {
status: res.status,
raw: res,
};
}
/**
* Delete a team.
*
* @param {string} teamId - Team UUID
* @returns {object} Parsed response { status }
*/
function deleteTeam(teamId) {
const res = rpc("POST", "delete-team", { id: teamId });
return {
status: res.status,
raw: res,
};
}
/**
* Invite members to a team by email.
*
* @param {string} teamId - Team UUID
* @param {string[]} emails - Array of email addresses
* @param {string} role - Role for the invited members (e.g. "editor")
* @returns {object} Parsed response { status, body }
*/
function inviteTeamMembers(teamId, emails, role) {
const res = rpc("POST", "create-team-invitations", {
"team-id": teamId,
emails: emails,
role: role,
});
return {
status: res.status,
body: res.status === 200 ? res.json() : null,
raw: res,
};
}
/**
* Get an invitation token for a specific email.
*
* @param {string} teamId - Team UUID
* @param {string} email - Invited email address
* @returns {object} Parsed response { status, body }
*/
function getTeamInvitationToken(teamId, email) {
const res = rpc("GET", "get-team-invitation-token", {
"team-id": teamId,
email: email,
});
return {
status: res.status,
body: res.status === 200 ? res.json() : null,
raw: res,
};
}
/**
* Logout the current user.
*
* @param {string} profileId - Profile UUID
* @returns {object} Parsed response { status }
*/
function logout(profileId) {
const res = rpc("POST", "logout", { "profile-id": profileId });
return {
status: res.status,
raw: res,
};
}
// Return the client interface
return {
sessionId,
externalSessionId,
rpc,
login,
getProfile,
getTeams,
createTeam,
getProjects,
createProject,
createFile,
getFile,
getFileLibraries,
getFileObjectThumbnails,
getFileDataForThumbnail,
updateFile,
uploadFileMediaObject,
uploadFileMediaObjectDirect,
createUploadSession,
uploadChunk,
assembleFileMediaObject,
deleteFile,
deleteProject,
deleteTeam,
inviteTeamMembers,
getTeamInvitationToken,
logout,
};
}
+444
View File
@@ -0,0 +1,444 @@
#!/usr/bin/env bash
#
# Penpot Performance Tests
#
# k6-based load/performance test suite for the Penpot backend.
#
# Prerequisites:
# - k6 (https://k6.io/) installed and in PATH
# - A running Penpot backend (local devenv or remote)
#
# Usage:
# ./run.sh smoke # 1 VU, 1 iteration smoke test
# ./run.sh lifecycle # Full user lifecycle
# ./run.sh workspace-open # Read-heavy file open flow
# ./run.sh workspace-edit # Write-heavy file edit loop
# ./run.sh media-upload # Direct + chunked image uploads
# ./run.sh font-upload # Chunked font upload + variant creation
# ./run.sh concurrent-edit # Concurrent editing (same-file or multi-file)
# ./run.sh all # Run all scenarios together (orchestrator)
# ./run.sh clean # Remove test results
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# ---------------------------------------------------------------------------
# Defaults
# ---------------------------------------------------------------------------
BASE_URL="${PENPOT_BASE_URL:-http://localhost:6060}"
VUS=""
ITER=""
DURATION=""
REGISTER_MODE="${PENPOT_REGISTER_MODE:-demo}"
K6="${K6:-k6}"
EDIT_MODE="${PENPOT_EDIT_MODE:-same-file}"
FILE_COUNT="${PENPOT_FILE_COUNT:-1}"
VUS_PER_FILE="${PENPOT_VUS_PER_FILE:-1}"
EDIT_ITERATIONS=""
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
usage() {
cat <<EOF
Penpot Performance Tests
Usage:
$(basename "$0") <command> [options]
Commands:
smoke 1 VU, 1 iteration smoke test of the lifecycle flow
lifecycle Full user lifecycle (register → CRUD → delete)
workspace-open Read-heavy: repeatedly open a file (get-file, libraries, thumbnails)
workspace-edit Write-heavy: repeatedly edit a file (get-file + update-file loop)
media-upload Upload images of varying sizes (direct + chunked)
font-upload Upload fonts via chunked upload + create-font-variant
concurrent-edit Concurrent editing: same-file or multi-file mode
file-size-matrix Measure latency vs file size (10, 100, 500, 1000 shapes)
compare Compare two k6 JSON results for regression
all Run all scenarios together (orchestrator)
clean Remove test results
help Show this help
Options:
-u URL Backend base URL (default: $BASE_URL)
-v NUM Number of virtual users (default: per-script defaults)
-n NUM Iterations per VU (default: per-script defaults)
-d DURATION Test duration (e.g. 30s, 5m, 2h; default: k6 default)
-m MODE Register mode: 'demo' or 'register' (default: $REGISTER_MODE)
-k PATH Path to k6 binary (default: $K6)
Concurrent-edit options:
--mode MODE 'same-file' or 'multi-file' (default: $EDIT_MODE)
--files NUM Number of files for multi-file mode (default: $FILE_COUNT)
--vus-per-file NUM VUs per file for multi-file mode (default: $VUS_PER_FILE)
--edit-iterations NUM Per-VU edit loop iterations (concurrent-edit, file-size-matrix; default: 10)
Environment variables:
PENPOT_BASE_URL Same as -u
PENPOT_REGISTER_MODE Same as -m
PENPOT_EDIT_MODE Same as --mode
PENPOT_FILE_COUNT Same as --files
PENPOT_VUS_PER_FILE Same as --vus-per-file
PENPOT_EDIT_ITERATIONS Same as --edit-iterations
K6 Same as -k
PENPOT_DURATION Same as -d
Examples:
$(basename "$0") smoke
$(basename "$0") lifecycle -v 5 -n 10
$(basename "$0") workspace-edit -v 20 -n 50
$(basename "$0") media-upload -u https://penpot.example.com
$(basename "$0") concurrent-edit --mode same-file -v 5 -n 10
$(basename "$0") concurrent-edit --mode multi-file --files 3 --vus-per-file 2 -n 10
$(basename "$0") file-size-matrix -n 10
$(basename "$0") all -v 50
EOF
}
check_k6() {
if ! command -v "$K6" &>/dev/null; then
echo "Error: k6 not found at '$K6'" >&2
echo "Install from https://k6.io/docs/get-started/installation/" >&2
exit 1
fi
}
# Build k6 env flags
k6_env_flags() {
local flags="--env PENPOT_BASE_URL=$BASE_URL --env PENPOT_REGISTER_MODE=$REGISTER_MODE --env PENPOT_EDIT_MODE=$EDIT_MODE --env PENPOT_FILE_COUNT=$FILE_COUNT --env PENPOT_VUS_PER_FILE=$VUS_PER_FILE"
if [[ -n "${PENPOT_TOTAL_VUS:-}" ]]; then
flags="$flags --env PENPOT_TOTAL_VUS=$PENPOT_TOTAL_VUS"
fi
if [[ -n "$VUS" ]]; then
flags="$flags --env K6_VUS=$VUS"
fi
if [[ -n "$ITER" ]]; then
flags="$flags --env K6_ITERATIONS=$ITER"
fi
if [[ -n "$EDIT_ITERATIONS" ]]; then
flags="$flags --env PENPOT_EDIT_ITERATIONS=$EDIT_ITERATIONS"
fi
echo "$flags"
}
# Build k6 VU/iteration/duration flags (only if explicitly set)
k6_scale_flags() {
local flags=""
if [[ -n "$VUS" ]]; then
flags="$flags --vus $VUS"
fi
if [[ -n "$ITER" ]]; then
flags="$flags --iterations $ITER"
elif [[ -n "$VUS" && -z "$DURATION" ]]; then
# k6 requires iterations/duration/stages alongside --vus.
# When only -v is given, default iterations to VUs so
# iterations >= VUs (k6 constraint for shared-iterations).
flags="$flags --iterations $VUS"
fi
if [[ -n "$DURATION" ]]; then
flags="$flags --duration $DURATION"
fi
echo "$flags"
}
# Run a single k6 script
run_script() {
local script="$1"
local label="$2"
local results_dir="$SCRIPT_DIR/results/$(date +%Y%m%d-%H%M%S)-${label}"
mkdir -p "$results_dir"
echo ""
echo "=== $label ==="
echo " Script: scripts/${script}"
echo " Base URL: $BASE_URL"
echo " Register mode: $REGISTER_MODE"
[[ -n "$VUS" ]] && echo " VUs: $VUS"
[[ -n "$ITER" ]] && echo " Iterations: $ITER"
echo " Results: $results_dir"
echo ""
# shellcheck disable=SC2046
$K6 run \
$(k6_env_flags) \
$(k6_scale_flags) \
--out "json=$results_dir/k6-summary.json" \
"$SCRIPT_DIR/scripts/${script}"
}
# Run all scenarios as parallel k6 processes
run_all() {
local results_dir="$SCRIPT_DIR/results/$(date +%Y%m%d-%H%M%S)-all"
mkdir -p "$results_dir"
local default_vus="${VUS:-10}"
echo ""
echo "=== Penpot Performance Orchestrator ==="
echo " Base URL: $BASE_URL"
echo " Total VUs: $default_vus (distributed across flows)"
echo " Results: $results_dir"
echo ""
echo " Flow distribution:"
echo " lifecycle: 2 VUs (full CRUD)"
echo " workspace-open: 3 VUs (read-heavy)"
echo " workspace-edit: 3 VUs (write-heavy)"
echo " media-upload: 1 VU (storage I/O)"
echo " font-upload: 1 VU (CPU/storage)"
echo ""
local pids=()
# Lifecycle — full CRUD
$K6 run \
$(k6_env_flags) \
--vus 2 --iterations 2 \
--env "PENPOT_OPEN_ITERATIONS=3" \
--out "json=$results_dir/lifecycle.json" \
"$SCRIPT_DIR/scripts/lifecycle.js" &
pids+=($!)
# Workspace open — read-heavy
$K6 run \
$(k6_env_flags) \
--vus 3 --iterations 3 \
--env "PENPOT_OPEN_ITERATIONS=3" \
--out "json=$results_dir/workspace-open.json" \
"$SCRIPT_DIR/scripts/workspace-open.js" &
pids+=($!)
# Workspace edit — write-heavy
$K6 run \
$(k6_env_flags) \
--vus 3 --iterations 5 \
--env "PENPOT_EDIT_ITERATIONS=5" \
--out "json=$results_dir/workspace-edit.json" \
"$SCRIPT_DIR/scripts/workspace-edit.js" &
pids+=($!)
# Media upload
$K6 run \
$(k6_env_flags) \
--vus 1 --iterations 2 \
--out "json=$results_dir/media-upload.json" \
"$SCRIPT_DIR/scripts/media-upload.js" &
pids+=($!)
# Font upload
$K6 run \
$(k6_env_flags) \
--vus 1 --iterations 2 \
--out "json=$results_dir/font-upload.json" \
"$SCRIPT_DIR/scripts/font-upload.js" &
pids+=($!)
# Wait for all and collect exit codes
local failed=0
for pid in "${pids[@]}"; do
if ! wait "$pid"; then
failed=$((failed + 1))
fi
done
echo ""
if [[ $failed -gt 0 ]]; then
echo "WARNING: $failed flow(s) had non-zero exit codes"
fi
echo "Results saved to: $results_dir"
}
# ---------------------------------------------------------------------------
# Commands
# ---------------------------------------------------------------------------
cmd_smoke() {
check_k6
REGISTER_MODE=demo
VUS=1
ITER=1
run_script "lifecycle.js" "smoke"
}
cmd_lifecycle() { check_k6; run_script "lifecycle.js" "lifecycle"; }
cmd_workspace_open() { check_k6; run_script "workspace-open.js" "workspace-open"; }
cmd_workspace_edit() { check_k6; run_script "workspace-edit.js" "workspace-edit"; }
cmd_media_upload() { check_k6; run_script "media-upload.js" "media-upload"; }
cmd_font_upload() { check_k6; run_script "font-upload.js" "font-upload"; }
cmd_all() { check_k6; run_all; }
cmd_concurrent_edit() {
check_k6
local label="concurrent-edit-${EDIT_MODE}"
if [[ "$EDIT_MODE" == "multi-file" ]]; then
label="${label}-${FILE_COUNT}files-${VUS_PER_FILE}vpu"
fi
echo ""
echo "=== Concurrent Edit ($EDIT_MODE) ==="
echo " Mode: $EDIT_MODE"
if [[ "$EDIT_MODE" == "multi-file" ]]; then
echo " Files: $FILE_COUNT"
echo " VUs per file: $VUS_PER_FILE"
VUS=$((FILE_COUNT * VUS_PER_FILE))
echo " Total VUs: $VUS"
else
[[ -n "$VUS" ]] && echo " VUs: $VUS"
fi
[[ -n "$ITER" ]] && echo " Iterations: $ITER"
echo ""
# For same-file mode, pass VUS as PENPOT_TOTAL_VUS so setup() knows how many pages to create
if [[ "$EDIT_MODE" == "same-file" && -n "$VUS" ]]; then
export PENPOT_TOTAL_VUS="$VUS"
fi
run_script "workspace-edit-concurrent.js" "$label"
}
cmd_file_size_matrix() {
check_k6
echo ""
echo "=== File Size Matrix ==="
echo " Tiers: small(10), medium(100), large(500), xlarge(1000)"
[[ -n "$EDIT_ITERATIONS" ]] && echo " Iterations: $EDIT_ITERATIONS (per tier)"
echo ""
run_script "file-size-matrix.js" "file-size-matrix"
}
cmd_compare() {
local baseline="$1"
local current="$2"
local threshold="${3:-20}"
if [[ -z "$baseline" || -z "$current" ]]; then
echo "Usage: ./run.sh compare <baseline.json> <current.json> [threshold]"
echo ""
echo "Compare two k6 JSON results for performance regression."
echo ""
echo "Arguments:"
echo " baseline.json k6 JSON output from base branch"
echo " current.json k6 JSON output from PR branch"
echo " threshold Fail if p95 increases > N% (default: 20)"
exit 1
fi
if [[ ! -f "$baseline" ]]; then
echo "Error: Baseline file not found: $baseline" >&2
exit 1
fi
if [[ ! -f "$current" ]]; then
echo "Error: Current file not found: $current" >&2
exit 1
fi
node "$SCRIPT_DIR/scripts/compare-results.cjs" "$baseline" "$current" --threshold "$threshold"
}
cmd_clean() {
local results_dir="$SCRIPT_DIR/results"
if [[ -d "$results_dir" ]]; then
rm -rf "$results_dir"
echo "Cleaned $results_dir"
else
echo "Nothing to clean"
fi
}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
# Parse global options first (before command)
parse_opts() {
# First, extract long options (--mode, --files, --vus-per-file)
local args=()
while [[ $# -gt 0 ]]; do
case "$1" in
--mode)
EDIT_MODE="$2"
shift 2
;;
--files)
FILE_COUNT="$2"
shift 2
;;
--vus-per-file)
VUS_PER_FILE="$2"
shift 2
;;
--edit-iterations)
EDIT_ITERATIONS="$2"
shift 2
;;
*)
args+=("$1")
shift
;;
esac
done
# Apply PENPOT_DURATION env var as default (before CLI parsing takes precedence)
if [[ -z "$DURATION" && -n "${PENPOT_DURATION:-}" ]]; then
DURATION="$PENPOT_DURATION"
fi
# Now parse short options with getopts
set -- "${args[@]}"
OPTIND=1
while getopts "u:v:n:d:m:k:h" opt; do
case "$opt" in
u) BASE_URL="$OPTARG" ;;
v) VUS="$OPTARG" ;;
n) ITER="$OPTARG" ;;
d) DURATION="$OPTARG" ;;
m) REGISTER_MODE="$OPTARG" ;;
k) K6="$OPTARG" ;;
h) usage; exit 0 ;;
*) usage >&2; exit 1 ;;
esac
done
}
if [[ $# -lt 1 ]]; then
usage >&2
exit 1
fi
command="$1"
shift
# Parse options for flow commands (not smoke/clean/help/all)
case "$command" in
smoke|clean|help|-h|--help)
;;
*)
parse_opts "$@"
;;
esac
case "$command" in
smoke) cmd_smoke ;;
lifecycle) cmd_lifecycle ;;
workspace-open) cmd_workspace_open ;;
workspace-edit) cmd_workspace_edit ;;
media-upload) cmd_media_upload ;;
font-upload) cmd_font_upload ;;
concurrent-edit) cmd_concurrent_edit ;;
file-size-matrix) cmd_file_size_matrix ;;
compare) cmd_compare "$@" ;;
all) cmd_all ;;
clean) cmd_clean ;;
help|-h|--help) usage ;;
*)
echo "Unknown command: $command" >&2
usage >&2
exit 1
;;
esac
@@ -0,0 +1,270 @@
#!/usr/bin/env node
//
// compare-results.js
//
// Compares two k6 JSON output files and reports performance regressions.
// Used for relative comparison: base branch vs PR branch in the same CI run.
//
// Usage:
// node scripts/compare-results.js <baseline.json> <current.json>
// node scripts/compare-results.js <baseline.json> <current.json> --threshold 20
//
// Exit codes:
// 0 - No regressions detected
// 1 - Regression detected (p95 increased > threshold)
// 2 - Error (invalid input, missing file, etc.)
const fs = require("fs");
const path = require("path");
// ---------------------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------------------
const DEFAULT_THRESHOLD = 20; // Fail if p95 increases > 20%
const CRITICAL_COMMANDS = [
"get-file",
"update-file",
"login-with-password",
"create-demo-profile",
"get-file-libraries",
"get-file-object-thumbnails",
];
// ---------------------------------------------------------------------------
// Parse k6 JSON output
// ---------------------------------------------------------------------------
function parseK6Json(filePath) {
const content = fs.readFileSync(filePath, "utf-8");
const lines = content.trim().split("\n");
// Collect all http_req_duration points with rpc_command tag
const durations = {}; // { rpc_command: [value, ...] }
for (const line of lines) {
try {
const entry = JSON.parse(line);
if (
entry.type === "Point" &&
entry.metric === "http_req_duration" &&
entry.data?.tags?.rpc_command
) {
const cmd = entry.data.tags.rpc_command;
const value = entry.data.value;
if (!durations[cmd]) {
durations[cmd] = [];
}
durations[cmd].push(value);
}
} catch (e) {
// Skip malformed lines
}
}
return durations;
}
// ---------------------------------------------------------------------------
// Calculate percentiles
// ---------------------------------------------------------------------------
function percentile(values, p) {
if (values.length === 0) return 0;
const sorted = values.slice().sort((a, b) => a - b);
const index = Math.ceil((p / 100) * sorted.length) - 1;
return sorted[Math.max(0, index)];
}
function calculateStats(values) {
if (values.length === 0) {
return { count: 0, p50: 0, p95: 0, p99: 0, min: 0, max: 0, avg: 0 };
}
const sorted = values.slice().sort((a, b) => a - b);
const sum = values.reduce((a, b) => a + b, 0);
return {
count: values.length,
p50: percentile(values, 50),
p95: percentile(values, 95),
p99: percentile(values, 99),
min: sorted[0],
max: sorted[sorted.length - 1],
avg: sum / values.length,
};
}
// ---------------------------------------------------------------------------
// Compare two results
// ---------------------------------------------------------------------------
function compareResults(baseline, current, threshold) {
const results = [];
const allCommands = new Set([
...Object.keys(baseline),
...Object.keys(current),
]);
for (const cmd of allCommands) {
const baseStats = calculateStats(baseline[cmd] || []);
const currStats = calculateStats(current[cmd] || []);
// Calculate p95 change percentage
let p95Change = 0;
if (baseStats.p95 > 0) {
p95Change = ((currStats.p95 - baseStats.p95) / baseStats.p95) * 100;
} else if (currStats.p95 > 0) {
p95Change = 100; // New command with latency
}
const isCritical = CRITICAL_COMMANDS.includes(cmd);
const isRegression = p95Change > threshold;
results.push({
command: cmd,
isCritical,
baseline: baseStats,
current: currStats,
p95Change: Math.round(p95Change * 100) / 100,
isRegression,
});
}
// Sort: regressions first, then by p95 change descending
results.sort((a, b) => {
if (a.isRegression !== b.isRegression) return b.isRegression - a.isRegression;
return b.p95Change - a.p95Change;
});
return results;
}
// ---------------------------------------------------------------------------
// Print report
// ---------------------------------------------------------------------------
function printReport(results, threshold) {
console.log("\n=== Performance Regression Report ===\n");
console.log(`Threshold: p95 increase > ${threshold}%\n`);
// Print table header
const header = [
"Command".padEnd(30),
"Baseline p95".padStart(12),
"Current p95".padStart(12),
"Change".padStart(10),
"Status".padStart(10),
].join(" | ");
console.log(header);
console.log("-".repeat(header.length));
// Print results
for (const r of results) {
const baseP95 = `${Math.round(r.baseline.p95)}ms`;
const currP95 = `${Math.round(r.current.p95)}ms`;
const change = `${r.p95Change > 0 ? "+" : ""}${r.p95Change}%`;
const status = r.isRegression ? "FAIL" : "OK";
const critical = r.isCritical ? " *" : "";
const row = [
(r.command + critical).padEnd(30),
baseP95.padStart(12),
currP95.padStart(12),
change.padStart(10),
status.padStart(10),
].join(" | ");
console.log(row);
}
// Print legend
console.log("\n* = Critical command (always checked)");
// Print regressions summary
const regressions = results.filter((r) => r.isRegression);
if (regressions.length > 0) {
console.log(`\n❌ REGRESSION DETECTED: ${regressions.length} command(s) exceeded threshold`);
for (const r of regressions) {
console.log(` - ${r.command}: p95 ${Math.round(r.baseline.p95)}ms → ${Math.round(r.current.p95)}ms (+${r.p95Change}%)`);
}
} else {
console.log("\n✅ No regressions detected");
}
return regressions.length;
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
function main() {
const args = process.argv.slice(2);
// Parse arguments
let baselineFile = null;
let currentFile = null;
let threshold = DEFAULT_THRESHOLD;
for (let i = 0; i < args.length; i++) {
if (args[i] === "--threshold" && args[i + 1]) {
threshold = parseInt(args[i + 1], 10);
i++;
} else if (!baselineFile) {
baselineFile = args[i];
} else if (!currentFile) {
currentFile = args[i];
}
}
// Validate arguments
if (!baselineFile || !currentFile) {
console.error("Usage: node compare-results.js <baseline.json> <current.json> [--threshold N]");
console.error("");
console.error("Arguments:");
console.error(" baseline.json k6 JSON output from base branch");
console.error(" current.json k6 JSON output from PR branch");
console.error(" --threshold N Fail if p95 increases > N% (default: 20)");
process.exit(2);
}
// Check files exist
if (!fs.existsSync(baselineFile)) {
console.error(`Error: Baseline file not found: ${baselineFile}`);
process.exit(2);
}
if (!fs.existsSync(currentFile)) {
console.error(`Error: Current file not found: ${currentFile}`);
process.exit(2);
}
// Parse files
console.log(`Parsing baseline: ${path.basename(baselineFile)}`);
const baseline = parseK6Json(baselineFile);
const baseCommands = Object.keys(baseline).length;
console.log(` Found ${baseCommands} RPC commands`);
console.log(`Parsing current: ${path.basename(currentFile)}`);
const current = parseK6Json(currentFile);
const currCommands = Object.keys(current).length;
console.log(` Found ${currCommands} RPC commands`);
if (baseCommands === 0 && currCommands === 0) {
console.error("Error: No RPC command data found in either file");
process.exit(2);
}
// Compare and report
const results = compareResults(baseline, current, threshold);
const regressionCount = printReport(results, threshold);
// Exit with appropriate code
process.exit(regressionCount > 0 ? 1 : 0);
}
main();
@@ -0,0 +1,249 @@
// File Size Matrix Performance Test
//
// Measures how update-file and get-file latency scales with file size.
// Creates files with different shape counts (10, 100, 500, 1000) and
// benchmarks operations on each.
//
// Usage:
// k6 run scripts/file-size-matrix.js
// k6 run --iterations 10 scripts/file-size-matrix.js
// ./run.sh file-size-matrix
// ./run.sh file-size-matrix -n 10
import { check, sleep, fail } from "k6";
import { uuidv4 } from "https://jslib.k6.io/k6-utils/1.4.0/index.js";
import { createClient } from "../lib/penpot-client.js";
// ---------------------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------------------
const BASE_URL = __ENV.PENPOT_BASE_URL || "http://localhost:6060";
const ITERATIONS_PER_TIER = parseInt(__ENV.PENPOT_EDIT_ITERATIONS || "5");
// Shape tiers
const TIERS = [
{ name: "small", shapes: 10, color: "#ff0000" },
{ name: "medium", shapes: 100, color: "#00ff00" },
{ name: "large", shapes: 500, color: "#0000ff" },
{ name: "xlarge", shapes: 1000, color: "#ff00ff" },
];
export const options = {
thresholds: {
http_req_duration: ["p(95)<10000"],
http_req_failed: ["rate<0.01"],
},
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function assertOk(res, label) {
const ok = check(res, {
[`${label} — status is 2xx`]: (r) => r.status >= 200 && r.status < 300,
});
if (!ok) {
let bodyStr = "";
try {
if (res.raw && res.raw.body) {
bodyStr = typeof res.raw.body === "string"
? res.raw.body.substring(0, 500)
: JSON.stringify(res.raw.body).substring(0, 500);
} else if (res.body) {
bodyStr = JSON.stringify(res.body).substring(0, 500);
}
} catch (e) {
bodyStr = "(could not read body)";
}
console.error(`FAIL: ${label} — status=${res.status} body=${bodyStr}`);
}
return ok;
}
function makeAddRectChange(pageId, index, color) {
const shapeId = uuidv4();
const x = 50 + (index % 20) * 30;
const y = 50 + Math.floor(index / 20) * 30;
const w = 100;
const h = 80;
return {
type: "add-obj",
pageId: pageId,
id: shapeId,
frameId: pageId,
parentId: pageId,
obj: {
id: shapeId, type: "rect", name: `Shape ${index}`,
x, y, width: w, height: h,
fillColor: color, fillOpacity: 0.8,
rotation: 0, hidden: false, locked: false,
selrect: { x, y, width: w, height: h, x1: x, y1: y, x2: x + w, y2: y + h },
points: [
{ x, y }, { x: x + w, y }, { x: x + w, y: y + h }, { x, y: y + h },
],
transform: { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 },
transformInverse: { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 },
parentId: pageId, frameId: pageId,
},
};
}
// Populate a file with N shapes in a single update-file call
function populateFile(client, fileId, pageId, shapeCount, color) {
// Get current file state
const getFileRes = client.getFile(fileId);
if (getFileRes.status !== 200) return null;
let { revn, vern } = getFileRes.body;
// Add shapes in batches (backend may have limits on changes per call)
const BATCH_SIZE = 100;
let added = 0;
while (added < shapeCount) {
const batchCount = Math.min(BATCH_SIZE, shapeCount - added);
const changes = [];
for (let i = 0; i < batchCount; i++) {
changes.push(makeAddRectChange(pageId, added + i, color));
}
const updateRes = client.updateFile(fileId, revn, vern, client.sessionId, changes);
if (updateRes.status !== 200) {
console.error(`Failed to add batch at ${added}: ${JSON.stringify(updateRes.body)}`);
return null;
}
// update-file returns {revn, lagged} but not vern
// vern only changes on snapshot restore, so keep the original
revn = updateRes.body.revn;
added += batchCount;
}
return { revn, vern };
}
// ---------------------------------------------------------------------------
// Setup — create files with different shape counts
// ---------------------------------------------------------------------------
export function setup() {
console.log(`File Size Matrix Test`);
console.log(` Base URL: ${BASE_URL}`);
console.log(` Iterations/tier: ${ITERATIONS_PER_TIER}`);
console.log(` Tiers: ${TIERS.map(t => `${t.name}(${t.shapes})`).join(", ")}`);
console.log(``);
const client = createClient(BASE_URL);
if (client.getProfile().status === 0) fail(`Backend unreachable at ${BASE_URL}`);
// Create demo profile
const userRes = client.rpc("POST", "create-demo-profile", {});
if (userRes.status !== 200) fail("Failed to create demo profile");
const user = userRes.json();
console.log(` Created demo profile: ${user.email}`);
// Login
if (client.login(user.email, user.password).status !== 200) fail("Login failed");
const teamId = client.getTeams().body[0].id;
const projectId = client.createProject(teamId, "File Size Matrix Project").body.id;
console.log(` Project: ${projectId}`);
// Create and populate files for each tier
const tiers = [];
for (const tier of TIERS) {
console.log(`\n Creating ${tier.name} file (${tier.shapes} shapes)...`);
// Create file
const fileRes = client.createFile(projectId, `Matrix ${tier.name} (${tier.shapes} shapes)`);
if (fileRes.status !== 200) fail(`Failed to create ${tier.name} file`);
const fileId = fileRes.body.id;
// Get page ID
const getFileRes = client.getFile(fileId);
if (getFileRes.status !== 200) fail(`Failed to get ${tier.name} file`);
const pageId = getFileRes.body.data.pages[0];
// Populate with shapes
const startTime = Date.now();
const result = populateFile(client, fileId, pageId, tier.shapes, tier.color);
if (!result) fail(`Failed to populate ${tier.name} file`);
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
console.log(` ${tier.name}: ${tier.shapes} shapes in ${elapsed}s (revn=${result.revn})`);
tiers.push({
name: tier.name,
shapes: tier.shapes,
fileId,
pageId,
revn: result.revn,
vern: result.vern,
});
}
console.log(`\n Setup complete. ${tiers.length} files ready.`);
return { baseUrl: BASE_URL, user, tiers, iterationsPerTier: ITERATIONS_PER_TIER };
}
// ---------------------------------------------------------------------------
// Main VU Function — benchmark each tier
// ---------------------------------------------------------------------------
export default function (data) {
const client = createClient(data.baseUrl);
// Login
if (!assertOk(client.login(data.user.email, data.user.password), "login")) fail("login failed");
sleep(0.5);
console.log(`\n=== Starting benchmark (${data.iterationsPerTier} iterations per tier) ===\n`);
// Benchmark each tier
for (const tier of data.tiers) {
console.log(`--- Tier: ${tier.name} (${tier.shapes} shapes) ---`);
// Get latest file state
const getFileRes = client.getFile(tier.fileId);
if (!assertOk(getFileRes, `get-file-${tier.name}`)) continue;
let { revn, vern } = getFileRes.body;
for (let i = 0; i < data.iterationsPerTier; i++) {
// Benchmark get-file
const getRes = client.getFile(tier.fileId);
if (!assertOk(getRes, `get-file-${tier.name}`)) continue;
sleep(0.2);
// Benchmark update-file (add 1 shape)
const change = makeAddRectChange(tier.pageId, tier.shapes + i, "#ffaa00");
const updateRes = client.updateFile(tier.fileId, getRes.body.revn, getRes.body.vern, client.sessionId, [change]);
if (updateRes.status !== 200) {
console.error(`update-file failed on ${tier.name} iteration ${i}: ${JSON.stringify(updateRes.body)}`);
continue;
}
// update-file returns {revn, lagged} but not vern
// vern only changes on snapshot restore, so keep the original
revn = updateRes.body.revn;
sleep(0.3);
}
console.log(` Completed ${data.iterationsPerTier} iterations on ${tier.name}`);
}
console.log(`\n=== Benchmark complete ===`);
}
// ---------------------------------------------------------------------------
// Teardown
// ---------------------------------------------------------------------------
export function teardown(data) {
console.log(`File size matrix test complete.`);
}
+158
View File
@@ -0,0 +1,158 @@
// Font Upload Performance Test
//
// Tests the font upload flow: chunked upload of TTF + OTF files followed by
// creating a font variant. Exercises storage pipeline and font processing.
//
// setup() creates N demo profiles.
// Each VU picks its user, uploads fonts, and creates a variant.
//
// Usage:
// k6 run scripts/font-upload.js
// k6 run --vus 50 --iterations 5 scripts/font-upload.js
import { check, sleep, fail } from "k6";
import { uuidv4 } from "https://jslib.k6.io/k6-utils/1.4.0/index.js";
import { createClient } from "../lib/penpot-client.js";
// ---------------------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------------------
const BASE_URL = __ENV.PENPOT_BASE_URL || "http://localhost:6060";
export const options = {
thresholds: {
http_req_duration: ["p(95)<15000"],
http_req_failed: ["rate<0.01"],
"http_req_duration{rpc_command:create-upload-session}": ["p(95)<1000"],
"http_req_duration{rpc_command:upload-chunk}": ["p(95)<5000"],
"http_req_duration{rpc_command:create-font-variant}": ["p(95)<10000"],
},
};
// ---------------------------------------------------------------------------
// Test Data
// ---------------------------------------------------------------------------
const fontTtf = open("../../test/backend_tests/test_files/font-1.ttf", "b");
const fontOtf = open("../../test/backend_tests/test_files/font-1.otf", "b");
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function assertOk(res, label) {
const ok = check(res, {
[`${label} — status is 2xx`]: (r) => r.status >= 200 && r.status < 300,
});
if (!ok) {
let bodyStr = "";
try {
if (res.raw && res.raw.body) {
bodyStr = typeof res.raw.body === "string"
? res.raw.body.substring(0, 500)
: JSON.stringify(res.raw.body).substring(0, 500);
} else if (res.body) {
bodyStr = JSON.stringify(res.body).substring(0, 500);
}
} catch (e) {
bodyStr = "(could not read body)";
}
console.error(`FAIL: ${label} — status=${res.status} body=${bodyStr}`);
}
return ok;
}
// ---------------------------------------------------------------------------
// Setup — create user pool
// ---------------------------------------------------------------------------
export function setup() {
const vuCount = parseInt(__ENV.K6_VUS) || 1;
console.log(`Penpot Font Upload Test`);
console.log(` Base URL: ${BASE_URL}`);
console.log(` VUs: ${vuCount}`);
console.log(``);
const client = createClient(BASE_URL);
if (client.getProfile().status === 0) fail(`Backend unreachable at ${BASE_URL}`);
const users = [];
for (let i = 0; i < vuCount; i++) {
const res = client.rpc("POST", "create-demo-profile", {});
if (res.status !== 200) fail(`Failed to create demo profile ${i + 1}/${vuCount}`);
users.push(res.json());
}
console.log(` Created ${users.length} demo profiles`);
return { baseUrl: BASE_URL, users };
}
// ---------------------------------------------------------------------------
// Main VU Function
// ---------------------------------------------------------------------------
export default function (data) {
const client = createClient(data.baseUrl);
// Pick user from pool
const user = data.users[__VU - 1];
if (!user) fail(`No user for VU ${__VU}`);
// Login
if (!assertOk(client.login(user.email, user.password), "login")) fail("login failed");
const teamId = client.getTeams().body[0].id;
sleep(0.5);
const fontId = uuidv4();
const fontFamily = `PerfFont-${uuidv4().substring(0, 8)}`;
const chunkSize = 50 * 1024; // 50 KB
// Upload TTF via chunked upload
const ttfChunks = Math.ceil(fontTtf.byteLength / chunkSize);
const ttfSessionRes = client.createUploadSession(ttfChunks);
if (!assertOk(ttfSessionRes, "create-upload-session (ttf)")) fail("create-upload-session failed");
const ttfSessionId = ttfSessionRes.sessionId;
for (let i = 0; i < ttfChunks; i++) {
const chunk = fontTtf.slice(i * chunkSize, Math.min((i + 1) * chunkSize, fontTtf.byteLength));
if (!assertOk(client.uploadChunk(ttfSessionId, i, chunk, "font-1.ttf", "font/ttf"), `upload-chunk ttf ${i}`)) fail("ttf chunk failed");
sleep(0.1);
}
// Upload OTF via chunked upload
const otfChunks = Math.ceil(fontOtf.byteLength / chunkSize);
const otfSessionRes = client.createUploadSession(otfChunks);
if (!assertOk(otfSessionRes, "create-upload-session (otf)")) fail("create-upload-session (otf) failed");
const otfSessionId = otfSessionRes.sessionId;
for (let i = 0; i < otfChunks; i++) {
const chunk = fontOtf.slice(i * chunkSize, Math.min((i + 1) * chunkSize, fontOtf.byteLength));
if (!assertOk(client.uploadChunk(otfSessionId, i, chunk, "font-1.otf", "font/otf"), `upload-chunk otf ${i}`)) fail("otf chunk failed");
sleep(0.1);
}
sleep(0.5);
// Create font variant
if (!assertOk(client.rpc("POST", "create-font-variant", {
"team-id": teamId,
"font-id": fontId,
"font-family": fontFamily,
"font-weight": 400,
"font-style": "normal",
uploads: { "font/ttf": ttfSessionId, "font/otf": otfSessionId },
}), "create-font-variant")) fail("create-font-variant failed");
console.log(`VU ${__VU}: Font "${fontFamily}" created`);
}
// ---------------------------------------------------------------------------
// Teardown
// ---------------------------------------------------------------------------
export function teardown(data) {
console.log("Font upload test complete.");
}
+260
View File
@@ -0,0 +1,260 @@
// Lifecycle Performance Test
//
// Simulates a realistic user lifecycle from registration through CRUD operations.
// Each VU performs the full flow independently, creating its own artifacts.
//
// setup() creates a user pool (one demo profile per VU) before measurements begin.
// Each VU picks its assigned user to login — no profile creation during the test.
//
// Flow:
// 1. Login (with pre-existing user from pool)
// 2. Get profile & teams
// 3. Create project
// 4. Create file
// 5. Get file
// 6. Update file (add a shape)
// 7. Upload images (direct + chunked)
// 8. Delete file
// 9. Delete project
// 10. Logout
//
// Usage:
// k6 run scripts/lifecycle.js
// k6 run --vus 100 --iterations 100 scripts/lifecycle.js
// k6 run --env PENPOT_BASE_URL=http://localhost:6060 scripts/lifecycle.js
import { check, sleep, fail } from "k6";
import { uuidv4 } from "https://jslib.k6.io/k6-utils/1.4.0/index.js";
import { createClient } from "../lib/penpot-client.js";
// ---------------------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------------------
const BASE_URL = __ENV.PENPOT_BASE_URL || "http://localhost:6060";
// k6 options — VUs and iterations set via run.sh (--vus, --iterations)
export const options = {
thresholds: {
http_req_duration: ["p(95)<5000"],
http_req_failed: ["rate<0.01"],
"http_req_duration{rpc_command:login-with-password}": ["p(95)<1000"],
"http_req_duration{rpc_command:get-profile}": ["p(95)<500"],
"http_req_duration{rpc_command:create-project}": ["p(95)<1000"],
"http_req_duration{rpc_command:create-file}": ["p(95)<1000"],
"http_req_duration{rpc_command:get-file}": ["p(95)<500"],
"http_req_duration{rpc_command:update-file}": ["p(95)<2000"],
"http_req_duration{rpc_command:delete-file}": ["p(95)<1000"],
},
};
// ---------------------------------------------------------------------------
// Test Data
// ---------------------------------------------------------------------------
const testImageSmall = open("../../test/backend_tests/test_files/sample.png", "b");
const testImageLarge = open("../../test/backend_tests/test_files/sample.jpg", "b");
// A minimal "add-obj" change payload for update-file.
function makeAddRectChange(pageId) {
const shapeId = uuidv4();
const x = 100;
const y = 100;
const w = 200;
const h = 150;
return {
type: "add-obj",
pageId: pageId,
id: shapeId,
frameId: pageId,
parentId: pageId,
obj: {
id: shapeId,
type: "rect",
name: "Perf Test Rect",
x: x, y: y, width: w, height: h,
fillColor: "#ff0000", fillOpacity: 1,
rotation: 0, hidden: false, locked: false,
selrect: { x, y, width: w, height: h, x1: x, y1: y, x2: x + w, y2: y + h },
points: [
{ x, y }, { x: x + w, y }, { x: x + w, y: y + h }, { x, y: y + h },
],
transform: { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 },
transformInverse: { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 },
parentId: pageId, frameId: pageId,
},
};
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function assertOk(res, label) {
const ok = check(res, {
[`${label} — status is 2xx`]: (r) => r.status >= 200 && r.status < 300,
});
if (!ok) {
let bodyStr = "";
try {
if (res.raw && res.raw.body) {
bodyStr = typeof res.raw.body === "string"
? res.raw.body.substring(0, 500)
: JSON.stringify(res.raw.body).substring(0, 500);
} else if (res.body) {
bodyStr = JSON.stringify(res.body).substring(0, 500);
}
} catch (e) {
bodyStr = "(could not read body)";
}
console.error(`FAIL: ${label} — status=${res.status} body=${bodyStr}`);
}
return ok;
}
// ---------------------------------------------------------------------------
// Setup — create user pool before VUs start
// ---------------------------------------------------------------------------
export function setup() {
// Resolve VU count from options or CLI --vus flag
const vuCount = parseInt(__ENV.K6_VUS) || 1;
console.log(`Penpot Lifecycle Test`);
console.log(` Base URL: ${BASE_URL}`);
console.log(` VUs: ${vuCount}`);
console.log(` Creating ${vuCount} demo profiles...`);
console.log(``);
const client = createClient(BASE_URL);
// Verify backend is reachable
const pingRes = client.getProfile();
if (pingRes.status === 0) fail(`Backend unreachable at ${BASE_URL}`);
// Create one demo profile per VU
const users = [];
for (let i = 0; i < vuCount; i++) {
const res = client.rpc("POST", "create-demo-profile", {});
if (res.status !== 200) {
fail(`Failed to create demo profile ${i + 1}/${vuCount}: status=${res.status}`);
}
users.push(res.json());
}
console.log(` Created ${users.length} demo profiles`);
return { baseUrl: BASE_URL, users };
}
// ---------------------------------------------------------------------------
// Main VU Function
// ---------------------------------------------------------------------------
export default function (data) {
const client = createClient(data.baseUrl);
// Pick user from pool
const user = data.users[__VU - 1];
if (!user) {
fail(`No user for VU ${__VU} (pool size: ${data.users.length})`);
}
// ---- Step 1: Login ----
const loginRes = client.login(user.email, user.password);
if (!assertOk(loginRes, "login-with-password")) fail("Login failed");
const profile = loginRes.body;
const profileId = profile.id;
sleep(1);
// ---- Step 2: Get profile ----
if (!assertOk(client.getProfile(), "get-profile")) fail("get-profile failed");
sleep(0.5);
// ---- Step 3: Get teams ----
const teamsRes = client.getTeams();
if (!assertOk(teamsRes, "get-teams")) fail("get-teams failed");
const defaultTeamId = teamsRes.body[0].id;
sleep(0.5);
// ---- Step 4: Create a project ----
const projectRes = client.createProject(defaultTeamId, `Perf Project ${uuidv4().substring(0, 8)}`);
if (!assertOk(projectRes, "create-project")) fail("create-project failed");
const projectId = projectRes.body.id;
sleep(1);
// ---- Step 5: Create a file ----
const fileRes = client.createFile(projectId, `Perf File ${uuidv4().substring(0, 8)}`);
if (!assertOk(fileRes, "create-file")) fail("create-file failed");
const fileId = fileRes.body.id;
sleep(1);
// ---- Step 6: Get the file ----
const getFileRes = client.getFile(fileId);
if (!assertOk(getFileRes, "get-file")) fail("get-file failed");
const fileData = getFileRes.body;
const pageId = fileData.data.pages[0];
sleep(1);
// ---- Step 7: Update file (add a shape) ----
if (pageId) {
const changes = [makeAddRectChange(pageId)];
const updateRes = client.updateFile(fileId, fileData.revn, fileData.vern, client.sessionId, changes);
if (updateRes.status !== 200) {
// Retry once on revn conflict
const body = updateRes.body;
const isConflict = body && (body.code === "revn-conflict" || body.type === "revn-conflict");
if (isConflict) {
const retryFile = client.getFile(fileId);
if (retryFile.status === 200) {
client.updateFile(fileId, retryFile.body.revn, retryFile.body.vern, client.sessionId, changes);
}
}
}
}
sleep(1);
// ---- Step 8: Upload images ----
if (testImageSmall && testImageSmall.byteLength > 0) {
assertOk(
client.uploadFileMediaObject(fileId, testImageSmall, "sample.png", "image/png"),
"upload (direct)"
);
}
sleep(0.5);
if (testImageLarge && testImageLarge.byteLength > 0) {
assertOk(
client.uploadFileMediaObject(fileId, testImageLarge, "sample.jpg", "image/jpeg"),
"upload (chunked)"
);
}
sleep(1);
// ---- Step 9: Delete file ----
assertOk(client.deleteFile(fileId), "delete-file");
sleep(0.5);
// ---- Step 10: Delete project ----
assertOk(client.deleteProject(projectId), "delete-project");
sleep(0.5);
// ---- Step 11: Logout ----
client.logout(profileId);
}
// ---------------------------------------------------------------------------
// Teardown
// ---------------------------------------------------------------------------
export function teardown(data) {
console.log("Lifecycle test complete.");
}
+142
View File
@@ -0,0 +1,142 @@
// Media Upload Performance Test
//
// Tests direct and chunked image uploads with varying file sizes.
// Each VU creates its own file and uploads multiple images to it.
//
// Upload sizes:
// - SVG (3.6 KB) → direct upload
// - PNG (5.1 KB) → direct upload
// - JPG (305 KB) → chunked upload (7 chunks at 50 KB each)
//
// Usage:
// k6 run scripts/media-upload.js
// k6 run --vus 50 --iterations 5 scripts/media-upload.js
import { check, sleep, fail } from "k6";
import { uuidv4 } from "https://jslib.k6.io/k6-utils/1.4.0/index.js";
import { createClient } from "../lib/penpot-client.js";
// ---------------------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------------------
const BASE_URL = __ENV.PENPOT_BASE_URL || "http://localhost:6060";
export const options = {
thresholds: {
http_req_duration: ["p(95)<10000"],
http_req_failed: ["rate<0.01"],
"http_req_duration{rpc_command:upload-file-media-object}": ["p(95)<5000"],
"http_req_duration{rpc_command:upload-chunk}": ["p(95)<5000"],
"http_req_duration{rpc_command:assemble-file-media-object}": ["p(95)<5000"],
},
};
// ---------------------------------------------------------------------------
// Test Data
// ---------------------------------------------------------------------------
const imageSvg = open("../../test/backend_tests/test_files/sample1.svg", "b");
const imagePng = open("../../test/backend_tests/test_files/sample.png", "b");
const imageJpg = open("../../test/backend_tests/test_files/sample.jpg", "b");
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function assertOk(res, label) {
const ok = check(res, {
[`${label} — status is 2xx`]: (r) => r.status >= 200 && r.status < 300,
});
if (!ok) {
let bodyStr = "";
try {
if (res.raw && res.raw.body) {
bodyStr = typeof res.raw.body === "string"
? res.raw.body.substring(0, 500)
: JSON.stringify(res.raw.body).substring(0, 500);
} else if (res.body) {
bodyStr = JSON.stringify(res.body).substring(0, 500);
}
} catch (e) {
bodyStr = "(could not read body)";
}
console.error(`FAIL: ${label} — status=${res.status} body=${bodyStr}`);
}
return ok;
}
// ---------------------------------------------------------------------------
// Setup — create user pool
// ---------------------------------------------------------------------------
export function setup() {
const vuCount = parseInt(__ENV.K6_VUS) || 1;
console.log(`Penpot Media Upload Test`);
console.log(` Base URL: ${BASE_URL}`);
console.log(` VUs: ${vuCount}`);
console.log(``);
const client = createClient(BASE_URL);
if (client.getProfile().status === 0) fail(`Backend unreachable at ${BASE_URL}`);
const users = [];
for (let i = 0; i < vuCount; i++) {
const res = client.rpc("POST", "create-demo-profile", {});
if (res.status !== 200) fail(`Failed to create demo profile ${i + 1}/${vuCount}`);
users.push(res.json());
}
console.log(` Created ${users.length} demo profiles`);
return { baseUrl: BASE_URL, users };
}
// ---------------------------------------------------------------------------
// Main VU Function
// ---------------------------------------------------------------------------
export default function (data) {
const client = createClient(data.baseUrl);
// Pick user from pool
const user = data.users[__VU - 1];
if (!user) fail(`No user for VU ${__VU}`);
// Login
if (!assertOk(client.login(user.email, user.password), "login")) fail("login failed");
sleep(0.5);
// Get team
const teamId = client.getTeams().body[0].id;
// Create project + file
const projectId = client.createProject(teamId, `Media ${uuidv4().substring(0, 8)}`).body.id;
const fileId = client.createFile(projectId, `Media ${uuidv4().substring(0, 8)}`).body.id;
sleep(0.5);
// Upload SVG (direct — 3.6 KB)
assertOk(client.uploadFileMediaObject(fileId, imageSvg, "sample.svg", "image/svg+xml"), "upload SVG");
sleep(0.5);
// Upload PNG (direct — 5.1 KB)
assertOk(client.uploadFileMediaObject(fileId, imagePng, "sample.png", "image/png"), "upload PNG");
sleep(0.5);
// Upload JPG (chunked — 305 KB > 50 KB threshold)
assertOk(client.uploadFileMediaObject(fileId, imageJpg, "sample.jpg", "image/jpeg"), "upload JPG");
console.log(`VU ${__VU}: Media upload complete`);
}
// ---------------------------------------------------------------------------
// Teardown
// ---------------------------------------------------------------------------
export function teardown(data) {
console.log("Media upload test complete.");
}
@@ -0,0 +1,361 @@
// Workspace Edit Concurrent Performance Test
//
// Two modes for measuring concurrent file editing:
//
// Mode 1: same-file — N VUs edit different pages in 1 file
// Measures lock contention on a single popular file.
// Bottleneck: advisory lock serialization (db/xact-lock!).
//
// Mode 2: multi-file — G groups × M VUs per file
// Each group edits its own file on its own page.
// Measures whole system responsiveness under parallel edit sessions.
// Bottleneck: DB connection pool, CPU, memory.
//
// Key insight: revn conflicts only occur when incoming > stored (should
// never happen in normal usage). The real contention point is the file-level
// advisory lock that serializes all update-file calls on the same file.
//
// Usage:
// # Same-file mode (default): 5 VUs edit different pages in 1 file
// k6 run --vus 5 --iterations 10 scripts/workspace-edit-concurrent.js
//
// # Multi-file mode: 3 files × 2 VUs each = 6 VUs total
// PENPOT_EDIT_MODE=multi-file PENPOT_FILE_COUNT=3 PENPOT_VUS_PER_FILE=2 \
// k6 run --vus 6 --iterations 10 scripts/workspace-edit-concurrent.js
//
// # Via run.sh
// ./run.sh concurrent-edit --mode same-file --vus 5 --iterations 10
// ./run.sh concurrent-edit --mode multi-file --files 3 --vus-per-file 2 --iterations 10
import { check, sleep, fail } from "k6";
import { uuidv4 } from "https://jslib.k6.io/k6-utils/1.4.0/index.js";
import { createClient } from "../lib/penpot-client.js";
// ---------------------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------------------
const BASE_URL = __ENV.PENPOT_BASE_URL || "http://localhost:6060";
const EDIT_MODE = __ENV.PENPOT_EDIT_MODE || "same-file"; // "same-file" or "multi-file"
const FILE_COUNT = parseInt(__ENV.PENPOT_FILE_COUNT || "1");
const VUS_PER_FILE = parseInt(__ENV.PENPOT_VUS_PER_FILE || "1");
const EDIT_ITERATIONS = parseInt(__ENV.PENPOT_EDIT_ITERATIONS || "50");
// Calculate total VUs based on mode
const TOTAL_VUS = EDIT_MODE === "multi-file"
? FILE_COUNT * VUS_PER_FILE
: parseInt(__ENV.PENPOT_TOTAL_VUS || "3");
export const options = {
thresholds: {
http_req_duration: ["p(95)<5000"],
http_req_failed: ["rate<0.01"],
"http_req_duration{rpc_command:get-file}": ["p(95)<500"],
"http_req_duration{rpc_command:update-file}": ["p(95)<3000"],
},
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function assertOk(res, label) {
const ok = check(res, {
[`${label} — status is 2xx`]: (r) => r.status >= 200 && r.status < 300,
});
if (!ok) {
let bodyStr = "";
try {
if (res.raw && res.raw.body) {
bodyStr = typeof res.raw.body === "string"
? res.raw.body.substring(0, 500)
: JSON.stringify(res.raw.body).substring(0, 500);
} else if (res.body) {
bodyStr = JSON.stringify(res.body).substring(0, 500);
}
} catch (e) {
bodyStr = "(could not read body)";
}
console.error(`FAIL: ${label} — status=${res.status} body=${bodyStr}`);
}
return ok;
}
function makeAddRectChange(pageId, index) {
const shapeId = uuidv4();
const x = 50 + (index % 10) * 30;
const y = 50 + Math.floor(index / 10) * 30;
const w = 100;
const h = 80;
return {
type: "add-obj",
pageId: pageId,
id: shapeId,
frameId: pageId,
parentId: pageId,
obj: {
id: shapeId, type: "rect", name: `Shape ${index}`,
x, y, width: w, height: h,
fillColor: "#00ff00", fillOpacity: 0.8,
rotation: 0, hidden: false, locked: false,
selrect: { x, y, width: w, height: h, x1: x, y1: y, x2: x + w, y2: y + h },
points: [
{ x, y }, { x: x + w, y }, { x: x + w, y: y + h }, { x, y: y + h },
],
transform: { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 },
transformInverse: { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 },
parentId: pageId, frameId: pageId,
},
};
}
// Add a page to a file via update-file with add-page change
function addPage(client, fileId, revn, vern, pageId, pageName) {
const change = {
type: "add-page",
id: pageId,
name: pageName,
};
return client.updateFile(fileId, revn, vern, client.sessionId, [change]);
}
// ---------------------------------------------------------------------------
// Setup — create users, files, and pages based on mode
// ---------------------------------------------------------------------------
export function setup() {
console.log(`Penpot Concurrent Edit Test`);
console.log(` Base URL: ${BASE_URL}`);
console.log(` Mode: ${EDIT_MODE}`);
console.log(` Edit iterations: ${EDIT_ITERATIONS}`);
if (EDIT_MODE === "same-file") {
console.log(` Total VUs: ${TOTAL_VUS} (same file)`);
} else {
console.log(` Files: ${FILE_COUNT}`);
console.log(` VUs per file: ${VUS_PER_FILE}`);
console.log(` Total VUs: ${TOTAL_VUS}`);
}
console.log(``);
const client = createClient(BASE_URL);
if (client.getProfile().status === 0) fail(`Backend unreachable at ${BASE_URL}`);
// Create demo profiles (one per VU)
const users = [];
for (let i = 0; i < TOTAL_VUS; i++) {
const res = client.rpc("POST", "create-demo-profile", {});
if (res.status !== 200) fail(`Failed to create demo profile ${i + 1}/${TOTAL_VUS}`);
users.push(res.json());
}
console.log(` Created ${users.length} demo profiles`);
// Login with first user to create shared team and files
const loginRes = client.login(users[0].email, users[0].password);
if (loginRes.status !== 200) fail("Login failed for setup");
// Create a shared team so all VUs can access the same file.
// Each demo profile gets its own default team; without a shared
// team, VUs 2+ would get 404 on get-file.
const teamRes = client.createTeam("Concurrent Edit Team");
if (teamRes.status !== 200) fail("Failed to create shared team");
const sharedTeamId = teamRes.body.id;
console.log(` Shared team: ${sharedTeamId}`);
// Invite remaining users to the shared team and get acceptance tokens.
// The tokens are used by each VU via verify-token to join the team.
const invitationTokens = [];
for (let i = 1; i < TOTAL_VUS; i++) {
const invRes = client.inviteTeamMembers(sharedTeamId, [users[i].email], "editor");
if (invRes.status !== 200) {
console.error(` Invite user ${i}: status=${invRes.status}`);
}
const tokenRes = client.getTeamInvitationToken(sharedTeamId, users[i].email);
if (tokenRes.status === 200 && tokenRes.body) {
invitationTokens.push({ vuIndex: i, token: tokenRes.body });
}
}
if (invitationTokens.length > 0) {
console.log(` Got ${invitationTokens.length} invitation tokens`);
} else {
console.log(` All users auto-added (no tokens needed)`);
}
// Create project and files in the shared team
const projectId = client.createProject(sharedTeamId, "Concurrent Edit Project").body.id;
console.log(` Project: ${projectId}`);
// Build file/page assignments based on mode
const fileAssignments = []; // [{ fileId, pageIds[] }]
const vuAssignments = []; // [{ vuIndex, fileId, pageId }]
if (EDIT_MODE === "same-file") {
// One file, N pages (one per VU)
const fileRes = client.createFile(projectId, "Shared Edit File");
if (fileRes.status !== 200) fail("Failed to create shared file");
const fileId = fileRes.body.id;
console.log(` Created file: ${fileId}`);
// Get initial file state (has 1 default page)
const getFileRes = client.getFile(fileId);
if (getFileRes.status !== 200) fail("Failed to get initial file");
const defaultPageId = getFileRes.body.data.pages[0];
let revn = getFileRes.body.revn;
let vern = getFileRes.body.vern;
// First VU uses the default page
const pageIds = [defaultPageId];
// Add remaining pages
// vern never changes on regular edits (only on snapshot restore),
// and each add-page increments revn by 1, so no need to re-fetch.
for (let i = 1; i < TOTAL_VUS; i++) {
const pageId = uuidv4();
const pageName = `Page ${i + 1}`;
const addRes = addPage(client, fileId, revn, vern, pageId, pageName);
if (addRes.status !== 200) fail(`Failed to add page ${i + 1}`);
revn++;
pageIds.push(pageId);
}
console.log(` Added ${pageIds.length} pages to file`);
fileAssignments.push({ fileId, pageIds });
// Each VU gets its own page in the same file
for (let i = 0; i < TOTAL_VUS; i++) {
vuAssignments.push({ vuIndex: i, fileId, pageId: pageIds[i] });
}
} else {
// Multi-file mode: G files, each with M pages
for (let f = 0; f < FILE_COUNT; f++) {
const fileRes = client.createFile(projectId, `Edit File ${f + 1}`);
if (fileRes.status !== 200) fail(`Failed to create file ${f + 1}`);
const fileId = fileRes.body.id;
console.log(` Created file ${f + 1}: ${fileId}`);
// Get initial file state (has 1 default page)
const getFileRes = client.getFile(fileId);
if (getFileRes.status !== 200) fail(`Failed to get file ${f + 1}`);
const defaultPageId = getFileRes.body.data.pages[0];
let revn = getFileRes.body.revn;
let vern = getFileRes.body.vern;
// First VU of this file uses the default page
const pageIds = [defaultPageId];
// Add remaining pages for this file
for (let p = 1; p < VUS_PER_FILE; p++) {
const pageId = uuidv4();
const pageName = `Page ${p + 1}`;
const addRes = addPage(client, fileId, revn, vern, pageId, pageName);
if (addRes.status !== 200) fail(`Failed to add page ${p + 1} to file ${f + 1}`);
revn++;
pageIds.push(pageId);
}
console.log(` Added ${pageIds.length} pages to file ${f + 1}`);
fileAssignments.push({ fileId, pageIds });
// Assign VUs to this file's pages
for (let p = 0; p < VUS_PER_FILE; p++) {
const vuIndex = f * VUS_PER_FILE + p;
vuAssignments.push({ vuIndex, fileId, pageId: pageIds[p] });
}
}
}
console.log(` Setup complete. ${vuAssignments.length} VU assignments.`);
console.log(``);
return {
baseUrl: BASE_URL,
editMode: EDIT_MODE,
users,
vuAssignments,
invitationTokens,
};
}
// ---------------------------------------------------------------------------
// Main VU Function — each VU edits its assigned page
// ---------------------------------------------------------------------------
// Track which VUs have accepted their invitation (once per VU, not per iteration)
const verifiedVus = {};
// ---------------------------------------------------------------------------
// Main VU Function — each VU edits its assigned page
// ---------------------------------------------------------------------------
export default function (data) {
const client = createClient(data.baseUrl);
// Each VU uses its own demo profile (different users editing the same file)
const vuIndex = __VU - 1;
const user = data.users[vuIndex];
const assignment = data.vuAssignments[vuIndex];
if (!user) fail(`No user for VU ${__VU} (index ${vuIndex})`);
if (!assignment) fail(`No assignment for VU ${__VU} (index ${vuIndex})`);
const { fileId, pageId } = assignment;
// Login
if (!assertOk(client.login(user.email, user.password), "login")) fail("login failed");
// Accept team invitation once per VU (not per iteration).
// In devenv the user may already be auto-added; 400 on already-accepted
// tokens is harmless — skip the token on subsequent iterations.
if (!verifiedVus[__VU]) {
const tokenEntry = data.invitationTokens.find((t) => t.vuIndex === vuIndex);
if (tokenEntry && tokenEntry.token) {
client.rpc("POST", "verify-token", tokenEntry.token);
}
verifiedVus[__VU] = true;
}
sleep(0.5);
// Edit loop
for (let i = 0; i < EDIT_ITERATIONS; i++) {
// Refresh file state to get latest revn
const refreshRes = client.getFile(fileId);
if (!assertOk(refreshRes, "get-file")) continue;
const { revn, vern } = refreshRes.body;
sleep(0.3);
// Submit a change to our assigned page
const changes = [makeAddRectChange(pageId, i)];
const updateRes = client.updateFile(fileId, revn, vern, client.sessionId, changes);
if (updateRes.status !== 200) {
const body = updateRes.body;
const isConflict = body && (body.code === "revn-conflict" || body.type === "revn-conflict");
if (isConflict) {
// This shouldn't happen in normal circumstances, but handle it gracefully
console.warn(`VU ${__VU}: revn conflict on iteration ${i} (unexpected)`);
const retryFile = client.getFile(fileId);
if (retryFile.status === 200) {
client.updateFile(fileId, retryFile.body.revn, retryFile.body.vern, client.sessionId, changes);
}
} else {
console.error(`VU ${__VU}: update-file failed on iteration ${i}: ${JSON.stringify(body)}`);
}
}
sleep(1);
}
console.log(`VU ${__VU}: Completed ${EDIT_ITERATIONS} edits on file ${fileId}, page ${pageId}`);
}
// ---------------------------------------------------------------------------
// Teardown
// ---------------------------------------------------------------------------
export function teardown(data) {
console.log(`Concurrent edit test complete (${data.editMode}).`);
}
@@ -0,0 +1,195 @@
// Workspace Edit Performance Test (Write-heavy)
//
// Simulates users editing files — repeatedly fetching the file (to get
// the latest revn) and submitting changes. Each VU edits its own file
// in its own project independently, so there are no concurrency conflicts.
//
// setup() creates N demo profiles + per-user project.
// Each VU picks its user, creates its own file, and edits it in a loop.
//
// Flow (per VU):
// Login → create file → loop: get-file → update-file → sleep
//
// Usage:
// k6 run scripts/workspace-edit.js
// k6 run --vus 100 --iterations 50 scripts/workspace-edit.js
import { check, sleep, fail } from "k6";
import { uuidv4 } from "https://jslib.k6.io/k6-utils/1.4.0/index.js";
import { createClient } from "../lib/penpot-client.js";
// ---------------------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------------------
const BASE_URL = __ENV.PENPOT_BASE_URL || "http://localhost:6060";
const EDIT_ITERATIONS = parseInt(__ENV.PENPOT_EDIT_ITERATIONS || "50");
export const options = {
thresholds: {
http_req_duration: ["p(95)<5000"],
http_req_failed: ["rate<0.01"],
"http_req_duration{rpc_command:get-file}": ["p(95)<500"],
"http_req_duration{rpc_command:update-file}": ["p(95)<2000"],
},
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function assertOk(res, label) {
const ok = check(res, {
[`${label} — status is 2xx`]: (r) => r.status >= 200 && r.status < 300,
});
if (!ok) {
let bodyStr = "";
try {
if (res.raw && res.raw.body) {
bodyStr = typeof res.raw.body === "string"
? res.raw.body.substring(0, 500)
: JSON.stringify(res.raw.body).substring(0, 500);
} else if (res.body) {
bodyStr = JSON.stringify(res.body).substring(0, 500);
}
} catch (e) {
bodyStr = "(could not read body)";
}
console.error(`FAIL: ${label} — status=${res.status} body=${bodyStr}`);
}
return ok;
}
function makeAddRectChange(pageId, index) {
const shapeId = uuidv4();
const x = 50 + (index % 10) * 30;
const y = 50 + Math.floor(index / 10) * 30;
const w = 100;
const h = 80;
return {
type: "add-obj",
pageId: pageId,
id: shapeId,
frameId: pageId,
parentId: pageId,
obj: {
id: shapeId, type: "rect", name: `Shape ${index}`,
x, y, width: w, height: h,
fillColor: "#00ff00", fillOpacity: 0.8,
rotation: 0, hidden: false, locked: false,
selrect: { x, y, width: w, height: h, x1: x, y1: y, x2: x + w, y2: y + h },
points: [
{ x, y }, { x: x + w, y }, { x: x + w, y: y + h }, { x, y: y + h },
],
transform: { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 },
transformInverse: { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 },
parentId: pageId, frameId: pageId,
},
};
}
// ---------------------------------------------------------------------------
// Setup — create N users, each with their own project
// ---------------------------------------------------------------------------
export function setup() {
const vuCount = parseInt(__ENV.K6_VUS) || 1;
console.log(`Penpot Workspace Edit Test`);
console.log(` Base URL: ${BASE_URL}`);
console.log(` VUs: ${vuCount}`);
console.log(` Edit iterations: ${EDIT_ITERATIONS}`);
console.log(``);
const client = createClient(BASE_URL);
if (client.getProfile().status === 0) fail(`Backend unreachable at ${BASE_URL}`);
// Create N demo profiles + per-user project.
// Each user needs their own project because demo profiles
// belong to different teams and cannot share a project.
const users = [];
for (let i = 0; i < vuCount; i++) {
const res = client.rpc("POST", "create-demo-profile", {});
if (res.status !== 200) fail(`Failed to create demo profile ${i + 1}/${vuCount}`);
const user = res.json();
// Login as this user and create their own project
const loginRes = client.login(user.email, user.password);
if (loginRes.status !== 200) fail(`Login failed for user ${i + 1}`);
const teamId = client.getTeams().body[0].id;
user.projectId = client.createProject(teamId, `WS Edit Project`).body.id;
users.push(user);
}
console.log(` Created ${users.length} demo profiles + projects`);
return { baseUrl: BASE_URL, users };
}
// ---------------------------------------------------------------------------
// Main VU Function — each VU creates its own file and edits it
// ---------------------------------------------------------------------------
export default function (data) {
const client = createClient(data.baseUrl);
// Pick user from pool
const user = data.users[__VU - 1];
if (!user) fail(`No user for VU ${__VU}`);
// Login
if (!assertOk(client.login(user.email, user.password), "login")) fail("login failed");
sleep(0.5);
// Create a file for this VU in their own project
const fileRes = client.createFile(user.projectId, `Edit File VU${__VU}`);
if (!assertOk(fileRes, "create-file")) fail("create-file failed");
const fileId = fileRes.body.id;
// Get initial file state
const getFileRes = client.getFile(fileId);
if (!assertOk(getFileRes, "get-file")) fail("get-file failed");
const pageId = getFileRes.body.data.pages[0];
sleep(0.5);
// Edit loop
for (let i = 0; i < EDIT_ITERATIONS; i++) {
// Refresh file state to get latest revn
const refreshRes = client.getFile(fileId);
if (!assertOk(refreshRes, "get-file")) continue;
const { revn, vern } = refreshRes.body;
sleep(0.3);
// Submit a change
const changes = [makeAddRectChange(pageId, i)];
const updateRes = client.updateFile(fileId, revn, vern, client.sessionId, changes);
if (updateRes.status !== 200) {
// Retry once on revn conflict
const body = updateRes.body;
const isConflict = body && (body.code === "revn-conflict" || body.type === "revn-conflict");
if (isConflict) {
const retryFile = client.getFile(fileId);
if (retryFile.status === 200) {
client.updateFile(fileId, retryFile.body.revn, retryFile.body.vern, client.sessionId, changes);
}
}
}
sleep(1);
}
console.log(`VU ${__VU}: Completed ${EDIT_ITERATIONS} edits on file ${fileId}`);
}
// ---------------------------------------------------------------------------
// Teardown
// ---------------------------------------------------------------------------
export function teardown(data) {
console.log("Workspace edit test complete.");
}
@@ -0,0 +1,157 @@
// Workspace Open Performance Test (Read-heavy)
//
// Simulates many users opening the same file in the workspace editor.
// This is the most common read-heavy operation — loading a file and its
// dependencies (libraries, thumbnails).
//
// setup() creates one user, one project, and one file with a shape.
// All VUs login with the same user and read the same file concurrently.
//
// Flow (per VU iteration):
// Login → get-file → get-file-libraries → get-file-object-thumbnails
// → get-file-data-for-thumbnail
//
// Usage:
// k6 run scripts/workspace-open.js
// k6 run --vus 100 --iterations 20 scripts/workspace-open.js
// k6 run --env PENPOT_BASE_URL=http://localhost:6060 scripts/workspace-open.js
import { check, sleep, fail } from "k6";
import { uuidv4 } from "https://jslib.k6.io/k6-utils/1.4.0/index.js";
import { createClient } from "../lib/penpot-client.js";
// ---------------------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------------------
const BASE_URL = __ENV.PENPOT_BASE_URL || "http://localhost:6060";
const OPEN_ITERATIONS = parseInt(__ENV.PENPOT_OPEN_ITERATIONS || "5");
export const options = {
thresholds: {
http_req_duration: ["p(95)<5000"],
http_req_failed: ["rate<0.01"],
"http_req_duration{rpc_command:get-file}": ["p(95)<500"],
"http_req_duration{rpc_command:get-file-libraries}": ["p(95)<500"],
"http_req_duration{rpc_command:get-file-object-thumbnails}": ["p(95)<500"],
"http_req_duration{rpc_command:get-file-data-for-thumbnail}": ["p(95)<500"],
},
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function assertOk(res, label) {
const ok = check(res, {
[`${label} — status is 2xx`]: (r) => r.status >= 200 && r.status < 300,
});
if (!ok) {
let bodyStr = "";
try {
if (res.raw && res.raw.body) {
bodyStr = typeof res.raw.body === "string"
? res.raw.body.substring(0, 500)
: JSON.stringify(res.raw.body).substring(0, 500);
} else if (res.body) {
bodyStr = JSON.stringify(res.body).substring(0, 500);
}
} catch (e) {
bodyStr = "(could not read body)";
}
console.error(`FAIL: ${label} — status=${res.status} body=${bodyStr}`);
}
return ok;
}
// ---------------------------------------------------------------------------
// Setup — create one user + one file with data
// ---------------------------------------------------------------------------
export function setup() {
console.log(`Penpot Workspace Open Test`);
console.log(` Base URL: ${BASE_URL}`);
console.log(` Open iterations: ${OPEN_ITERATIONS}`);
console.log(``);
const client = createClient(BASE_URL);
// Verify backend reachable
if (client.getProfile().status === 0) fail(`Backend unreachable at ${BASE_URL}`);
// Create one demo user
const demoRes = client.rpc("POST", "create-demo-profile", {});
if (demoRes.status !== 200) fail("Failed to create demo profile");
const { email, password } = demoRes.json();
// Login
const loginRes = client.login(email, password);
if (loginRes.status !== 200) fail("Login failed");
// Create project + file
const teamId = client.getTeams().body[0].id;
const projectId = client.createProject(teamId, "WS Open Project").body.id;
const fileId = client.createFile(projectId, "WS Open File").body.id;
// Get file data and add a shape so it has meaningful content
const fileData = client.getFile(fileId).body;
const pageId = fileData.data.pages[0];
const shapeId = uuidv4();
const x = 50, y = 50, w = 300, h = 200;
client.updateFile(fileId, fileData.revn, fileData.vern, uuidv4(), [{
type: "add-obj", pageId, id: shapeId, frameId: pageId, parentId: pageId,
obj: {
id: shapeId, type: "rect", name: "Background",
x, y, width: w, height: h,
fillColor: "#cccccc", fillOpacity: 1,
rotation: 0, hidden: false, locked: false,
selrect: { x, y, width: w, height: h, x1: x, y1: y, x2: x + w, y2: y + h },
points: [{ x, y }, { x: x + w, y }, { x: x + w, y: y + h }, { x, y: y + h }],
transform: { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 },
transformInverse: { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 },
parentId: pageId, frameId: pageId,
},
}]);
console.log(` File ready: ${fileId} (page: ${pageId})`);
return { baseUrl: BASE_URL, email, password, fileId };
}
// ---------------------------------------------------------------------------
// Main VU Function — all VUs read the same file
// ---------------------------------------------------------------------------
export default function (data) {
const client = createClient(data.baseUrl);
// Login with shared user
if (!assertOk(client.login(data.email, data.password), "login")) fail("login failed");
sleep(0.5);
for (let i = 0; i < OPEN_ITERATIONS; i++) {
if (!assertOk(client.getFile(data.fileId), "get-file")) fail("get-file failed");
sleep(0.3);
if (!assertOk(client.getFileLibraries(data.fileId), "get-file-libraries")) fail("get-file-libraries failed");
sleep(0.2);
if (!assertOk(client.getFileObjectThumbnails(data.fileId), "get-file-object-thumbnails")) fail("get-file-object-thumbnails failed");
sleep(0.2);
if (!assertOk(client.getFileDataForThumbnail(data.fileId), "get-file-data-for-thumbnail")) fail("get-file-data-for-thumbnail failed");
sleep(1);
}
console.log(`VU ${__VU}: Completed ${OPEN_ITERATIONS} open iterations`);
}
// ---------------------------------------------------------------------------
// Teardown
// ---------------------------------------------------------------------------
export function teardown(data) {
console.log("Workspace open test complete.");
}
+4 -2
View File
@@ -13,6 +13,10 @@ export PENPOT_MANAGEMENT_API_KEY=super-secret-management-api-key
# PENPOT_DATABASE_*, PENPOT_REDIS_URI, PENPOT_OBJECTS_STORAGE_*, AWS_*) is owned by
# docker/devenv/defaults.env and injected via the main service's env block.
if [ -f /home/selfsigned.crt ]; then
export NODE_EXTRA_CA_CERTS=/home/selfsigned.crt;
fi
# Background worker flag is per-instance. Defaults to enabled (ws0); ws1+
# overlays set PENPOT_BACKEND_WORKER=false so scheduled and async tasks only
# run on ws0, keeping notification Pub/Sub bound to a single Valkey. See
@@ -101,5 +105,3 @@ function setup_minio() {
mc alias set penpot-s3/ "${PENPOT_OBJECTS_STORAGE_S3_ENDPOINT}" minioadmin minioadmin -q
mc mb "penpot-s3/${PENPOT_OBJECTS_STORAGE_S3_BUCKET}" -p -q
}
+11
View File
@@ -14,10 +14,21 @@
:iterations 3
:parallelism 2})
(def ^:private weak-options
{:alg :pbkdf2+sha256
:iterations 100})
(defn derive-password
[password]
(hashers/derive password default-options))
(defn derive-password-weak
"Derives a password using a fast algorithm (pbkdf2+sha256, 100 iterations).
Intended for demo users only — they are already gated behind the
`demo-users` config flag which is disabled in production."
[password]
(hashers/derive password weak-options))
(defn verify-password
[attempt password]
(try
+5
View File
@@ -392,6 +392,8 @@
:delete-object
(ig/ref :app.tasks.delete-object/handler)
:demo-purge
(ig/ref :app.tasks.demo-purge/handler)
:process-webhook-event
(ig/ref ::webhooks/process-event-handler)
:run-webhook
@@ -429,6 +431,9 @@
:app.tasks.delete-object/handler
{::db/pool (ig/ref ::db/pool)}
:app.tasks.demo-purge/handler
{::db/pool (ig/ref ::db/pool)}
:app.tasks.file-gc/handler
{::db/pool (ig/ref ::db/pool)
::sto/storage (ig/ref ::sto/storage)}
+13 -6
View File
@@ -7,9 +7,9 @@
(ns app.rpc.commands.demo
"A demo specific mutations."
(:require
[app.auth :refer [derive-password]]
[app.auth :refer [derive-password-weak]]
[app.common.exceptions :as ex]
[app.common.time :as ct]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.db :as db]
[app.loggers.audit :as audit]
@@ -17,6 +17,7 @@
[app.rpc.commands.auth :as auth]
[app.rpc.doc :as-alias doc]
[app.util.services :as sv]
[app.worker :as wrk]
[buddy.core.codecs :as bc]
[buddy.core.nonce :as bn]))
@@ -34,8 +35,8 @@
:code :demo-users-not-allowed
:hint "Demo users are disabled by config."))
(let [sem (System/currentTimeMillis)
email (str "demo-" sem ".demo@example.com")
(let [sem (uuid/next)
email (str "demo-" sem "@demo.example.com")
fullname (str "Demo User " sem)
password (-> (bn/random-bytes 16)
@@ -46,12 +47,18 @@
:fullname fullname
:is-active true
:is-demo true
:deleted-at (ct/in-future (cf/get-deletion-delay))
:password (derive-password password)
:password (derive-password-weak password)
:props {}}
profile (db/tx-run! cfg (fn [cfg]
(->> (auth/create-profile cfg params)
(auth/create-profile-rels cfg))))]
(wrk/submit! (-> cfg
(assoc ::wrk/task :demo-purge)
(assoc ::wrk/delay (cf/get-deletion-delay))
(assoc ::wrk/params {:profile-id (:id profile)})))
(with-meta {:email email
:password password}
{::audit/profile-id (:id profile)})))
+1 -3
View File
@@ -141,9 +141,7 @@
(defn get-profile
"Get profile by id. Throws not-found exception if no profile found."
[conn id & {:as opts}]
;; NOTE: We need to set ::db/remove-deleted to false because demo profiles
;; are created with a set deleted-at value
(-> (db/get-by-id conn :profile id (assoc opts ::db/remove-deleted false))
(-> (db/get-by-id conn :profile id opts)
(decode-row)))
;; --- MUTATION: Update Profile (own)
+41
View File
@@ -0,0 +1,41 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.tasks.demo-purge
"Task handler for delayed demo profile deletion. Submitted at demo
creation time with a delay matching the configured deletion-delay."
(:require
[app.common.logging :as l]
[app.common.time :as ct]
[app.db :as db]
[app.worker :as wrk]
[integrant.core :as ig]))
(defmethod ig/assert-key ::handler
[_ params]
(assert (db/pool? (::db/pool params)) "expected a valid database pool"))
(defmethod ig/init-key ::handler
[_ cfg]
(fn [{:keys [props]}]
(let [profile-id (get props :profile-id)
now (ct/now)]
(l/trc :hint "demo-purge" :profile-id (str profile-id))
;; Mark the profile for immediate deletion
(db/tx-run! cfg
(fn [{:keys [::db/conn] :as cfg}]
(db/update! conn :profile
{:deleted-at now}
{:id profile-id}
{::db/return-keys false})
(wrk/submit!
(-> cfg
(assoc ::wrk/task :delete-object)
(assoc ::wrk/params {:object :profile
:deleted-at now
:id profile-id}))))))))
+47
View File
@@ -0,0 +1,47 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns backend-tests.demo-test
(:require
[app.common.time :as ct]
[app.db :as db]
[app.rpc.commands.profile :as profile]
[app.tasks.demo-purge :as demo-purge]
[app.worker :as wrk]
[backend-tests.helpers :as th]
[clojure.test :as t]
[integrant.core :as ig]))
(t/use-fixtures :once th/state-init)
(t/use-fixtures :each th/database-reset)
(t/deftest demo-profile-created-without-deleted-at
(let [profile (th/create-profile* 999 {:is-demo true})]
(t/is (true? (:is-demo profile)))
(t/is (nil? (:deleted-at profile)))
(t/is (some? (:id profile)))))
(t/deftest get-profile-finds-demo-user-without-override
(let [profile (th/create-profile* 998 {:is-demo true})
found (db/run! th/*pool*
(fn [{:keys [::db/conn]}]
(profile/get-profile conn (:id profile))))]
(t/is (some? found))
(t/is (= (:id profile) (:id found)))))
(t/deftest demo-purge-handler-submits-delete-object
(let [profile (th/create-profile* 996 {:is-demo true})
handler (ig/init-key :app.tasks.demo-purge/handler
{::db/pool th/*pool*})
submitted (atom nil)]
(with-redefs [wrk/submit! (fn [& {:keys [::wrk/task ::wrk/params]}]
(reset! submitted {:task task :params params}))]
(handler {:props {:profile-id (:id profile)
:deleted-at (ct/now)}}))
(t/is (= :delete-object (:task @submitted)))
(t/is (= :profile (:object (:params @submitted))))
(t/is (= (:id profile) (:id (:params @submitted))))
(t/is (some? (:deleted-at (:params @submitted))))))
+1 -1
View File
@@ -55,7 +55,7 @@
io.aviso/pretty {:mvn/version "1.4.4"}
environ/environ {:mvn/version "1.2.0"}}
:paths ["src" "vendor" "target/classes"]
:paths ["src" "vendor" "resources" "target/classes"]
:aliases
{:dev
{:extra-deps
+4 -1
View File
@@ -57,6 +57,7 @@
"text-editor/v2"
"text-editor-wasm/v1"
"render-wasm/v1"
"wasm-export/v1"
"variants/v1"})
;; A set of features enabled by default
@@ -82,7 +83,8 @@
"text-editor/v2"
"text-editor-wasm/v1"
"tokens/numeric-input"
"render-wasm/v1"})
"render-wasm/v1"
"wasm-export/v1"})
;; Features that are mainly backend only or there are a proper
;; fallback when frontend reports no support for it
@@ -132,6 +134,7 @@
:feature-text-editor-v2-html-paste "text-editor/v2-html-paste"
:feature-text-editor-wasm "text-editor-wasm/v1"
:feature-render-wasm "render-wasm/v1"
:feature-wasm-export "wasm-export/v1"
:feature-variants "variants/v1"
:feature-token-input "tokens/numeric-input"
nil))
+3
View File
@@ -178,6 +178,9 @@
:stroke-path
:stroke-per-side
;; Exporter only: uses render-wasm for export instead of browser
;; renderer.
:wasm-export
:custom-shortcuts
:remote-media-processing})
@@ -4,8 +4,9 @@
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.main.fonts
(ns app.common.fonts
"A fonts loading macros."
(:require
[app.common.uuid :as uuid]
[clojure.data.json :as json]
@@ -47,6 +48,3 @@
(let [data (slurp (io/resource path))
data (json/read-str data)]
`~(mapv parse-gfont (get data "items"))))
@@ -4,13 +4,159 @@
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.render-wasm.fallback-fonts
"Host-agnostic fallback-font knowledge: which scripts/emoji a text uses and
which (google) fallback fonts cover them. Pure data + pure fns — no browser
or Node dependencies — so the workspace (`api.texts`/`api.fonts`) and the
headless exporter (`app.renderer.wasm`) compute the SAME fallback set from
the same source. Anything a host must fetch/upload for text to render
belongs here, not in host code.")
(ns app.common.fonts
"Host-agnostic font knowledge shared by every renderer: the google catalog
baked at compile time from `common/resources/fonts/gfonts.*.json`, the
font-id/uuid mapping, weight/style variant resolution, and the noto fallback
fonts a text's scripts and emoji need. Also the one family bundled with the
frontend, which is not a google font but resolves by the same rules.
Pure data + pure fns — no browser or Node dependencies — so the workspace and
the headless exporter resolve the SAME fonts from the same source. Anything a
host must fetch or upload for text to render belongs here, not in host code."
(:require-macros [app.common.fonts :refer [preload-gfonts]])
(:require
[app.common.data :as d]
[app.common.uuid :as uuid]
[cuerdas.core :as str]))
;; --- GOOGLE FONTS CATALOG
(def catalog
(preload-gfonts "fonts/gfonts.2025.11.28.json"))
(def ^:private by-id
(reduce (fn [m font] (assoc m (:id font) font)) {} catalog))
(def ^:private by-uuid
(reduce (fn [m font] (assoc m (:uuid font) font)) {} catalog))
(defn gfont-id->uuid
"Maps a `gfont-<slug>` id to its (compilation-stable) catalog uuid, or nil."
[gfont-id]
(:uuid (get by-id gfont-id)))
;; --- font-id -> wasm uuid
(def ^:private custom-prefix "custom-")
(def ^:private gfont-prefix "gfont-")
(defn font-id->backend
"Which source a content font-id comes from: `:google` for `gfont-<slug>`,
`:custom` for `custom-<uuid>`, `:builtin` for everything else (bundled
families, but also unknown or malformed ids — the same bucket
`font-id->uuid` maps to `uuid/zero`)."
[font-id]
(cond
(not (string? font-id)) :builtin
(str/starts-with? font-id gfont-prefix) :google
(str/starts-with? font-id custom-prefix) :custom
:else :builtin))
(defn font-id->uuid
"Maps a content font-id to the uuid WASM keys fonts by:
- `gfont-<slug>` -> the catalog uuid,
- `custom-<uuid>` -> that uuid,
- anything else (builtin, unknown, malformed) -> `uuid/zero`, which WASM
resolves to the default font."
[font-id]
(case (font-id->backend font-id)
:google (or (gfont-id->uuid font-id) uuid/zero)
:custom (or (uuid/parse* (subs font-id (count custom-prefix))) uuid/zero)
uuid/zero))
;; --- proxy urls
(def ^:private gstatic-prefix
"https://fonts.gstatic.com/s")
(defn gstatic->proxy-url
[s base]
(let [base (str/rtrim (str base) "/")]
(str/replace (str s) gstatic-prefix base)))
;; --- variant resolution
(defn closest-variant
[variants target-weight target-style]
(when-let [target-weight (d/parse-integer target-weight)]
(let [result
(reduce
(fn [closest-match variant]
(let [weight (d/parse-integer (:weight variant))
distance (abs (- target-weight weight))
matches-style? (= target-style (:style variant))
current {:variant variant
:weight weight
:distance distance}]
(cond
;; Exact match found
(and (zero? distance)
(if target-style matches-style? true))
(reduced current)
(nil? closest-match) current
;; Update best match if this variant is closer or equal distance but higher weight
(or (< distance (:distance closest-match))
(and (= distance (:distance closest-match))
(> weight (:weight closest-match))))
current
;; Same weight as the `closest-match` but the style matches `target-style`
(and (= weight (:weight closest-match)) matches-style?)
current
:else
closest-match)))
nil
variants)]
(:variant result))))
(defn resolve-ttf-url
[font-uuid weight style]
(when-let [font (get by-uuid font-uuid)]
(let [style (if (zero? style) "normal" "italic")
variants (:variants font)]
(:ttf-url (or (closest-variant variants weight style)
(first variants))))))
;; --- BUILTIN FONTS
;;
;; Bundled with the frontend, served from `<public-uri>/fonts/`. Shared so the
;; workspace and the exporter upload the same TTF for a given weight/style.
(def local-fonts
[{:id "sourcesanspro"
:name "Source Sans Pro"
:family "sourcesanspro"
:variants
[{:id "200" :name "200" :weight "200" :style "normal" :suffix "extralight" :ttf-url "sourcesanspro-extralight.ttf"}
{:id "200italic" :name "200 Italic" :weight "200" :style "italic" :suffix "extralightitalic" :ttf-url "sourcesanspro-extralightitalic.ttf"}
{:id "300" :name "300" :weight "300" :style "normal" :suffix "light" :ttf-url "sourcesanspro-light.ttf"}
{:id "300italic" :name "300 Italic" :weight "300" :style "italic" :suffix "lightitalic" :ttf-url "sourcesanspro-lightitalic.ttf"}
{:id "regular" :name "400" :weight "400" :style "normal" :ttf-url "sourcesanspro-regular.ttf"}
{:id "italic" :name "400 Italic" :weight "400" :style "italic" :ttf-url "sourcesanspro-italic.ttf"}
{:id "600" :name "600" :weight "600" :style "normal" :suffix "semibold" :ttf-url "sourcesanspro-semibold.ttf"}
{:id "600italic" :name "600 Italic" :weight "600" :style "italic" :suffix "semibolditalic" :ttf-url "sourcesanspro-semibolditalic.ttf"}
{:id "bold" :name "700" :weight "700" :style "normal" :ttf-url "sourcesanspro-bold.ttf"}
{:id "bolditalic" :name "700 Italic" :weight "700" :style "italic" :ttf-url "sourcesanspro-bolditalic.ttf"}
{:id "black" :name "900" :weight "900" :style "normal" :ttf-url "sourcesanspro-black.ttf"}
{:id "blackitalic" :name "900 Italic" :weight "900" :style "italic" :ttf-url "sourcesanspro-blackitalic.ttf"}]}])
(defn resolve-ttf-file
"Builtin TTF file name for `weight` and `style` (0 normal, 1 italic), by the
same nearest-weight rule as the google catalog."
[weight style]
(let [variants (:variants (first local-fonts))]
(:ttf-url (or (closest-variant variants weight (if (zero? style) "normal" "italic"))
(first variants)))))
;; --- FALLBACK FONTS
;;
;; Which scripts/emoji a text uses and which (google) fallback fonts cover them.
(def ^:private emoji-pattern
#"(?:\uD83C[\uDDE6-\uDDFF]\uD83C[\uDDE6-\uDDFF])|(?:\uD83C[\uDC00-\uDFFF]|\uD83D[\uDC00-\uDEFF])|(?:\uD83E[\uDD00-\uDDFF])|(?:\uD83D[\uDE80-\uDEFF]|\uD83E[\uDC00-\uDCFF])|(?:\uD83E[\uDE70-\uDFFF])|[\u2600-\u26FF\u2700-\u27BF\u2300-\u23FF\u2B00-\u2BFF]")
@@ -0,0 +1,24 @@
# `app.common.render-wasm.*`
The host-agnostic ClojureScript side of the render-wasm binary protocol: byte
layouts, memory helpers and serializers that turn Penpot shapes into the buffers
`render-wasm` consumes.
The workspace drives it from `app.render-wasm.*`, the headless exporter from
`app.wasm.*` — same code underneath, so the two cannot drift.
Font knowledge is *not* here even though both hosts need it for rendering: it is
not specific to the wasm backend, so the google fonts catalog (baked from
`common/resources/fonts/gfonts.*.json`), the bundled builtin family and the
emoji/script fallback tables live in `app.common.fonts`. Likewise the image-id
enumeration lives in `app.common.types.shape.images`.
`shared.js` is not here: it is a per-build artifact, so each host compiles
against the copy from its own render-wasm build and passes it to
`wasm/init-serializers!` (see `app.render-wasm.api.enums`, `app.wasm.enums`).
## Rules for anything added here
**Nothing here may depend on a browser (no DOM, no WebGL, no app state) or on
`frontend/src`.** Dependencies are `app.common.*` and this subtree only. It also
has to run under plain Node — a `js/document` here breaks the exporter.
@@ -4,7 +4,7 @@
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.render-wasm.api.props
(ns app.common.render-wasm.api.props
"Browser-free WASM shape property setters, shared by the workspace render
orchestrator (`app.render-wasm.api`) and the headless exporter
(`app.wasm.serialize`).
@@ -15,15 +15,15 @@
data sources (fonts, image bytes, SVG static markup) stay in `app.render-wasm.api`."
(:require
[app.common.math :as mth]
[app.common.render-wasm.helpers :as h]
[app.common.render-wasm.mem :as mem]
[app.common.render-wasm.mem.heap32 :as mem.h32]
[app.common.render-wasm.serializers :as sr]
[app.common.render-wasm.serializers.color :as sr-clr]
[app.common.render-wasm.wasm :as wasm]
[app.common.types.fills :as types.fills]
[app.common.types.fills.impl :as types.fills.impl]
[app.common.types.path :as path]
[app.render-wasm.helpers :as h]
[app.render-wasm.mem :as mem]
[app.render-wasm.mem.heap32 :as mem.h32]
[app.render-wasm.serializers :as sr]
[app.render-wasm.serializers.color :as sr-clr]
[app.render-wasm.wasm :as wasm]))
[app.common.types.path :as path]))
(def ^:const MAX_BUFFER_CHUNK_SIZE (* 256 1024))
@@ -4,7 +4,7 @@
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.render-wasm.api.shapes
(ns app.common.render-wasm.api.shapes
"Batched shape property serialization for improved WASM performance.
This module provides a single WASM call to set all base shape properties,
@@ -13,11 +13,11 @@
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.uuid :as uuid]
[app.render-wasm.helpers :as h]
[app.render-wasm.mem :as mem]
[app.render-wasm.serializers :as sr]
[app.render-wasm.wasm :as wasm]))
[app.common.render-wasm.helpers :as h]
[app.common.render-wasm.mem :as mem]
[app.common.render-wasm.serializers :as sr]
[app.common.render-wasm.wasm :as wasm]
[app.common.uuid :as uuid]))
;; Binary layout constants matching Rust implementation:
;;
@@ -110,13 +110,9 @@
blend-mode (sr/translate-blend-mode (get shape :blend-mode))
constraint-h (let [c (get shape :constraints-h)]
(if (some? c)
(sr/translate-constraint-h c)
CONSTRAINT-NONE))
(sr/translate-constraint-h c))
constraint-v (let [c (get shape :constraints-v)]
(if (some? c)
(sr/translate-constraint-v c)
CONSTRAINT-NONE))
(sr/translate-constraint-v c))
opacity (d/nilv (get shape :opacity) 1.0)
rotation (d/nilv (get shape :rotation) 0.0)
@@ -0,0 +1,54 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.common.render-wasm.enums
"Serializer enum table from `shared.js`")
(def ^:private serializer-exports
[["raster-format" "RasterFormat"]
["blur-type" "RawBlurType"]
["blend-mode" "RawBlendMode"]
["bool-type" "RawBoolType"]
["font-style" "RawFontStyle"]
["flex-direction" "RawFlexDirection"]
["grid-direction" "RawGridDirection"]
["grow-type" "RawGrowType"]
["align-items" "RawAlignItems"]
["align-self" "RawAlignSelf"]
["align-content" "RawAlignContent"]
["justify-items" "RawJustifyItems"]
["justify-content" "RawJustifyContent"]
["justify-self" "RawJustifySelf"]
["wrap-type" "RawWrapType"]
["grid-track-type" "RawGridTrackType"]
["shadow-style" "RawShadowStyle"]
["guide-kind" "RawGuideKind"]
["stroke-style" "RawStrokeStyle"]
["stroke-cap" "RawStrokeCap"]
["shape-type" "RawShapeType"]
["constraint-h" "RawConstraintH"]
["constraint-v" "RawConstraintV"]
["sizing" "RawSizing"]
["vertical-align" "RawVerticalAlign"]
["fill-data" "RawFillData"]
["text-align" "RawTextAlign"]
["text-direction" "RawTextDirection"]
["text-decoration" "RawTextDecoration"]
["text-transform" "RawTextTransform"]
["multiple-state" "MultipleState"]
["transform-entry-kind" "RawTransformEntryKind"]
["segment-data" "RawSegmentData"]
["stroke-linecap" "RawStrokeLineCap"]
["stroke-linejoin" "RawStrokeLineJoin"]
["fill-rule" "RawFillRule"]])
(defmacro serializers
[alias]
(let [alias (name alias)]
`(cljs.core/js-obj
~@(mapcat (fn [[key export]]
[key (symbol alias export)])
serializer-exports))))
@@ -4,8 +4,8 @@
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.render-wasm.helpers
#?(:cljs (:require-macros [app.render-wasm.helpers]))
(ns app.common.render-wasm.helpers
#?(:cljs (:require-macros [app.common.render-wasm.helpers]))
(:require [app.common.data :as d]))
(def error-code
@@ -4,11 +4,11 @@
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.render-wasm.mem
(ns app.common.render-wasm.mem
(:require
[app.common.buffer :as buf]
[app.render-wasm.helpers :as h]
[app.render-wasm.wasm :as wasm]))
[app.common.render-wasm.helpers :as h]
[app.common.render-wasm.wasm :as wasm]))
(defn ->offset-32
"Convert a 8-bit (1 byte) offset to a 32-bit (4 bytes) offset"
@@ -4,7 +4,7 @@
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.render-wasm.mem.heap32
(ns app.common.render-wasm.mem.heap32
"A memory write helpers that uses 32 bits addressed offsets."
(:require
[app.common.data.macros :as dm]
@@ -4,7 +4,7 @@
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.render-wasm.serialize-shape
(ns app.common.render-wasm.serialize-shape
"Single source of truth for the host-independent part of serializing a whole
shape into the WASM design state.
@@ -24,8 +24,8 @@
The incremental workspace edit path (`set-wasm-attr!`) is unaffected; it keeps
dispatching per changed key through the same underlying `props` setters."
(:require
[app.render-wasm.api.props :as props]
[app.render-wasm.api.shapes :as shapes]))
[app.common.render-wasm.api.props :as props]
[app.common.render-wasm.api.shapes :as shapes]))
(defn serialize-shape!
"Applies every host-independent WASM property of `shape`. `set-shape-base-props`
@@ -4,16 +4,16 @@
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.render-wasm.serializers
(ns app.common.render-wasm.serializers
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.files.helpers :as cfh]
[app.common.render-wasm.serializers.color :as sr-clr]
[app.common.render-wasm.wasm :as wasm]
[app.common.types.color :as clr]
[app.common.types.shape-tree :as ctst]
[app.common.uuid :as uuid]
[app.render-wasm.serializers.color :as sr-clr]
[app.render-wasm.wasm :as wasm]
[cuerdas.core :as str]))
(defn u8
@@ -116,13 +116,13 @@
(defn translate-constraint-h
[type]
(let [values (unchecked-get wasm/serializers "constraint-h")
default 5] ;; TODO: fix code in rust so we have a proper None variant
default (unchecked-get values "none")]
(d/nilv (unchecked-get values (d/name type)) default)))
(defn translate-constraint-v
[type]
(let [values (unchecked-get wasm/serializers "constraint-v")
default 5] ;; TODO: fix code in rust so we have a proper None variant
default (unchecked-get values "none")]
(d/nilv (unchecked-get values (d/name type)) default)))
(defn translate-bool-type
@@ -1,4 +1,4 @@
(ns app.render-wasm.serializers.color
(ns app.common.render-wasm.serializers.color
(:require
[app.common.math :as mth]))
@@ -4,23 +4,24 @@
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.render-wasm.text-content
(ns app.common.render-wasm.text-content
"Single source of truth for writing a text shape's content into the WASM design
state. The binary layout ([num-spans][paragraph attrs][span attrs][text]) is
identical for the workspace and the headless exporter — only *font resolution*
differs (the workspace uses the loaded fonts DB; the exporter uses its gfonts
catalog + custom variants). So the byte-writing lives here and font resolution
is injected via the `opts` map passed to `write-shape-text!`.
identical for the workspace and the headless exporter, and so is the font-id
-> uuid mapping (`cfnt/font-id->uuid`). Only *variant* resolution differs —
the workspace has a loaded fonts DB, the exporter does not — so that part is
injected via the `opts` map passed to `write-shape-text!`.
Fully portable (no store/DOM/React), so it runs under Node too."
(:require
[app.common.data :as d]
[app.common.fonts :as cfnt]
[app.common.render-wasm.helpers :as h]
[app.common.render-wasm.mem :as mem]
[app.common.render-wasm.serializers :as sr]
[app.common.render-wasm.wasm :as wasm]
[app.common.types.fills.impl :as types.fills.impl]
[app.common.uuid :as uuid]
[app.render-wasm.helpers :as h]
[app.render-wasm.mem :as mem]
[app.render-wasm.serializers :as sr]
[app.render-wasm.wasm :as wasm]
[cuerdas.core :as str]))
(def ^:const PARAGRAPH-ATTR-U8-SIZE 12)
@@ -169,13 +170,15 @@
"Writes one paragraph's spans + text into WASM and appends it to the current
shape via `_set_shape_text_content`.
`opts` injects host-specific font resolution:
- `:normalize-font-id` (string font-id -> wasm uuid) — required in practice,
`opts` injects host-specific font handling:
- `:normalize-font-id` (string font-id -> wasm uuid) defaults to the shared
`cfnt/font-id->uuid`, which is what both hosts want — a host only
overrides it if it keys its font store some other way,
- `:normalize-paragraph`/`:normalize-span` — font-variant normalization from a
fonts DB (workspace); default to identity (the exporter resolves variants
differently / not at all)."
[spans paragraph text {:keys [normalize-font-id normalize-paragraph normalize-span]
:or {normalize-font-id identity
:or {normalize-font-id cfnt/font-id->uuid
normalize-paragraph identity
normalize-span (fn [span _paragraph] span)}}]
(let [paragraph (normalize-paragraph paragraph)
@@ -4,8 +4,7 @@
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.render-wasm.wasm
(:require ["./api/shared.js" :as shared]))
(ns app.common.render-wasm.wasm)
(defonce internal-frame-id nil)
(defonce internal-frame-type 0)
@@ -65,41 +64,19 @@
(set! gl-context nil)
(set! context-initialized? false))
(defonce serializers
#js {:raster-format shared/RasterFormat
:blur-type shared/RawBlurType
:blend-mode shared/RawBlendMode
:bool-type shared/RawBoolType
:font-style shared/RawFontStyle
:flex-direction shared/RawFlexDirection
:grid-direction shared/RawGridDirection
:grow-type shared/RawGrowType
:align-items shared/RawAlignItems
:align-self shared/RawAlignSelf
:align-content shared/RawAlignContent
:justify-items shared/RawJustifyItems
:justify-content shared/RawJustifyContent
:justify-self shared/RawJustifySelf
:wrap-type shared/RawWrapType
:grid-track-type shared/RawGridTrackType
:shadow-style shared/RawShadowStyle
:guide-kind shared/RawGuideKind
:stroke-style shared/RawStrokeStyle
:stroke-cap shared/RawStrokeCap
:shape-type shared/RawShapeType
:constraint-h shared/RawConstraintH
:constraint-v shared/RawConstraintV
:sizing shared/RawSizing
:vertical-align shared/RawVerticalAlign
:fill-data shared/RawFillData
:text-align shared/RawTextAlign
:text-direction shared/RawTextDirection
:text-decoration shared/RawTextDecoration
:text-transform shared/RawTextTransform
:multiple-state shared/MultipleState
:transform-entry-kind shared/RawTransformEntryKind
:segment-data shared/RawSegmentData
:stroke-linecap shared/RawStrokeLineCap
:stroke-linejoin shared/RawStrokeLineJoin
:fill-rule shared/RawFillRule})
(defonce serializers nil)
(defn init-serializers!
"Binds the enum table produced by the `enums/serializers` macro."
[table]
(let [missing (array)]
(doseq [key (js/Object.keys table)]
(when (undefined? (unchecked-get table key))
(.push missing key)))
(when (pos? (alength missing))
(throw (ex-info "stale or incomplete render-wasm shared.js"
{:missing (vec missing)})))
(set! serializers table)))
+95 -10
View File
@@ -233,7 +233,50 @@
[:grow-type {:optional true}
[::sm/one-of grow-types]]
[:applied-tokens {:optional true} cto/schema:applied-tokens]
[:plugin-data {:optional true} ctpg/schema:plugin-data]])
[:plugin-data {:optional true} ctpg/schema:plugin-data]
;; `rotation`, `flip-x` and `flip-y` are fields of the `Shape` record (see
;; `cr/defrecord Shape` above) and this schema did not declare them.
;; `rotation` was already named in `allowed-shape-attrs` here and in
;; `app.common.types.shape.attrs/editable-attrs`, so the omission was in this
;; schema and not in the model. Anything reading the model from the schema
;; rather than from a live shape missed all three: the graph projection
;; derives one column per entry (`app.graph.schema.projection`), so shape
;; nodes carried no rotation at all, and a consumer cannot place a shape
;; without it.
;;
;; Nilable, because `app.common.record/defrecord` cannot remove a base
;; field: its `without` assocs nil and its `containsKey` answers true
;; whatever the field holds, so nil is how a record field says "unset".
;; `flip-x` and `flip-y` are nil on every shape `setup-shape` builds, since
;; `make-minimal-shape` gives them no default.
;;
;; Optional as well, unlike the geometry group below, because this schema
;; has a second job: `check-shape-generic-attrs` validates partial update
;; payloads with it, such as the `{:blocked true}` that
;; `app.main.data.workspace/update-shape` passes. A required key here would
;; reject every such payload.
[:rotation {:optional true} [:maybe ::sm/safe-number]]
[:flip-x {:optional true} [:maybe :boolean]]
[:flip-y {:optional true} [:maybe :boolean]]
;; Carried on circles, rects and texts too, not only on frames, so it
;; belongs here rather than in `schema:frame-attrs`. Not nilable: the key
;; lives outside the record, `app.common.logic.shapes` dissocs it to unset
;; it, and `setup-shape` drops it when a caller passes nil.
[:hide-in-viewer {:optional true} :boolean]
;; The SVG provenance an import leaves on a shape. Typed `:map` rather than
;; more precisely on purpose: legacy files hold `svg-transform` as a plain
;; `{:a … :f}` map rather than a `::gmt/matrix` record, and `svg-viewbox` as
;; either a `::grc/rect` record or a plain map, so a tighter schema here
;; would reject files that are otherwise valid. The graph *column* types are
;; tightened separately, where a wrong guess costs a column rather than a
;; rejected file (`app.graph.schema.contract/type-overrides`).
[:svg-attrs {:optional true} :map]
[:svg-defs {:optional true} :map]
[:svg-transform {:optional true} :map]
[:svg-viewbox {:optional true} :map]])
(def schema:group-attrs
[:map {:title "GroupAttrs"}
@@ -244,7 +287,30 @@
[:shapes [:vector {:gen/max 10 :gen/min 1} ::sm/uuid]]
[:hide-fill-on-export {:optional true} :boolean]
[:show-content {:optional true} :boolean]
[:hide-in-viewer {:optional true} :boolean]])
;; `hide-in-viewer` moved to `schema:shape-generic-attrs`: stored files carry
;; it on circles, rects and texts too, not only on frames.
;; `use-for-thumbnail` is a frame attribute the model has long had, since
;; `app.common.files.migrations` renames `:use-for-thumbnail?` to it and
;; `app.common.logic.libraries` reads it, and this schema had not declared.
[:use-for-thumbnail {:optional true} :boolean]])
(def ^:private schema:nilable-geom-attrs
"`schema:shape-geom-attrs`, but nilable.
Bools and paths are the only two shape types whose geometry can be nil:
`make-minimal-shape` gives `x`, `y`, `width` and `height` a default for every
other type and skips those two, whose extent their content and `selrect`
imply instead. The four keys stay required, because they are `Shape` record
fields and `app.common.record/defrecord` keeps a base field present whatever
it holds. So these two branches cannot merge `schema:shape-geom-attrs`, which
rejects the nil, and declare the same four keys nilable instead. A
schema-derived reader previously saw a bool or a path as having no position or
size at all."
[:map {:title "NilableGeometryAttrs"}
[:x [:maybe ::sm/safe-number]]
[:y [:maybe ::sm/safe-number]]
[:width [:maybe ::sm/safe-number]]
[:height [:maybe ::sm/safe-number]]])
(def ^:private schema:bool-attrs
[:map {:title "BoolAttrs"}
@@ -253,10 +319,19 @@
[:content path/schema:content]])
(def ^:private schema:rect-attrs
[:map {:title "RectAttrs"}])
[:map {:title "RectAttrs"}
;; Legacy radii, set by SVG import (`app.common.files.shapes-builder` parses
;; `rx`/`ry` off the element) and by migration 0003, which assocs `0`.
;; Superseded by `r1` to `r4`, but stored files still carry them. Not
;; nilable: both keys live outside the `Shape` record, so a dissoc removes
;; them, and `setup-shape` drops a nil before the merge.
[:rx {:optional true} ::sm/safe-number]
[:ry {:optional true} ::sm/safe-number]])
(def ^:private schema:circle-attrs
[:map {:title "CircleAttrs"}])
[:map {:title "CircleAttrs"}
[:rx {:optional true} ::sm/safe-number]
[:ry {:optional true} ::sm/safe-number]])
(def ^:private schema:svg-raw-attrs
[:map {:title "SvgRawAttrs"}
@@ -266,7 +341,15 @@
;; keeps the child ids typed as uuid, so a JSON round trip (binfile
;; export/import) decodes them back to uuids instead of leaving
;; strings that no longer resolve against the objects map.
[:shapes {:optional true} [:vector {:gen/max 10} ::sm/uuid]]])
[:shapes {:optional true} [:vector {:gen/max 10} ::sm/uuid]]
;; The raw SVG node an import kept.
;; `app.common.files.shapes-builder/create-raw-svg` sets it and
;; `allowed-svg-attrs` names it. Usually the parsed element,
;; `{:tag … :attrs … :content …}`, but a bare text node arrives as the
;; string itself: `<text>hi</text>` becomes one svg-raw for the element
;; and another for `"hi"`. `app.common.files.shapes-builder/parse-svg-element`
;; carries a FIXME about exactly that. Both forms are legal and stored.
[:content {:optional true} [:or :map :string]]])
(def schema:image-attrs
[:map {:title "ImageAttrs"}
@@ -301,7 +384,10 @@
(->> (sg/generator schema:shape-base-attrs)
(sg/mcat (fn [{:keys [type] :as shape}]
(sg/let [attrs1 (sg/generator schema:shape-generic-attrs)
attrs2 (sg/generator schema:shape-geom-attrs)
attrs2 (if (or (= type :path)
(= type :bool))
(sg/generator schema:nilable-geom-attrs)
(sg/generator schema:shape-geom-attrs))
attrs3 (case type
:text (sg/generator schema:text-attrs)
:path (sg/generator schema:path-attrs)
@@ -312,10 +398,7 @@
:bool (sg/generator schema:bool-attrs)
:group (sg/generator schema:group-attrs)
:frame (sg/generator schema:frame-attrs))]
(if (or (= type :path)
(= type :bool))
(merge attrs1 shape attrs3)
(merge attrs1 shape attrs2 attrs3)))))
(merge attrs1 shape attrs2 attrs3))))
(sg/fmap create-shape)))
(def schema:shape-attrs
@@ -347,6 +430,7 @@
ctsl/schema:layout-child-attrs
schema:bool-attrs
schema:shape-generic-attrs
schema:nilable-geom-attrs
schema:shape-base-attrs]]
[:rect
@@ -386,6 +470,7 @@
ctsl/schema:layout-child-attrs
schema:path-attrs
schema:shape-generic-attrs
schema:nilable-geom-attrs
schema:shape-base-attrs]]
[:text
@@ -4,12 +4,12 @@
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.render-wasm.resources
(ns app.common.types.shape.images
"Host-agnostic enumeration of the external resources a scene needs to
render: which image bytes its shapes reference. Pure data walking — no
browser or Node dependencies — so the workspace and the headless exporter
derive the same set from the same source (sibling of
`app.render-wasm.fallback-fonts`, which does the same for fonts)."
derive the same set from the same source (counterpart of
`app.common.fonts`, which does the same for fonts)."
(:require
[app.common.types.fills :as types.fills]))
@@ -9,7 +9,6 @@
[app.common.data :as d]
[app.common.files.helpers :as cfh]
[app.common.geom.point :as gpt]
[app.common.geom.shapes.bounds :as gsb]
[app.common.schema :as sm]
[app.common.schema.generators :as sg]))
@@ -482,7 +481,13 @@
(if (nil? dest-frame)
[(gpt/point 0 0) [:top :left]]
(let [overlay-size (gsb/get-object-bounds objects dest-frame)
(let [;; Use the destination frame selrect (the visible frame box) to compute
;; the overlay position, not its full object bounds. Bounds include
;; padding for shadows, blur, strokes and overflowing children, which
;; would make centered/right/bottom positions off by half that padding
;; (the visible frame ends up shifted). The viewer reserves the bounds
;; size and re-aligns the selrect separately (see viewer/calculate-delta).
overlay-size (:selrect dest-frame)
base-frame-size (:selrect base-frame)
relative-to-shape-size (:selrect relative-to-shape)
relative-to-adjusted-to-base-frame {:x (- (:x relative-to-shape-size) (:x base-frame-size))
@@ -146,3 +146,24 @@
;; (app.common.pprint/pprint shape-3)
(= shape shape-3)))
{:num 200})))
(t/deftest shape-generator-key-presence
"The generator must produce the keys the schema declares required, even when
nilable. This is a targeted check for the attributes added to
`schema:shape-generic-attrs` and `schema:nilable-geom-attrs`."
(let [shapes (sg/sample (sg/generator schema:shape) {:size 200})
by-type (group-by :type shapes)]
;; All shapes: rotation, flip-x, flip-y are base record fields, always
;; present (possibly nil).
(doseq [shape shapes]
(t/is (contains? shape :rotation) "missing :rotation")
(t/is (contains? shape :flip-x) "missing :flip-x")
(t/is (contains? shape :flip-y) "missing :flip-y"))
;; Bool and path: x/y/width/height are required-but-nilable in the
;; schema. The generator must produce them (nil is a valid value).
(doseq [shape (concat (get by-type :bool [])
(get by-type :path []))]
(t/is (contains? shape :x) "bool/path missing :x")
(t/is (contains? shape :y) "bool/path missing :y")
(t/is (contains? shape :width) "bool/path missing :width")
(t/is (contains? shape :height) "bool/path missing :height"))))
@@ -10,6 +10,7 @@
[app.common.geom.point :as gpt]
[app.common.geom.rect :as grc]
[app.common.geom.shapes :as gsh]
[app.common.geom.shapes.bounds :as gsb]
[app.common.math :as mth]
[app.common.types.shape :as cts]
[app.common.types.shape.interactions :as ctsi]
@@ -1078,3 +1079,49 @@
[overlay-pos snap] (ctsi/calc-overlay-position frame-relative base-frame objects base-frame base-frame overlay-frame frame-offset)]
(t/is (= (gpt/point 18 22) overlay-pos))
(t/is (= [:top :left] snap))))))
(t/deftest calc-overlay-position-ignores-filter-bounds
;; Regression for #9048: the overlay position must be computed from the
;; destination frame selrect (the visible frame box), not from its
;; filter-inflated object bounds. Shadows, blur, strokes or overflowing
;; children make get-object-bounds larger than the selrect, which used to
;; shift centered/right/bottom overlays by half that extra padding (the
;; overlay appeared offset, e.g. "a bit to the left").
(let [base-frame (cts/setup-shape {:type :frame :width 100 :height 100})
overlay-plain (cts/setup-shape {:type :frame :width 30 :height 20})
;; same selrect as overlay-plain, but with a drop shadow that widens
;; and heightens its object bounds well beyond the selrect.
overlay-shadow (-> (cts/setup-shape {:type :frame :width 30 :height 20})
(assoc :shadow [{:style :drop-shadow
:offset-x 0 :offset-y 0
:spread 10 :blur 0 :hidden false}]))
objects {(:id base-frame) base-frame
(:id overlay-plain) overlay-plain
(:id overlay-shadow) overlay-shadow}
frame-offset (gpt/point 5 5)
interaction (-> ctsi/default-interaction
(ctsi/set-action-type :open-overlay)
(ctsi/set-position-relative-to (:id base-frame)))]
;; Precondition: the shadow really does inflate the object bounds, so the
;; assertions below are meaningful (otherwise the test would be vacuous).
(t/is (> (:width (gsb/get-object-bounds objects overlay-shadow))
(:width (:selrect overlay-shadow))))
(t/is (> (:height (gsb/get-object-bounds objects overlay-shadow))
(:height (:selrect overlay-shadow))))
;; For every position type that depends on the overlay size, the computed
;; position must be identical whether or not the destination frame has a
;; bounds-inflating shadow.
(doseq [pos-type [:center :top-center :top-right :bottom-center :bottom-right]]
(let [i-plain (-> interaction
(ctsi/set-destination (:id overlay-plain))
(ctsi/set-overlay-pos-type pos-type base-frame objects))
i-shadow (-> interaction
(ctsi/set-destination (:id overlay-shadow))
(ctsi/set-overlay-pos-type pos-type base-frame objects))
[pos-plain snap-plain] (ctsi/calc-overlay-position i-plain base-frame objects base-frame base-frame overlay-plain frame-offset)
[pos-shadow snap-shadow] (ctsi/calc-overlay-position i-shadow base-frame objects base-frame base-frame overlay-shadow frame-offset)]
(t/testing (str "overlay position ignores filter bounds for " pos-type)
(t/is (= pos-plain pos-shadow))
(t/is (= snap-plain snap-shadow)))))))
+23
View File
@@ -246,6 +246,7 @@ ENV CLJKONDO_VERSION=2026.07.24 \
BABASHKA_VERSION=1.13.219 \
CLJFMT_VERSION=0.16.5 \
PIXI_VERSION=0.75.0 \
K6_VERSION=2.0.0 \
GITHUB_CLI_VERSION=2.97.0 \
UV_VERSION=0.12.1 \
UV_TOOL_DIR=/opt/uv/tools \
@@ -375,6 +376,28 @@ RUN set -ex; \
mv /tmp/mc /opt/utils/bin/; \
chmod +x /opt/utils/bin/mc;
# Install k6
RUN set -ex; \
ARCH="$(dpkg --print-architecture)"; \
case "${ARCH}" in \
aarch64|arm64) \
BINARY_URL="https://github.com/grafana/k6/releases/download/v$K6_VERSION/k6-v$K6_VERSION-linux-arm64.tar.gz"; \
;; \
amd64|x86_64) \
BINARY_URL="https://github.com/grafana/k6/releases/download/v$K6_VERSION/k6-v$K6_VERSION-linux-amd64.tar.gz"; \
;; \
*) \
echo "Unsupported arch: ${ARCH}"; \
exit 1; \
;; \
esac; \
curl -LfsSo /tmp/k6.tar.gz ${BINARY_URL}; \
cd /tmp; \
tar -xf /tmp/k6.tar.gz; \
mv /tmp/k6-v$K6_VERSION-linux-*/k6 /opt/utils/bin/; \
chmod +x /opt/utils/bin/k6; \
rm -rf /tmp/k6.tar.gz /tmp/k6-v$K6_VERSION-linux-*;
# Install uv
RUN set -ex; \
ARCH="$(dpkg --print-architecture)"; \
+19 -7
View File
@@ -419,16 +419,28 @@ After creating or modifying this file, **reload the browser** (no need to restar
### Backend flags via PENPOT_FLAGS
Backend feature flags are controlled through the `PENPOT_FLAGS` environment
variable using the same `enable-<flag>` / `disable-<flag>` format. You can set
this in the `docker/devenv/docker-compose.yaml` file under the `main` service
`environment` section:
variable using the same `enable-<flag>` / `disable-<flag>` format. The devenv
sets its own list in `backend/scripts/_env`.
```yaml
environment:
- PENPOT_FLAGS=enable-access-tokens enable-mcp
To change that list for your checkout, create `backend/scripts/_env.local`.
`backend/scripts/start-dev` sources it immediately after `_env`, and the file
is gitignored, so your override never appears in `git status`:
```bash
export PENPOT_FLAGS="$PENPOT_FLAGS enable-access-tokens enable-mcp"
```
This requires **restarting the backend** to take effect.
Flags are applied left to right and the last entry wins, so appending to
`$PENPOT_FLAGS` both adds flags and switches off ones that `_env` enables:
`disable-demo-users` at the end turns off the demo users that `_env` enables
earlier.
Setting `PENPOT_FLAGS` in the container environment does not work for this,
because `_env` expands the inherited value *before* its own list. Any flag it
sets afterwards wins over yours.
This requires **restarting the backend** to take effect: stop the process in
the `backend` tmux window and run `./scripts/start-dev` again.
> **Note**: Some features (e.g., access tokens, webhooks) need both frontend and
> backend flags enabled to work end-to-end. The frontend flag enables the UI, while
+1
View File
@@ -33,6 +33,7 @@
"watch:app": "pnpm run clear:shadow-cache && clojure -M:dev:shadow-cljs watch main",
"watch": "pnpm run watch:app",
"build:app": "clojure -M:dev:shadow-cljs release main",
"build:wasm": "../render-wasm/build export",
"build": "pnpm run clear:shadow-cache && pnpm run build:app",
"fmt": "cljfmt fix --parallel=true src/",
"check-fmt": "cljfmt check --parallel=true src/",
+14
View File
@@ -8,6 +8,17 @@ export NODE_ENV=production;
corepack enable;
corepack install || exit 1;
pnpm install || exit 1;
pnpm run build:wasm;
WASM_SRC="resources/wasm";
WASM_SHARED="src/app/wasm/shared.js";
if [ ! -f "$WASM_SRC/render-wasm.wasm" ] || [ ! -f "$WASM_SHARED" ]; then
echo "ERROR: the render-wasm build did not produce:" >&2;
echo " $WASM_SRC/render-wasm.wasm" >&2;
echo " $WASM_SHARED" >&2;
exit 1;
fi
rm -rf target
# Build the application
@@ -18,6 +29,9 @@ cp pnpm-workspace.yaml target/;
cp package.json target/;
touch target/pnpm-workspace.yaml;
mkdir -p target/$WASM_SRC;
cp "$WASM_SRC/render-wasm.js" "$WASM_SRC/render-wasm.wasm" target/$WASM_SRC/;
cat <<EOF | tee target/setup
#/usr/bin/env bash
set -e;
+7
View File
@@ -12,6 +12,7 @@
[app.config :as cf]
[app.http :as http]
[app.redis :as redis]
[app.wasm :as wasm]
[promesa.core :as p]))
(enable-console-print!)
@@ -23,6 +24,12 @@
:public-uri (str (cf/get :public-uri))
:internal-uri (str (cf/get-internal-uri))
:version (:full cf/version))
(when (contains? cf/flags :wasm-export)
(l/warn :msg "headless wasm export enabled (experimental)"
:hint (str "renders run in-process on a single shared wasm module, "
"one at a time; not recommended for busy instances")
:wasm-dir wasm/artifact-dir
:image-cache-mb wasm/image-cache-mb))
(p/do!
(bwr/init)
(redis/init)
+19 -7
View File
@@ -7,10 +7,13 @@
(ns app.renderer
"Common renderer interface."
(:require
[app.common.logging :as l]
[app.common.spec :as us]
[app.config :as cf]
[app.renderer.bitmap :as rb]
[app.renderer.pdf :as rp]
[app.renderer.svg :as rs]
[app.renderer.wasm :as rw]
[cljs.spec.alpha :as s]))
(s/def ::name ::us/string)
@@ -36,13 +39,22 @@
:opt-un [::is-wasm]))
(defn render
[{:keys [type] :as params} on-object]
[{:keys [type is-wasm] :as params} on-object]
(us/verify ::render-params params)
(us/verify fn? on-object)
(case type
:png (rb/render params on-object)
:jpeg (rb/render params on-object)
:webp (rb/render params on-object)
:pdf (rp/render params on-object)
:svg (rs/render params on-object)))
(let [wasm-export? (contains? cf/flags :wasm-export)
headless? (and is-wasm wasm-export? (not= :svg type))]
(when is-wasm
(l/info :hint "render"
:type type
:wasm-export wasm-export?
:backend (if headless? "wasm" "browser")))
(if headless?
(rw/render params on-object)
(case type
:png (rb/render params on-object)
:jpeg (rb/render params on-object)
:webp (rb/render params on-object)
:pdf (rp/render params on-object)
:svg (rs/render params on-object)))))
+451
View File
@@ -0,0 +1,451 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.renderer.wasm
"Headless renderer backend: renders exports with the render-wasm Skia
pipeline in this Node process, with no browser and no WebGL.
Per request: fetch scene (get-page RPC) -> serialize -> provision fonts and
images -> relayout text with the real fonts -> render each object.
One shared WASM design state, so requests are serialized one at a time.
Handles png/jpeg/webp (Skia encodes all three) and pdf; `:svg` stays on the
browser path."
(:require
["node:fs" :as fs]
["undici" :as http]
[app.common.data :as d]
[app.common.fonts :as cfnt]
;; Required for side effects: these register the transit read handlers and
;; deftype impls the `get-page` response is decoded into.
[app.common.geom.matrix]
[app.common.geom.point]
[app.common.geom.rect]
[app.common.logging :as l]
[app.common.transit :as t]
[app.common.types.fills.impl]
[app.common.types.objects-map]
[app.common.types.path.impl]
[app.common.types.shape]
[app.common.types.shape.images :as images]
[app.common.uri :as u]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.util.mime :as mime]
[app.util.shell :as sh]
[app.wasm :as wasm]
[app.wasm.serialize :as serialize]
[cuerdas.core :as str]
[promesa.core :as p]))
;; --- module lifecycle (one shared, lazily-initialized instance)
(defonce ^:private module* (atom nil))
(defn- ensure-module!
[]
(or @module*
(reset! module* (wasm/init!))))
;; --- serialized access to the shared module
;;
;; `handle-multiple-export` fans out partitions concurrently, but there is one
;; design state and one global mem buffer, so their serialize/render/alloc must
;; not interleave.
(defonce ^:private queue (atom (p/resolved nil)))
(defn- enqueue!
"Runs `thunk` (0-arg, returns a promise) only after all previously enqueued
work has settled. Returns `thunk`'s promise. A task's failure is isolated:
it doesn't break the chain for the next task."
[thunk]
(let [result (p/handle @queue (fn [_ _] (thunk)))]
(reset! queue (p/handle result (fn [_ _] nil)))
result))
;; --- backend endpoints
;;
;; Every fetch targets the internal endpoint (falling back to public-uri),
;; in a deployment the exporter reaches the backend over the container network
(defn- internal-uri
"Absolute URI for `path` on the internal (backend) endpoint."
[path]
(-> (cf/get-internal-uri)
(u/ensure-path-slash)
(u/join path)
(str)))
(defn- error-detail
"Node's fetch reports every transport failure as a bare `TypeError: fetch
failed`; the actual reason (TLS rejection, DNS, ECONNREFUSED) is buried in a
nested `cause` chain that the logger does not print. Flattens the chain into
one readable string."
[cause]
(->> (iterate (fn [^js e] (unchecked-get e "cause")) cause)
(take-while some?)
(take 5)
(map (fn [^js e]
(let [code (unchecked-get e "code")
msg (or (unchecked-get e "message") (str e))]
(if code (str code ": " msg) msg))))
(str/join " <- ")))
(defn- fetch!
"`undici/fetch` that fails with an ex-info carrying the target uri and the
unwrapped cause chain, so a failed request says what actually went wrong and
against which endpoint."
[uri opts]
(->> (p/do (http/fetch uri opts))
(p/merr (fn [cause]
(p/rejected (ex-info "http fetch failed"
{:uri uri :detail (error-detail cause)}
cause))))))
(defn- explain
"Log-friendly reason for `cause`: the detail `fetch!` already attached, or a
freshly unwrapped chain for anything else (WASM aborts, decode errors)."
[cause]
(or (:detail (ex-data cause))
(error-detail cause)))
(defn- rpc-headers
"Auth headers for backend RPC calls (management key + bearer)."
[token]
#js {"Content-Type" "application/transit+json"
"X-Shared-Key" (str "exporter " cf/management-key)
"Authorization" (str "Bearer " token)})
(defn- asset-headers
"Auth headers for `/assets/*`. Cookie, not Bearer: those endpoints redirect to
a presigned S3/minio URL, and a Bearer header makes S3 400 (\"multiple
authentication types\")."
[token]
#js {"X-Shared-Key" (str "exporter " cf/management-key)
"Cookie" (str "auth-token=" token)})
;; --- shape bundle fetch (backend RPC)
(defn- fetch-objects
"Fetches the exported roots and their children from the backend via the
`get-page` RPC (`:object-id`, as the browser render path does), using the
same auth the exporter uses elsewhere (management key + bearer)."
[{:keys [file-id page-id share-id token objects]}]
(let [headers (rpc-headers token)
root-ids (into #{} (map :id) objects)
body (t/encode-str (cond-> {:file-id file-id
:page-id page-id}
(seq root-ids) (assoc :object-id root-ids)
share-id (assoc :share-id share-id)))
uri (internal-uri "api/rpc/command/get-page")]
(l/dbg :hint "wasm render: get-page"
:uri uri
:file-id (str file-id)
:page-id (str page-id)
:roots (count root-ids))
(->> (fetch! uri #js {:method "POST" :headers headers :body body})
(p/mcat (fn [^js resp]
(if (= 200 (.-status resp))
(.text resp)
(->> (.text resp)
(p/mcat (fn [resp-body]
(l/error :hint "wasm render: get-page failed"
:uri uri
:status (.-status resp)
:body resp-body)
(p/rejected (ex-info "get-page failed"
{:status (.-status resp)
:body resp-body}))))))))
(p/fmap t/decode-str)
(p/fmap :objects))))
;; --- font resolution
;;
;; The text serializer keeps each font's real uuid, so `wasm/fonts-for-shape`
;; reports it. Custom (team) fonts resolve through the file's font variants,
;; google fonts through the shared `app.common.fonts` catalog; builtin
;; fonts through its bundled family + the frontend's static `/fonts/`.
(defn- fetch-font-variants
"Team (custom) font variants for the file, or nil — a failure here degrades
to fallback fonts, it does not fail the export."
[{:keys [file-id share-id token]}]
(let [headers (rpc-headers token)
body (t/encode-str (cond-> {:file-id file-id}
share-id (assoc :share-id share-id)))
uri (internal-uri "api/rpc/command/get-font-variants")]
(->> (fetch! uri #js {:method "POST" :headers headers :body body})
(p/mcat (fn [^js resp]
(if (= 200 (.-status resp))
(.text resp)
(p/resolved nil))))
(p/fmap (fn [s] (when s (t/decode-str s))))
(p/merr (fn [cause]
(l/warn :hint "wasm render: get-font-variants failed"
:uri uri :detail (explain cause) :cause cause)
(p/resolved nil))))))
(defn- fetch-ttf-bytes
"Downloads a TTF, returning a promise of an ArrayBuffer (or nil). A failure
here degrades to fallback fonts, it does not fail the export."
([uri] (fetch-ttf-bytes uri #js {:method "GET"}))
([uri opts]
(->> (fetch! uri opts)
(p/mcat (fn [^js resp]
(if (= 200 (.-status resp))
(.arrayBuffer resp)
(p/resolved nil))))
(p/merr (fn [cause]
(l/warn :hint "wasm render: font fetch failed"
:uri uri :detail (explain cause) :cause cause)
(p/resolved nil))))))
;; TTF bytes cached for the process lifetime, keyed by whatever identifies the
;; variant (a gfont id+weight+style, a builtin file name).
(defonce ^:private font-bytes* (atom {}))
(defn- cached-ttf-bytes
[cache-key fetch-fn]
(if-let [bytes (get @font-bytes* cache-key)]
(p/resolved bytes)
(->> (fetch-fn)
(p/fmap (fn [buf]
(when buf (swap! font-bytes* assoc cache-key buf))
buf)))))
(defn- fetch-asset-bytes
[asset-id {:keys [token]}]
(fetch-ttf-bytes (internal-uri (str "assets/by-id/" asset-id))
#js {:method "GET" :headers (asset-headers token)}))
(defn- fetch-gfont-bytes
[ttf-url]
(fetch-ttf-bytes (cfnt/gstatic->proxy-url ttf-url (internal-uri "internal/gfonts/font"))))
(defn- fetch-builtin-font-bytes
[ttf-file]
(cached-ttf-bytes ttf-file #(fetch-ttf-bytes (internal-uri (str "fonts/" ttf-file)))))
(defn- make-resolve-font
"Builds a `resolve-font` fn (family map -> promise of TTF bytes). Custom
variants first, matching uuid+weight+style then degrading to uuid+weight then
uuid; the bundled fonts for `uuid/zero`, which is what `font-id->uuid` maps
every builtin family to; google catalog otherwise."
[variants params]
(fn [{:keys [id weight style]}]
(let [font-uuid (uuid/from-unsigned-parts (aget id 0) (aget id 1) (aget id 2) (aget id 3))
style-str (if (zero? style) "normal" "italic")
variant (or (d/seek (fn [v] (and (= (:font-id v) font-uuid)
(= (:font-weight v) weight)
(= (name (:font-style v)) style-str)))
variants)
(d/seek (fn [v] (and (= (:font-id v) font-uuid)
(= (:font-weight v) weight)))
variants)
(d/seek (fn [v] (= (:font-id v) font-uuid)) variants))]
(cond
(:ttf-file-id variant)
(fetch-asset-bytes (:ttf-file-id variant) params)
(= uuid/zero font-uuid)
(fetch-builtin-font-bytes (cfnt/resolve-ttf-file weight style))
:else
(if-let [gurl (cfnt/resolve-ttf-url font-uuid weight style)]
(fetch-gfont-bytes gurl)
(p/resolved nil))))))
;; --- fallback fonts (emoji + per-script noto fonts)
;;
;; Emoji and non-latin scripts render through fallback families, not through
;; any span's font family, so `wasm/fonts-for-shape` never reports them and the
;; provisioning above never uploads them. Must run per request, since
;; `clear-fonts!` empties the store; the TTF bytes stay cached per process.
(defn- scene-fallback-fonts
"Fallback font descriptors needed by the scene's text. Deduped because
several languages map to one noto family and provisioning is concurrent —
otherwise they all miss the byte cache at once and refetch the same TTF."
[scene]
(let [texts (for [shape (vals scene)
:when (= :text (:type shape))
node (or (some->> (:content shape) (tree-seq :children :children)) [])
:let [text (:text node)]
:when (string? text)]
text)
emoji? (boolean (some cfnt/contains-emoji? texts))
langs (reduce cfnt/collect-used-languages #{} texts)]
(distinct
(cond-> (cfnt/add-noto-fonts [] langs)
emoji? (cfnt/add-emoji-font)))))
(defn- fetch-fallback-font-bytes
"Downloads one fallback font's TTF. Cached by the whole variant, not just
`font-id`: `resolve-ttf-url` picks a different TTF per weight/style, so a
font-id-only key would serve the first downloaded variant for every other one."
[{:keys [font-id weight style]}]
(if-let [ttf-url (some-> (cfnt/gfont-id->uuid font-id) (cfnt/resolve-ttf-url weight style))]
(cached-ttf-bytes [font-id weight style] #(fetch-gfont-bytes ttf-url))
(p/resolved nil)))
(defn- provision-fallback-fonts!
[scene]
(->> (scene-fallback-fonts scene)
(map (fn [{:keys [font-id weight style is-emoji is-fallback] :as font}]
(if-let [font-uuid (cfnt/gfont-id->uuid font-id)]
(->> (fetch-fallback-font-bytes font)
(p/fmap (fn [buf]
(if buf
(wasm/store-font! {:id (uuid/get-u32 font-uuid)
:weight weight
:style style
:emoji? (boolean is-emoji)
:fallback? (boolean is-fallback)}
buf)
(l/warn :hint "wasm render: fallback font unavailable"
:font-id font-id)))))
(p/resolved nil))))
(p/all)))
;; --- image resolution
;;
;; Image fills reference file-media ids; the encoded bytes go straight to
;; `_store_image` (Skia decodes, no WebGL), keyed by media uuid so this happens
;; once per request rather than per rendered object.
(defn- fetch-file-media-bytes
"Downloads an image fill's encoded bytes by file-media id."
[media-id {:keys [token]}]
(let [headers (asset-headers token)
uri (internal-uri (str "assets/by-file-media-id/" media-id))]
(->> (fetch! uri #js {:method "GET" :headers headers})
(p/mcat (fn [^js resp]
(if (= 200 (.-status resp))
(.arrayBuffer resp)
(do
(l/warn :hint "wasm render: image fetch non-200"
:media-id (str media-id)
:uri uri
:status (.-status resp))
(p/resolved nil)))))
(p/merr (fn [cause]
(l/warn :hint "wasm render: image fetch failed"
:media-id (str media-id) :uri uri
:detail (explain cause) :cause cause)
(p/resolved nil))))))
(defn- provision-images!
"Fetches and stores every image the scene references (shape, stroke and
text-span fills, enumerated by `app.common.types.shape.images`). Unlike fonts,
the image store is not reset per request, so already-held images are skipped
and repeated exports of a file reuse them."
[scene params]
(let [all-ids (images/scene-image-ids scene)
new-ids (remove wasm/image-cached? all-ids)]
(l/dbg :hint "wasm render: provisioning images"
:total (count all-ids)
:cached (- (count all-ids) (count new-ids)))
(->> new-ids
(map (fn [image-id]
(->> (fetch-file-media-bytes image-id params)
(p/fmap (fn [buf]
(if buf
(do
(l/dbg :hint "wasm render: image stored"
:media-id (str image-id)
:bytes (.-byteLength ^js buf))
(wasm/store-image! image-id buf))
(l/warn :hint "wasm render: image unavailable"
:media-id (str image-id))))))))
(p/all))))
(defn- relayout-text!
"Recomputes layout for every text shape, once the real fonts are provisioned
(serialize-time layout used the fallback)."
[scene]
(doseq [shape (vals scene)
:when (= :text (:type shape))]
(wasm/update-text-layout! (:id shape))))
;; --- render
(defn- render-object-bytes
[type id scale]
(if (= :pdf type)
(let [bytes (wasm/render-shape-pdf id scale)]
(l/dbg :hint "PDF generated via Skia (render-wasm headless)"
:object-id (str id)
:backend "skia-wasm"
:bytes (.-length bytes))
bytes)
(wasm/render-shape-raster id scale type)))
(defn- render*
[{:keys [scale type objects] :as params} on-object]
(l/dbg :hint "wasm render: start"
:type type
:scale scale
:objects (count objects)
:file-id (str (:file-id params))
:page-id (str (:page-id params)))
(->> (ensure-module!)
(p/mcat (fn [_] (fetch-objects params)))
(p/mcat (fn [scene]
(l/dbg :hint "wasm render: scene fetched" :shapes (count scene))
(serialize/serialize-scene! scene)
(l/dbg :hint "wasm render: scene serialized")
;; So fonts from a previous request don't leak into this one.
(wasm/clear-fonts!)
(->> (p/all [(fetch-font-variants params)
(provision-images! scene params)
(provision-fallback-fonts! scene)])
(p/mcat
(fn [[variants _]]
(let [resolve-font (make-resolve-font (or variants []) params)]
;; Before rendering, so the relayout below sees real
;; font metrics. Deduped across objects: a partition
;; sharing one family downloads its TTF once.
(wasm/provision-fonts! (map :id objects) resolve-font))))
(p/mcat
(fn [_]
(relayout-text! scene)
(p/run
(fn [{:keys [id] :as object}]
(let [bytes (render-object-bytes type id scale)
path (sh/tempfile :prefix "penpot.tmp.wasm."
:suffix (mime/get-extension type))]
(l/dbg :hint "wasm render: object rendered"
:object-id (str id) :bytes (.-length bytes))
(fs/writeFileSync path bytes)
;; `on-object` returns a plain value (zip append) or
;; a promise (single export's file move); `p/do`
;; normalizes both to a thenable.
(p/do (on-object (assoc object :path path)))))
objects))))))
(p/fmap (fn [result]
;; After the request, never mid-render, so an image can't
;; disappear under a running export.
(let [evicted (wasm/evict-images! wasm/image-cache-mb)]
(when (pos? evicted)
(l/info :hint "wasm render: evicted cached images" :count evicted)))
result))
(p/merr (fn [cause]
(l/error :hint "wasm render: failed"
:detail (explain cause)
:internal-uri (str (cf/get-internal-uri))
:cause cause)
;; A panic can leave the mem buffer allocated or the instance
;; aborted; drop it so the next request rebuilds a fresh one.
(reset! module* nil)
(p/rejected cause)))))
(defn render
"Public entry. `enqueue!` keeps concurrent exports off each other's toes on
the shared WASM instance."
[params on-object]
(enqueue! (fn [] (render* params on-object))))
+255
View File
@@ -0,0 +1,255 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.wasm
"Headless driver for the render-wasm module under Node: the GPU-free
counterpart of `app.render-wasm.api`. Loads the emscripten artifact, boots it
via `init_headless`, and exposes font provisioning + shape rendering.
Serialization is reused from the portable render-wasm leaves, so this
namespace owns only the Node runtime and the headless render calls.
Requires render-wasm built with `-sENVIRONMENT=web,node`."
(:require
["node:fs" :as fs]
["node:path" :as path]
[app.common.data :as d]
[app.common.logging :as l]
[app.common.render-wasm.helpers :as h]
[app.common.render-wasm.mem :as mem]
[app.common.render-wasm.serializers :as sr]
[app.common.render-wasm.wasm :as wasm]
[app.common.uuid :as uuid]
;; Required for side effects: binds the generated enums.
[app.wasm.enums]
[promesa.core :as p]
[shadow.esm :refer [dynamic-import]]))
(def ^:private default-viewport-width 1920)
(def ^:private default-viewport-height 1080)
;; render_shape_raster / render_shape_pixels result header: [len u32][w u32][h u32].
(def ^:private RASTER-HEADER-BYTES 12)
;; render_shape_pdf result header: [len u32] only.
(def ^:private PDF-HEADER-BYTES 4)
;; get_fonts_for_shape entry: [uuid 16 bytes][weight u32][style u32].
(def ^:private FONT-ENTRY-BYTES 24)
(def artifact-dir
"Built render-wasm artifact, relative to the process working directory. Same
path in devenv and inside the bundle, so it is a constant."
"resources/wasm")
(def image-cache-mb
"Byte budget (MB) the image store is trimmed to between requests."
256)
(defn- read-result-bytes
"Reads `len` bytes from the WASM heap starting at `offset`, copying them out
(via `.slice`) before the buffer is freed."
[offset len]
(.slice (mem/get-heap-u8) offset (+ offset len)))
;; --- MODULE LIFECYCLE
(defn init!
"Loads the render-wasm artifact under Node and boots it headless. Sets the
shared `wasm/internal-module` so the portable serialization leaves work.
Idempotent-ish: callers should hold the returned module."
([] (init! default-viewport-width default-viewport-height))
([width height]
(let [dir artifact-dir
js-path (path/resolve dir "render-wasm.js")
wasm-path (path/resolve dir "render-wasm.wasm")
wasm-bytes (fs/readFileSync wasm-path)]
(l/info :hint "loading render-wasm (headless)" :js js-path)
;; shadow-cljs :esm — use its dynamic-import helper (raw `js/import`
;; compiles to an undefined `import$`).
(->> (dynamic-import (str "file://" js-path))
(p/mcat
(fn [mod]
(let [factory (unchecked-get mod "default")]
(factory
#js {;; Bypass the web fetch loader: instantiate from local bytes.
:instantiateWasm
(fn [imports success]
(-> (js/WebAssembly.instantiate wasm-bytes imports)
(.then (fn [result] (success (.-instance result)))))
#js {})
:locateFile (fn [p] (path/resolve dir p))
:printErr (fn [s] (l/warn :wasm s))}))))
(p/fmap
(fn [module]
(set! wasm/internal-module module)
(h/call module "_init_headless" width height)
(set! wasm/context-initialized? true)
(l/info :hint "render-wasm headless module ready" :width width :height height)
module))))))
;; --- FONT PROVISIONING (on demand, mirrors the browser)
(defn fonts-for-shape
"Returns the distinct font families needed to render the subtree rooted at
`shape-id` as a vector of {:id <uuid-u32x4> :weight :style}. Equivalent to
the browser's `get-content-fonts`, but read from the loaded WASM tree."
[shape-id]
(let [module wasm/internal-module
buf (uuid/get-u32 shape-id) ;; resolved from app.render-wasm leaves
offset (h/call module "_get_fonts_for_shape"
(aget buf 0) (aget buf 1) (aget buf 2) (aget buf 3))
heap32 (mem/get-heap-u32)
n (aget heap32 (mem/->offset-32 offset))
;; `vec` must stay eager: it reads the result buffer, and the
;; `mem/free` below invalidates these offsets.
entries (vec
(for [i (range n)]
(let [base (+ offset 4 (* i FONT-ENTRY-BYTES))
u32 (fn [o] (aget heap32 (mem/->offset-32 (+ base o))))]
{:id #js [(u32 0) (u32 4) (u32 8) (u32 12)]
:weight (u32 16)
:style (u32 20)})))]
(mem/free)
entries))
(defn- font-key
"Value key for a family map. Its `:id` is a JS array, so the map itself can't
be compared by value."
[{:keys [id weight style]}]
[(aget id 0) (aget id 1) (aget id 2) (aget id 3) weight style])
(defn fonts-for-shapes
"Distinct font families needed by every subtree in `shape-ids`. Objects in a
partition overwhelmingly share families, so deduping here means one download
and one `_store_font` per family rather than one per object."
[shape-ids]
(into [] (comp (mapcat fonts-for-shape)
(d/distinct-xf font-key))
shape-ids))
(defn store-font!
"Uploads one font's TTF bytes into the WASM font store, keyed by the family
(uuid quartet + weight + style). `font-bytes` is a Uint8Array/Buffer.
Does NOT call `mem/free` — `store_font` (and likewise `store_image` below)
releases the global buffer itself on the Rust side. Freeing again here would
drop a buffer a later writer already owns."
[{:keys [id weight style emoji? fallback?]} font-bytes]
(let [module wasm/internal-module
size (.-byteLength font-bytes)
ptr (h/call module "_alloc_bytes" size)
heap (mem/get-heap-u8)]
(.set heap (js/Uint8Array. font-bytes) ptr)
(h/call module "_store_font"
(aget id 0) (aget id 1) (aget id 2) (aget id 3)
weight style (boolean emoji?) (boolean fallback?))))
(defn clear-fonts!
"Resets the WASM font store. Must be called once per render request because
the shared module would otherwise accumulate fonts across requests."
[]
(h/call wasm/internal-module "_clear_fonts"))
(defn update-text-layout!
"Recomputes a text shape's layout with the currently provisioned fonts. Text is
laid out at serialize time using the fallback font (real fonts aren't uploaded
yet), so this must run again after `provision-fonts!` or glyph metrics/line
breaks are wrong."
[shape-id]
(let [buf (uuid/get-u32 shape-id)]
(h/call wasm/internal-module "_update_shape_text_layout_for"
(aget buf 0) (aget buf 1) (aget buf 2) (aget buf 3))))
(defn image-cached?
"True when the module's image store already holds this image (full size).
The store is NOT reset between requests, so previously provisioned images
can be reused instead of refetched."
[image-id]
(let [buf (uuid/get-u32 image-id)]
(not (zero? (h/call wasm/internal-module "_is_image_cached"
(aget buf 0) (aget buf 1) (aget buf 2) (aget buf 3)
false)))))
(defn store-image!
"Uploads one image's *encoded* bytes (PNG/JPEG — Skia decodes, no WebGL) into
the WASM image store via `_store_image`. Buffer layout matches the Rust reader:
[shape uuid 16][image uuid 16][is_thumbnail u32][encoded bytes]. Images are
keyed by image uuid, so the shape uuid is left zero. `image-bytes` is an
ArrayBuffer/Buffer/Uint8Array."
[image-id image-bytes]
(let [module wasm/internal-module
img-u8 (js/Uint8Array. image-bytes)
size (.-byteLength img-u8)
total (+ 36 size)
ptr (h/call module "_alloc_bytes" total)
heap (mem/get-heap-u8)
dview (js/DataView. (.-buffer heap))
quart (uuid/get-u32 image-id)]
;; shape uuid [0..16) = 0 (images are keyed by image uuid only)
(.setUint32 dview (+ ptr 0) 0 true)
(.setUint32 dview (+ ptr 4) 0 true)
(.setUint32 dview (+ ptr 8) 0 true)
(.setUint32 dview (+ ptr 12) 0 true)
;; image uuid [16..32) — 4 LE u32 (matches common `buffer/write-uuid`, which
;; the fill path uses, so it hashes to the same key the fill references)
(.setUint32 dview (+ ptr 16) (aget quart 0) true)
(.setUint32 dview (+ ptr 20) (aget quart 1) true)
(.setUint32 dview (+ ptr 24) (aget quart 2) true)
(.setUint32 dview (+ ptr 28) (aget quart 3) true)
;; is_thumbnail [32..36) = 0
(.setUint32 dview (+ ptr 32) 0 true)
;; encoded bytes [36..)
(.set heap img-u8 (+ ptr 36))
(h/call module "_store_image")))
(defn evict-images!
"Evicts least-recently-used images until the store retains at most `max-mb`
megabytes. Returns the number evicted."
[max-mb]
(h/call wasm/internal-module "_evict_images_to_budget" max-mb))
(defn provision-fonts!
"Resolves and uploads every font needed by `shape-ids`, each family fetched
once. `resolve-font` is an injected fn of the family map -> promise of TTF
bytes (or nil to skip). This keeps the font *source* (gfonts proxy / custom
assets / backend) out of the driver."
[shape-ids resolve-font]
(->> (fonts-for-shapes shape-ids)
(map (fn [family]
(->> (resolve-font family)
(p/fmap (fn [bytes] (when bytes (store-font! family bytes)))))))
(p/all)))
;; --- RENDER
(defn- read-render-result
"Copies the encoded payload out of a `_render_shape_*` result buffer and frees
it. `header-bytes` is the size of the header preceding the payload."
[offset header-bytes]
(let [heap32 (mem/get-heap-u32)
len (aget heap32 (mem/->offset-32 offset))
bytes (read-result-bytes (+ offset header-bytes) len)]
(mem/free)
bytes))
(defn render-shape-raster
"Renders the shape subtree to encoded image bytes (Uint8Array) on a CPU
surface. `format` is :png, :jpeg or :webp; jpeg is flattened onto white on
the Rust side, since it has no alpha channel."
[shape-id scale format]
(let [buf (uuid/get-u32 shape-id)]
(-> (h/call wasm/internal-module "_render_shape_raster"
(aget buf 0) (aget buf 1) (aget buf 2) (aget buf 3)
scale (sr/translate-raster-format format))
(read-render-result RASTER-HEADER-BYTES))))
(defn render-shape-pdf
"Renders the shape subtree to PDF bytes (Uint8Array)."
[shape-id scale]
(let [buf (uuid/get-u32 shape-id)]
(-> (h/call wasm/internal-module "_render_shape_pdf"
(aget buf 0) (aget buf 1) (aget buf 2) (aget buf 3)
scale)
(read-render-result PDF-HEADER-BYTES))))
+19
View File
@@ -0,0 +1,19 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.wasm.enums
"Binds this build's generated enums into the shared bridge.
`shared.js` is emitted next to this file by `render-wasm/build export` and is
not committed. Requiring this namespace is what makes
`app.common.render-wasm.wasm/serializers` usable."
(:require
["./shared.js" :as shared]
[app.common.render-wasm.wasm :as wasm])
(:require-macros
[app.common.render-wasm.enums :as enums]))
(wasm/init-serializers! (enums/serializers shared))
+46
View File
@@ -0,0 +1,46 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.wasm.serialize
"Browser-free shape serialization for the headless exporter: the counterpart
of `app.render-wasm.api/set-object`, which cannot be reused directly because
its namespace pulls React/DOM/store. Only the call sequencing lives here —
every byte layout comes from the shared serializers, so the bytes sent to
WASM are the editor's.
Covers everything except svg-raw. Image bytes and fonts are provisioned
separately by `app.renderer.wasm`."
(:require
[app.common.render-wasm.api.props :as props]
[app.common.render-wasm.helpers :as h]
[app.common.render-wasm.serialize-shape :as serialize-shape]
[app.common.render-wasm.wasm :as wasm]
[app.wasm.text :as text]))
(defn set-shape!
"Serializes a single shape into the WASM design state. The host-independent
properties (base props, children, blur, shadows, svg-attrs, mask, bool-type,
path geometry, grow-type) go through the shared `serialize-shape!` — the same
code the workspace's `set-object` uses, so the two can't drift. Only the
host-specific parts are handled here: fills/strokes (image bytes are provisioned
separately) and text content (fonts provisioned separately)."
[shape]
(let [type (get shape :type)]
(serialize-shape/serialize-shape! shape)
(props/write-shape-fills! (get shape :fills))
(when-not (= type :group)
(props/write-shape-strokes! (get shape :strokes)))
(when (= type :text)
(text/set-shape-text! (get shape :content)))))
(defn serialize-scene!
"Loads every shape of an `objects` map into the WASM design state. Resets the
shapes pool first so repeated exports don't accumulate into the shared
state. Order is irrelevant: shapes reference each other by id and the tree
is resolved at render time."
[objects]
(h/call wasm/internal-module "_init_shapes_pool" (count objects))
(run! set-shape! (vals objects)))
+35
View File
@@ -0,0 +1,35 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.wasm.text
"Browser-free text-content serialization for the headless exporter. Only the
paragraph walk is local: the binary layout and the font-id -> uuid mapping
both come from `app.common.render-wasm.text-content`."
(:require
[app.common.render-wasm.helpers :as h]
[app.common.render-wasm.serializers :as sr]
[app.common.render-wasm.text-content :as tc]
[app.common.render-wasm.wasm :as wasm]))
(defn set-shape-text!
"Serializes a text shape's content into the current WASM shape. Mirrors the
editor's sequence: clear -> vertical-align -> append each paragraph -> layout.
Byte writing and font resolution are the shared
`text-content/write-shape-text!` defaults; the exporter has no fonts DB, so
it injects no variant normalization."
[content]
(when content
(h/call wasm/internal-module "_clear_shape_text")
(h/call wasm/internal-module "_set_shape_vertical_align"
(sr/translate-vertical-align (get content :vertical-align)))
(let [paragraph-set (first (get content :children))
paragraphs (get paragraph-set :children)]
(doseq [paragraph paragraphs]
(let [spans (get paragraph :children)]
(when (seq spans)
(let [text (apply str (map :text spans))]
(tc/write-shape-text! spans paragraph text {}))))))
(h/call wasm/internal-module "_update_shape_text_layout")))
+2 -1
View File
@@ -1,6 +1,7 @@
{:paths ["src" "vendor" "resources" "test"]
:deps
{penpot/common
{;; Carries `app.common.render-wasm.*`, shared with the headless exporter.
penpot/common
{:local/root "../common"}
org.clojure/clojure {:mvn/version "1.12.2"}
+1 -1
View File
@@ -19,7 +19,7 @@
"build:storybook": "(cd packages/ui && pnpm run build) && pnpm run build:storybook:assets && pnpm run build:storybook:cljs && storybook build",
"build:storybook:assets": "node ./scripts/build-storybook-assets.js",
"build:storybook:cljs": "clojure -M:dev:shadow-cljs compile storybook",
"build:wasm": "../render-wasm/build",
"build:wasm": "../render-wasm/build frontend",
"build:app:libs": "node ./scripts/build-libs.js",
"build:app:main": "clojure -M:dev:shadow-cljs release main worker",
"build:app:worker": "clojure -M:dev:shadow-cljs release worker",
@@ -0,0 +1,349 @@
{
"~:features": {
"~#set": [
"fdata/path-data",
"plugins/runtime",
"design-tokens/v1",
"layout/grid",
"styles/v2",
"fdata/pointer-map",
"fdata/objects-map",
"components/v2",
"fdata/shape-data-type",
"text-editor/v2"
]
},
"~:team-id": "~u9e6e22b2-db76-81d6-8006-75d7cdbb8bad",
"~:permissions": {
"~:type": "~:membership",
"~:is-owner": true,
"~:is-admin": true,
"~:can-edit": true,
"~:can-read": true,
"~:is-logged": true
},
"~:has-media-trimmed": false,
"~:comment-thread-seqn": 0,
"~:name": "Fixed size text",
"~:revn": 3,
"~:modified-at": "~m1753957736516",
"~:vern": 0,
"~:id": "~u238a17e0-75ff-8075-8006-934586ea2230",
"~:is-shared": false,
"~:migrations": {
"~#ordered-set": [
"legacy-2",
"legacy-3",
"legacy-5",
"legacy-6",
"legacy-7",
"legacy-8",
"legacy-9",
"legacy-10",
"legacy-11",
"legacy-12",
"legacy-13",
"legacy-14",
"legacy-16",
"legacy-17",
"legacy-18",
"legacy-19",
"legacy-25",
"legacy-26",
"legacy-27",
"legacy-28",
"legacy-29",
"legacy-31",
"legacy-32",
"legacy-33",
"legacy-34",
"legacy-36",
"legacy-37",
"legacy-38",
"legacy-39",
"legacy-40",
"legacy-41",
"legacy-42",
"legacy-43",
"legacy-44",
"legacy-45",
"legacy-46",
"legacy-47",
"legacy-48",
"legacy-49",
"legacy-50",
"legacy-51",
"legacy-52",
"legacy-53",
"legacy-54",
"legacy-55",
"legacy-56",
"legacy-57",
"legacy-59",
"legacy-62",
"legacy-65",
"legacy-66",
"legacy-67",
"0001-remove-tokens-from-groups",
"0002-normalize-bool-content-v2",
"0002-clean-shape-interactions",
"0003-fix-root-shape",
"0003-convert-path-content-v2",
"0004-clean-shadow-color",
"0005-deprecate-image-type",
"0006-fix-old-texts-fills",
"0007-clear-invalid-strokes-and-fills-v2",
"0008-fix-library-colors-v4",
"0009-clean-library-colors",
"0009-add-partial-text-touched-flags"
]
},
"~:version": 67,
"~:project-id": "~u9e6e22b2-db76-81d6-8006-75d7cdc30669",
"~:created-at": "~m1753957644225",
"~:data": {
"~:pages": [
"~u238a17e0-75ff-8075-8006-934586ea2231"
],
"~:pages-index": {
"~u238a17e0-75ff-8075-8006-934586ea2231": {
"~:objects": {
"~u00000000-0000-0000-0000-000000000000": {
"~#shape": {
"~:y": 0,
"~:hide-fill-on-export": false,
"~:transform": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:rotation": 0,
"~:name": "Root Frame",
"~:width": 0.01,
"~:type": "~:frame",
"~:points": [
{
"~#point": {
"~:x": 0.0,
"~:y": 0.0
}
},
{
"~#point": {
"~:x": 0.01,
"~:y": 0.0
}
},
{
"~#point": {
"~:x": 0.01,
"~:y": 0.01
}
},
{
"~#point": {
"~:x": 0.0,
"~:y": 0.01
}
}
],
"~:r2": 0,
"~:proportion-lock": false,
"~:transform-inverse": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:r3": 0,
"~:r1": 0,
"~:id": "~u00000000-0000-0000-0000-000000000000",
"~:parent-id": "~u00000000-0000-0000-0000-000000000000",
"~:frame-id": "~u00000000-0000-0000-0000-000000000000",
"~:strokes": [],
"~:x": 0,
"~:proportion": 1.0,
"~:r4": 0,
"~:selrect": {
"~#rect": {
"~:x": 0,
"~:y": 0,
"~:width": 0.01,
"~:height": 0.01,
"~:x1": 0,
"~:y1": 0,
"~:x2": 0.01,
"~:y2": 0.01
}
},
"~:fills": [
{
"~:fill-color": "#FFFFFF",
"~:fill-opacity": 1
}
],
"~:flip-x": null,
"~:height": 0.01,
"~:flip-y": null,
"~:shapes": [
"~ucc6f0580-449c-8019-8006-9345db077fa0"
]
}
},
"~ucc6f0580-449c-8019-8006-9345db077fa0": {
"~#shape": {
"~:y": 150,
"~:transform": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:rotation": 0,
"~:grow-type": "~:fixed",
"~:content": {
"~:type": "root",
"~:key": "1s4am1jl24s",
"~:children": [
{
"~:type": "paragraph-set",
"~:children": [
{
"~:line-height": "1.2",
"~:font-style": "normal",
"~:children": [
{
"~:line-height": "1.2",
"~:font-style": "normal",
"~:typography-ref-id": null,
"~:text-transform": "none",
"~:font-id": "sourcesanspro",
"~:key": "13p0zwl2yhc",
"~:font-size": "14",
"~:font-weight": "400",
"~:typography-ref-file": null,
"~:font-variant-id": "regular",
"~:text-decoration": "none",
"~:letter-spacing": "0",
"~:fills": [
{
"~:fill-color": "#000000",
"~:fill-opacity": 1
}
],
"~:font-family": "sourcesanspro",
"~:text": "Lorem ipsum"
}
],
"~:typography-ref-id": null,
"~:text-transform": "none",
"~:text-align": "left",
"~:font-id": "sourcesanspro",
"~:key": "20hf3kmyoub",
"~:font-size": "14",
"~:font-weight": "400",
"~:typography-ref-file": null,
"~:text-direction": "ltr",
"~:type": "paragraph",
"~:font-variant-id": "regular",
"~:text-decoration": "none",
"~:letter-spacing": "0",
"~:fills": [
{
"~:fill-color": "#000000",
"~:fill-opacity": 1
}
],
"~:font-family": "sourcesanspro"
}
]
}
],
"~:vertical-align": "top"
},
"~:hide-in-viewer": false,
"~:name": "Fixed text",
"~:width": 300,
"~:type": "~:text",
"~:points": [
{
"~#point": {
"~:x": 200,
"~:y": 150
}
},
{
"~#point": {
"~:x": 500,
"~:y": 150
}
},
{
"~#point": {
"~:x": 500,
"~:y": 350
}
},
{
"~#point": {
"~:x": 200,
"~:y": 350
}
}
],
"~:transform-inverse": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:id": "~ucc6f0580-449c-8019-8006-9345db077fa0",
"~:parent-id": "~u00000000-0000-0000-0000-000000000000",
"~:frame-id": "~u00000000-0000-0000-0000-000000000000",
"~:x": 200,
"~:selrect": {
"~#rect": {
"~:x": 200,
"~:y": 150,
"~:width": 300,
"~:height": 200,
"~:x1": 200,
"~:y1": 150,
"~:x2": 500,
"~:y2": 350
}
},
"~:flip-x": null,
"~:height": 200,
"~:flip-y": null
}
}
},
"~:id": "~u238a17e0-75ff-8075-8006-934586ea2231",
"~:name": "Page 1"
}
},
"~:id": "~u238a17e0-75ff-8075-8006-934586ea2230",
"~:options": {
"~:components-v2": true,
"~:base-font-size": "16px"
}
}
}
@@ -9,7 +9,9 @@ const FILE = {
test.beforeEach(async ({ page }) => {
await WasmWorkspacePage.init(page);
// WASM_FLAGS already enables render-wasm; add the WASM text editor on top.
await WasmWorkspacePage.mockConfigFlags(page, ["enable-feature-text-editor-wasm"]);
await WasmWorkspacePage.mockConfigFlags(page, [
"enable-feature-text-editor-wasm",
]);
});
async function openEditorAndSelectAll(workspace) {
@@ -22,12 +24,12 @@ async function openEditorAndSelectAll(workspace) {
}
test.describe("BUG 10502 - Mixed families and variants", () => {
test("Multiple variants of the same font family", async ({
page,
}) => {
test("Multiple variants of the same font family", async ({ page }) => {
const workspace = new WasmWorkspacePage(page, { textEditor: true });
await workspace.setupEmptyFile();
await workspace.mockGetFile("text-editor/get-file-10502-mixed-variants.json");
await workspace.mockGetFile(
"text-editor/get-file-10502-mixed-variants.json",
);
await workspace.goToWorkspace(FILE);
await workspace.waitForFirstRender();
@@ -47,10 +49,14 @@ test.describe("BUG 10502 - Mixed families and variants", () => {
await expect(fontVariant).toHaveText("--");
});
test("Mixed font families appear as such in the dropdown", async ({ page }) => {
test("Mixed font families appear as such in the dropdown", async ({
page,
}) => {
const workspace = new WasmWorkspacePage(page, { textEditor: true });
await workspace.setupEmptyFile();
await workspace.mockGetFile("text-editor/get-file-10502-mixed-families.json");
await workspace.mockGetFile(
"text-editor/get-file-10502-mixed-families.json",
);
// Serve a stand-in TTF for Sora so the render doesn't wait on a real fetch.
// Glyphs are irrelevant here: the assertion only inspects the sidebar.
await workspace.mockGoogleFont("sora", "render-wasm/assets/ebgaramond.ttf");
@@ -129,6 +135,63 @@ test("BUG 10467 - Auto-width text captures every typed character", async ({
await workspace.waitForSelectedShapeName("hello world");
});
test.describe("BUG 10910 - Text is not replaced when there is a selection", () => {
// Non-ascii on purpose: selection offsets are counted in characters.
test("Typing over a selection replaces it", async ({ page }) => {
const workspace = new WasmWorkspacePage(page, { textEditor: true });
await workspace.setupEmptyFile();
await workspace.goToWorkspace();
await workspace.waitForFirstRender();
await workspace.createAutoWidthTextShape(200, 150, "Añadir");
await page.keyboard.press("ControlOrMeta+a");
await page.keyboard.type("nuevo");
await workspace.textEditor.stopEditing();
await workspace.layers.getByTestId("layer-row").first().click();
await workspace.waitForSelectedShapeName("nuevo");
});
test("Typing over a selection that contains emoji replaces it", async ({
page,
}) => {
const workspace = new WasmWorkspacePage(page, { textEditor: true });
await workspace.setupEmptyFile();
await workspace.goToWorkspace();
await workspace.waitForFirstRender();
await workspace.createAutoWidthTextShape(200, 150, "Hola 😀");
await page.keyboard.press("ControlOrMeta+a");
await page.keyboard.type("ok");
await workspace.textEditor.stopEditing();
await workspace.layers.getByTestId("layer-row").first().click();
await workspace.waitForSelectedShapeName("ok");
});
test("Backspace deletes the selection", async ({ page }) => {
const workspace = new WasmWorkspacePage(page, { textEditor: true });
await workspace.setupEmptyFile();
await workspace.goToWorkspace();
await workspace.waitForFirstRender();
await workspace.createAutoWidthTextShape(200, 150, "Añadir texto");
await page.keyboard.press("ControlOrMeta+a");
await page.keyboard.press("Backspace");
await page.keyboard.type("ok");
await workspace.textEditor.stopEditing();
await workspace.layers.getByTestId("layer-row").first().click();
await workspace.waitForSelectedShapeName("ok");
});
});
test("BUG 10531 - Entering the editor auto-selects the whole text", async ({
page,
}) => {
@@ -147,9 +210,94 @@ test("BUG 10531 - Entering the editor auto-selects the whole text", async ({
await workspace.copy("keyboard");
// Assert the text was copied correctly
const copiedText = await page.evaluate(() =>
navigator.clipboard.readText(),
);
const copiedText = await page.evaluate(() => navigator.clipboard.readText());
expect(copiedText).toBe("Lorem ipsum");
});
test.describe("BUG 10934 - Double-clicking a text side handle sets auto-size", () => {
// Sets up the workspace and loads a text shape whose size is larger than its text
async function setupFixedSizeText(page) {
const workspace = new WasmWorkspacePage(page, { textEditor: true });
// Enable token inputs so they use the new component with accessible DOM
await workspace.mockConfigFlags(["enable-feature-token-input"]);
await workspace.setupEmptyFile();
await workspace.mockGetFile("text-editor/get-file-fixed-size-text.json");
await workspace.goToWorkspace();
await workspace.waitForFirstRender();
// Select the text and zoom to fit, so it is fully visible in the viewport
await workspace.clickLeafLayer("Fixed text");
await page.keyboard.press("Shift+1");
await workspace.waitForIdle();
return workspace;
}
async function doubleClickSideHandle(workspace, position) {
const handle = workspace.viewport.getByTestId(
`resize-side-handler-${position}`,
);
await handle.waitFor();
const box = await handle.boundingBox();
await workspace.page.mouse.dblclick(
box.x + box.width / 2,
box.y + box.height / 2,
);
}
function measureInput(workspace, name) {
return workspace.rightSidebar
.getByRole("region", { name: "shape-measures-section" })
.getByRole("textbox", { name, exact: true });
}
test("Double-clicking the right handle switches to auto-width", async ({
page,
}) => {
const workspace = await setupFixedSizeText(page);
const widthInput = workspace.rightSidebar
.getByRole("region", { name: "shape-measures-section" })
.getByRole("textbox", { name: "Width", exact: true });
const initialWidth = Number(await widthInput.inputValue());
await doubleClickSideHandle(workspace, "right");
// Assert auto-width is selected and that the width has shrunk. The resize
// is debounced, so poll the value (auto-retrying) rather than reading once.
await expect(
workspace.rightSidebar.getByRole("button", {
name: "Auto width",
pressed: true,
}),
).toBeVisible();
await expect
.poll(async () => Number(await widthInput.inputValue()))
.toBeLessThan(initialWidth);
});
test("Double-clicking the bottom handle switches to auto-height", async ({
page,
}) => {
const workspace = await setupFixedSizeText(page);
const heightInput = workspace.rightSidebar
.getByRole("region", { name: "shape-measures-section" })
.getByRole("textbox", { name: "Height", exact: true });
const initialHeight = Number(await heightInput.inputValue());
await doubleClickSideHandle(workspace, "bottom");
// Assert auto-height is selected and that the height has shrunk. The resize
// is debounced, so poll the value (auto-retrying) rather than reading once.
await expect(
workspace.rightSidebar.getByRole("button", {
name: "Auto height",
pressed: true,
}),
).toBeVisible();
await expect
.poll(async () => Number(await heightInput.inputValue()))
.toBeLessThan(initialHeight);
});
});
+1 -1
View File
@@ -30,7 +30,7 @@ mkdir -p target/dist;
# Build render wasm binary
pushd ../render-wasm;
./build
./build frontend
popd
pushd ../mcp;
+1 -1
View File
@@ -68,7 +68,7 @@ function slug(value) {
}
async function findGfontsJson() {
const dir = "resources/fonts";
const dir = "../common/resources/fonts";
const entries = await fs.readdir(dir);
const matches = entries.filter((f) => /^gfonts\..*\.json$/.test(f)).sort();
if (matches.length === 0) {
@@ -8,7 +8,6 @@
(:require
[app.common.time :as ct]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.main.data.event :as ev]
[app.main.data.exports.wasm :as wasm.exports]
[app.main.data.helpers :as dsh]
@@ -183,11 +182,11 @@
(def ^:private wasm-export-types #{:jpeg :webp :png :pdf})
(defn- wasm-export-enabled?
"WASM export is available: the flag is set AND render-wasm is active for the
current file. When render-wasm is inactive its shape tree isn't loaded, so a
client-side WASM render would crash."
"WASM export is available when the `wasm-export/v1` feature is active AND
render-wasm is active for the current file. When render-wasm is inactive its
shape tree isn't loaded, so a client-side WASM render would crash."
[state]
(and (contains? cf/flags :wasm-export)
(and (features/active-feature? state "wasm-export/v1")
(features/active-feature? state "render-wasm/v1")))
(defn- use-wasm-export?
+1 -1
View File
@@ -18,6 +18,7 @@
[app.common.geom.shapes :as gsh]
[app.common.logging :as log]
[app.common.path-names :as cpn]
[app.common.render-wasm.wasm :as wasm-state]
[app.common.transit :as t]
[app.common.types.component :as ctc]
[app.common.types.components-list :as ctkl]
@@ -77,7 +78,6 @@
[app.plugins.register :as preg]
[app.render-wasm :as wasm]
[app.render-wasm.api :as wasm.api]
[app.render-wasm.wasm :as wasm-state]
[app.util.dom :as dom]
[app.util.globals :as ug]
[app.util.http :as http]
@@ -1201,7 +1201,7 @@
;; Call exporter to get image URI, then fetch blob and resolve the deferred.
(->> (if (and (features/active-feature? state "render-wasm/v1")
(contains? cf/flags :wasm-export))
(features/active-feature? state "wasm-export/v1"))
(rx/of {:uri (wasm.exports/export-image-uri export)})
(rp/cmd! :export
{:exports [export]
@@ -11,6 +11,7 @@
[app.common.types.text :as txt]
[app.main.data.shortcuts :as ds]
[app.main.data.workspace.texts :as dwt]
[app.main.data.workspace.texts-v3 :as dwt-v3]
[app.main.data.workspace.undo :as dwu]
[app.main.features :as features]
[app.main.fonts :as fonts]
@@ -170,6 +171,8 @@
:else props)]
(when (and shape props)
(when (features/active-feature? @st/state "text-editor-wasm/v1")
(st/emit! (dwt-v3/v3-update-text-editor-styles (:id shape) props)))
(st/emit! (dwt/update-attrs (:id shape) props)))))
(defn blend-props
+40 -11
View File
@@ -30,6 +30,7 @@
[app.main.data.workspace.reflow :as wrf]
[app.main.data.workspace.selection :as dws]
[app.main.data.workspace.shapes :as dwsh]
[app.main.data.workspace.texts-v3 :as dwt-v3]
[app.main.data.workspace.transforms :as dwt]
[app.main.data.workspace.undo :as dwu]
[app.main.data.workspace.wasm-text :as dwwt]
@@ -699,13 +700,19 @@
(rx/concat (rx/of (dwsh/update-shapes shape-ids update-shape options))
(when (features/active-feature? state "text-editor-wasm/v1")
(let [styles ((comp update-node-fn migrate-node))
result (wasm.api/apply-styles-to-selection styles)]
;; Transform each span so add-fill preserves its existing fills.
(let [result (wasm.api/apply-styles-to-selection
(comp update-node-fn migrate-node)
{:with-fills? true})]
(when result
(rx/of (v2-update-text-shape-content
(:shape-id result)
(:content result)
:update-name? true)))))))))
:update-name? true)
;; Refresh the panel now, not only after a reselect.
(dwt-v3/v3-update-text-editor-styles
(:shape-id result)
{:fills (:fills result)})))))))))
ptk/EffectEvent
(effect [_ state _]
@@ -968,7 +975,14 @@
(watch [_ state stream]
(let [text-editor-instance (:workspace-editor state)
objects (dsh/lookup-page-objects state)
text-ids (resolve-text-ids objects id)]
text-ids (resolve-text-ids objects id)
wasm-editing?
(and (features/active-feature? state "text-editor-wasm/v1")
(= id (wasm.api/text-editor-get-active-shape-id)))
wasm-editing-selection?
(and wasm-editing? (wasm.api/text-editor-has-selection?))]
(if (and (features/active-feature? state "text-editor/v2")
(some? text-editor-instance))
(rx/empty)
@@ -978,15 +992,30 @@
(rx/of (update-root-attrs {:id id :attrs attrs}))
(rx/empty)))
(let [attrs (select-keys attrs txt/paragraph-attrs)]
(if-not (empty? attrs)
(rx/of (update-paragraph-attrs {:id id :attrs attrs}))
(rx/empty)))
;; `:line-height` is stored on both the paragraph and its spans, and
;; the renderer takes the larger of the two.
(let [pattrs (if wasm-editing-selection?
(conj txt/paragraph-attrs :line-height)
txt/paragraph-attrs)
attrs (select-keys attrs pattrs)
result (when (and (seq attrs) wasm-editing?)
(wasm.api/apply-paragraph-attrs-to-selection attrs))]
(cond
(empty? attrs)
(rx/empty)
(some? result)
(rx/of (v2-update-text-shape-content
(:shape-id result) (:content result)
:update-name? true))
:else
(rx/of (update-paragraph-attrs {:id id :attrs attrs}))))
(let [attrs (select-keys attrs txt/text-node-attrs)]
(if-not (empty? attrs)
(rx/of (update-text-attrs {:id id :attrs attrs}))
(rx/empty)))
(if (or (empty? attrs) wasm-editing-selection?)
(rx/empty)
(rx/of (update-text-attrs {:id id :attrs attrs}))))
(when (and (features/active-feature? state "text-editor/v2")
(not (features/active-feature? state "text-editor-wasm/v1")))
+9 -61
View File
@@ -6,10 +6,10 @@
(ns app.main.fonts
"Fonts management and loading logic."
(:require-macros [app.main.fonts :refer [preload-gfonts]])
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.fonts :as cfnt]
[app.common.logging :as log]
[app.common.types.text :as txt]
[app.common.uri :as u]
@@ -25,27 +25,6 @@
(log/set-level! :warn)
(def google-fonts
(preload-gfonts "fonts/gfonts.2025.11.28.json"))
(def local-fonts
[{:id "sourcesanspro"
:name "Source Sans Pro"
:family "sourcesanspro"
:variants
[{:id "200" :name "200" :weight "200" :style "normal" :suffix "extralight" :ttf-url "sourcesanspro-extralight.ttf"}
{:id "200italic" :name "200 Italic" :weight "200" :style "italic" :suffix "extralightitalic" :ttf-url "sourcesanspro-extralightitalic.ttf"}
{:id "300" :name "300" :weight "300" :style "normal" :suffix "light" :ttf-url "sourcesanspro-light.ttf"}
{:id "300italic" :name "300 Italic" :weight "300" :style "italic" :suffix "lightitalic" :ttf-url "sourcesanspro-lightitalic.ttf"}
{:id "regular" :name "400" :weight "400" :style "normal" :ttf-url "sourcesanspro-regular.ttf"}
{:id "italic" :name "400 Italic" :weight "400" :style "italic" :ttf-url "sourcesanspro-italic.ttf"}
{:id "600" :name "600" :weight "600" :style "normal" :suffix "semibold" :ttf-url "sourcesanspro-semibold.ttf"}
{:id "600italic" :name "600 Italic" :weight "600" :style "italic" :suffix "semibolditalic" :ttf-url "sourcesanspro-semibolditalic.ttf"}
{:id "bold" :name "700" :weight "700" :style "normal" :ttf-url "sourcesanspro-bold.ttf"}
{:id "bolditalic" :name "700 Italic" :weight "700" :style "italic" :ttf-url "sourcesanspro-bolditalic.ttf"}
{:id "black" :name "900" :weight "900" :style "normal" :ttf-url "sourcesanspro-black.ttf"}
{:id "blackitalic" :name "900 Italic" :weight "900" :style "italic" :ttf-url "sourcesanspro-blackitalic.ttf"}]}])
(defonce fontsdb (l/atom {}))
(defonce fonts (l/atom []))
@@ -65,10 +44,10 @@
fonts (map #(assoc % :backend backend) fonts)]
(merge db (d/index-by :id fonts))))))
(register! :builtin local-fonts)
(register! :builtin cfnt/local-fonts)
(when (contains? cf/flags :google-fonts-provider)
(register! :google google-fonts))
(register! :google cfnt/catalog))
(defn get-font-data [id]
(get @fontsdb id))
@@ -266,8 +245,7 @@
(defn- process-gfont-css
[css]
(let [base (u/join cf/public-uri "internal/gfonts/font")]
(str/replace css "https://fonts.gstatic.com/s" (dm/str base))))
(cfnt/gstatic->proxy-url css (u/join cf/public-uri "internal/gfonts/font")))
(defn- fetch-gfont-css
[url]
@@ -397,42 +375,12 @@
(defn find-closest-variant
"Find the closest font weight variant in `font` for `target-weight` with optional `target-style` match.
When exactly between two weights, choose the higher one."
When exactly between two weights, choose the higher one.
The algorithm lives in `app.common.fonts` so the headless exporter resolves the
same variant for the same text."
[font target-weight target-style]
(when-let [target-weight (d/parse-integer target-weight)]
(let [variants (:variants font [])
result
(reduce
(fn [closest-match variant]
(let [weight (d/parse-integer (:weight variant))
distance (abs (- target-weight weight))
matches-style? (= target-style (:style variant))
current {:variant variant
:weight weight
:distance distance}]
(cond
;; Exact match found
(and (zero? distance)
(if target-style matches-style? true))
(reduced current)
(nil? closest-match) current
;; Update best match if this variant is closer or equal distance but higher weight
(or (< distance (:distance closest-match))
(and (= distance (:distance closest-match))
(> weight (:weight closest-match))))
current
;; Same weight as the `closest-match` but the style matches `target-style`
(and (= weight (:weight closest-match)) matches-style?)
current
:else
closest-match)))
nil
variants)]
(:variant result))))
(cfnt/closest-variant (:variants font []) target-weight target-style))
;; Font embedding functions
(defn get-node-fonts
@@ -9,8 +9,8 @@
(:require
[app.common.data.macros :as dm]
[app.common.exceptions :as ex]
[app.common.render-wasm.wasm :as wasm]
[app.render-wasm.api :as wasm.api]
[app.render-wasm.wasm :as wasm]
[app.util.dom :as dom]
[app.util.timers :as ts]
[app.util.webapi :as webapi]
@@ -99,6 +99,18 @@
(or (.-isComposing native)
(= 229 (.-keyCode event)))))
(defn- input-surface-class
"Class list for the contenteditable capture surface.
Mousetrap's `stopCallback` drops every keystroke whose target is
contentEditable, so without the `mousetrap` class (as in V1/V2) the text
shortcuts (Ctrl+B, Ctrl+I, …) never reach the dispatcher."
[rotation]
(dm/str "mousetrap "
(cur/get-dynamic "text" rotation)
" "
(stl/css :text-editor-container)))
(mf/defc text-editor*
"Contenteditable element positioned over the text shape to capture input events."
[{:keys [shape]}]
@@ -191,7 +203,7 @@
(fn [^js event]
(when (text-editor/text-editor-has-focus?)
(dom/prevent-default event)
(when (text-editor/text-editor-get-selection)
(when (text-editor/text-editor-has-selection?)
(let [text (text-editor/text-editor-export-selection)]
(.setData (.-clipboardData event) "text/plain" text))))))
@@ -200,7 +212,7 @@
(fn [^js event]
(when (text-editor/text-editor-has-focus?)
(dom/prevent-default event)
(when (text-editor/text-editor-get-selection)
(when (text-editor/text-editor-has-selection?)
(let [text (text-editor/text-editor-export-selection)]
(.setData (.-clipboardData event) "text/plain" (or text ""))
(when (and text (seq text))
@@ -256,6 +268,15 @@
(sync-wasm-text-editor-content!)
(wasm.api/request-render-preserving-target "text-delete-forward"))
;; Shift+Tab falls through to the browser, so the keyboard can
;; still leave the editor.
(and (= key "Tab") (not shift?))
(do
(dom/prevent-default event)
(text-editor/text-editor-insert-text "\t")
(sync-wasm-text-editor-content!)
(wasm.api/request-render-preserving-target "text-tab"))
;; Insert
(= key "Insert")
(do
@@ -359,7 +380,9 @@
(let [native-event (dom/event->native-event event)
off-pt (dom/get-offset-position native-event)]
(mf/set-ref-val! dragging-ref true)
(wasm.api/text-editor-pointer-down off-pt)
(if (.-shiftKey event)
(wasm.api/text-editor-pointer-down-extend off-pt)
(wasm.api/text-editor-pointer-down off-pt))
;; Repaint the caret over the cached tiles instead of a full render,
;; which flashes at high zoom (see `render-text-editor-overlay!`).
(wasm.api/render-text-editor-overlay!))))
@@ -407,9 +430,15 @@
on-blur
(mf/use-fn
(fn [^js _event]
(sync-wasm-text-editor-content! {:finalize? true})
(wasm.api/text-editor-blur)))
(fn [^js event]
;; MacOS Character Viewer on Firefox fires a `blur` when it opens.
;; To avoid losing the selected character, we need guard against
;; `activeElement` being the surface itself.
(when-not (and (some? event)
(= (.-activeElement js/document)
(mf/ref-val contenteditable-ref)))
(sync-wasm-text-editor-content! {:finalize? true})
(wasm.api/text-editor-blur))))
style #js {:pointerEvents "all"
"--editor-container-width" (dm/str width "px")
@@ -505,7 +534,5 @@
:on-focus on-focus
:on-blur on-blur
:id "text-editor-wasm-input"
:class (dm/str (cur/get-dynamic "text" (:rotation shape))
" "
(stl/css :text-editor-container))
:class (input-surface-class (:rotation shape))
:data-testid "text-editor-container"}]]]]))
@@ -18,6 +18,8 @@
[app.main.data.helpers :as dsh]
[app.main.data.workspace :as dw]
[app.main.data.workspace.shapes :as dwsh]
[app.main.data.workspace.wasm-text :as dwwt]
[app.main.features :as features]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.context :as ctx]
@@ -26,6 +28,7 @@
[app.util.debug :as dbg]
[app.util.dom :as dom]
[app.util.object :as obj]
[potok.v2.core :as ptk]
[rumext.v2 :as mf]))
(def rotation-handler-size 20)
@@ -295,13 +298,20 @@
on-double-click
(mf/use-fn
(mf/deps shape-id position shape-type)
(fn [_event]
(fn [event]
(when (= shape-type :text)
(cond
(= position :right)
(st/emit! (dwsh/update-shapes [shape-id] #(assoc % :grow-type :auto-width)))
(= position :bottom)
(st/emit! (dwsh/update-shapes [shape-id] #(assoc % :grow-type :auto-height)))))))]
;; Prevent the viewport double-click handler from entering text editor
(dom/stop-propagation event)
(let [grow-type (case position
:right :auto-width
:bottom :auto-height
nil)]
(when (some? grow-type)
(st/emit! (dwsh/update-shapes [shape-id] #(assoc % :grow-type grow-type)))
;; The WASM renderer needs an explicit reflow after the grow-type change
(when (features/active-feature? @st/state "render-wasm/v1")
(st/emit! (dwwt/resize-wasm-text-all [shape-id])
(ptk/data-event :layout/update {:ids [shape-id]}))))))))]
[:g.resize-handler
(when ^boolean show-handler
@@ -321,6 +331,7 @@
:height height
:class cursor
:data-position (name position)
:data-testid (dm/str "resize-side-handler-" (name position))
:transform transform-str
:on-pointer-down on-resize
:on-double-click on-double-click
+2 -2
View File
@@ -33,7 +33,6 @@
[app.common.types.shape.shadow :as ctss]
[app.common.types.text :as txt]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.main.data.exports.assets :as de]
[app.main.data.exports.wasm :as wasm.exports]
[app.main.data.persistence :as dwp]
@@ -50,6 +49,7 @@
[app.main.data.workspace.texts :as dwt]
[app.main.data.workspace.tokens.application :as dwta]
[app.main.data.workspace.variants :as dwv]
[app.main.features :as features]
[app.main.repo :as rp]
[app.main.store :as st]
[app.plugins.exports :as exports]
@@ -1532,7 +1532,7 @@
(u/not-valid plugin-id :export value)
:else
(if (and (contains? cf/flags :wasm-export)
(if (and (features/active-feature? @st/state "wasm-export/v1")
(contains? #{:jpeg :webp :png} (:type value :png)))
;; New export with wasm
(let [uri (wasm.exports/export-image-uri
+30 -17
View File
@@ -13,8 +13,17 @@
[app.common.exceptions :as ex]
[app.common.files.focus :as cpf]
[app.common.files.helpers :as cfh]
[app.common.fonts :as cfnt]
[app.common.logging :as log]
[app.common.math :as mth]
[app.common.render-wasm.api.props :as props]
[app.common.render-wasm.helpers :as h]
[app.common.render-wasm.mem :as mem]
[app.common.render-wasm.mem.heap32 :as mem.h32]
[app.common.render-wasm.serialize-shape :as serialize-shape]
[app.common.render-wasm.serializers :as sr]
[app.common.render-wasm.serializers.color :as sr-clr]
[app.common.render-wasm.wasm :as wasm]
[app.common.types.color :as clr]
[app.common.types.fills :as types.fills]
[app.common.types.path :as path]
@@ -31,23 +40,17 @@
[app.main.router :as rt]
[app.main.store :as st]
[app.main.ui.shapes.text]
;; Required for side effects: binds the generated enums.
[app.render-wasm.api.enums]
[app.render-wasm.api.fonts :as f]
[app.render-wasm.api.props :as props]
[app.render-wasm.api.texts :as t]
[app.render-wasm.api.webgl :as webgl]
[app.render-wasm.deserializers :as dr]
[app.render-wasm.gesture :as wasm-gesture]
[app.render-wasm.helpers :as h]
[app.render-wasm.mem :as mem]
[app.render-wasm.mem.heap32 :as mem.h32]
[app.render-wasm.performance :as perf]
[app.render-wasm.rulers-state :as rulers-state]
[app.render-wasm.serialize-shape :as serialize-shape]
[app.render-wasm.serializers :as sr]
[app.render-wasm.serializers.color :as sr-clr]
[app.render-wasm.svg-filters :as svg-filters]
[app.render-wasm.text-editor :as text-editor]
[app.render-wasm.wasm :as wasm]
[app.util.debug :as dbg]
[app.util.dom :as dom]
[app.util.functions :as fns]
@@ -281,6 +284,7 @@
(def text-editor-set-cursor-from-point text-editor/text-editor-set-cursor-from-point)
(def text-editor-toggle-overtype-mode text-editor/text-editor-toggle-overtype-mode)
(def text-editor-pointer-down text-editor/text-editor-pointer-down)
(def text-editor-pointer-down-extend text-editor/text-editor-pointer-down-extend)
(def text-editor-pointer-move text-editor/text-editor-pointer-move)
(def text-editor-pointer-up text-editor/text-editor-pointer-up)
(def text-editor-get-current-styles text-editor/text-editor-get-current-styles)
@@ -703,12 +707,21 @@
(defn apply-styles-to-selection
"Apply style attrs to the currently selected text spans.
Updates the cached content, pushes to WASM, and returns {:shape-id :content} for saving."
[attrs]
(let [result (text-editor/apply-styles-to-selection attrs use-shape set-shape-text-content)]
Updates the cached content, pushes to WASM, and returns {:shape-id :content} for saving.
`:with-fills?` also returns the selection's `:fills`."
[styles & [opts]]
(let [result (text-editor/apply-styles-to-selection styles use-shape set-shape-text-content opts)]
(request-render "apply-styles-to-selection")
result))
(defn apply-paragraph-attrs-to-selection
"Apply paragraph attrs to the paragraphs the editor selection touches.
Returns {:shape-id :content} for saving."
[attrs]
(let [result (text-editor/apply-paragraph-attrs-to-selection attrs use-shape set-shape-text-content)]
(request-render "apply-paragraph-attrs-to-selection")
result))
(defn set-parent-id
[id]
(let [buffer (uuid/get-u32 id)]
@@ -1285,8 +1298,8 @@
langs)
(let [text (apply str (map :text spans))
emoji? (if emoji? emoji? (t/contains-emoji? text))
langs (t/collect-used-languages langs text)]
emoji? (if emoji? emoji? (cfnt/contains-emoji? text))
langs (cfnt/collect-used-languages langs text)]
;; FIXME: this should probably be somewhere else
(when fallback-fonts-only? (t/write-shape-text spans paragraph text))
@@ -1297,8 +1310,8 @@
(let [updated-fonts
(-> #{}
(cond-> ^boolean emoji? (f/add-emoji-font))
(f/add-noto-fonts langs))
(cond-> ^boolean emoji? (cfnt/add-emoji-font))
(cfnt/add-noto-fonts langs))
fallback-fonts (filter #(get % :is-fallback) updated-fonts)]
(if fallback-fonts-only? updated-fonts fallback-fonts))))))
@@ -1404,7 +1417,7 @@
;; this implicitly (`zoom_changed`); this extends it to pan/resize-triggered
;; ends (e.g. selecting a shape opens the options panel and resizes the
;; viewport), which previously blanked.
(internal-render 0 RENDER-FLAG-SYNC-TILES)
(internal-render (js/performance.now) RENDER-FLAG-SYNC-TILES)
;; The direct render above bypasses the rAF `render` loop, so repaint the
;; editor overlay explicitly. Only when this was a full frame: a progressive
;; render keeps painting through the rAF loop and its partial frames must not
@@ -1419,7 +1432,7 @@
(if (view-gesture-active?)
;; Pan/zoom pause: render without ending the interaction.
(do
(internal-render 0 RENDER-FLAG-SYNC-TILES)
(internal-render (js/performance.now) RENDER-FLAG-SYNC-TILES)
(render-text-editor-overlay-after-frame!))
(finalize-view-interaction!))))]
(fns/debounce do-render DEBOUNCE_DELAY_MS)))
@@ -0,0 +1,19 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.render-wasm.api.enums
"Binds this build's generated enums into the shared bridge.
`shared.js` is emitted next to this file by `render-wasm/build frontend` and
is not committed. Requiring this namespace is what makes
`app.common.render-wasm.wasm/serializers` usable."
(:require
["./shared.js" :as shared]
[app.common.render-wasm.wasm :as wasm])
(:require-macros
[app.common.render-wasm.enums :as enums]))
(wasm/init-serializers! (enums/serializers shared))
+9 -62
View File
@@ -8,15 +8,15 @@
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.fonts :as cfnt]
[app.common.logging :as log]
[app.common.render-wasm.helpers :as h]
[app.common.render-wasm.wasm :as wasm]
[app.common.types.text :as txt]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.main.fonts :as fonts]
[app.main.store :as st]
[app.render-wasm.fallback-fonts :as fbf]
[app.render-wasm.helpers :as h]
[app.render-wasm.wasm :as wasm]
[app.util.http :as http]
[app.util.timers :as tm]
[beicon.v2.core :as rx]
@@ -39,33 +39,6 @@
(def ^:private default-line-height 1.2)
(def ^:private default-letter-spacing 0.0)
(defn- google-font-id->uuid
"Returns the UUID for a Google Font ID. Uses uuid/zero as fallback when the
font is not found in fontsdb. uuid/zero maps to the default font (Source
Sans Pro) in WASM.
A font id may not exist for different reasons:
- the gfonts.json catalog was updated and fonts were renamed or removed,
- the file was imported from another Penpot instance with different fonts,
..."
[font-id]
(let [font (fonts/get-font-data font-id)
result (:uuid font)]
(or result uuid/zero)))
(defn- custom-font-id->uuid
[font-id]
(uuid/uuid (subs font-id (inc (str/index-of font-id "-")))))
(defn- font-backend
[font-id]
(cond
(str/starts-with? font-id "gfont-")
:google
(str/starts-with? font-id "custom-")
:custom
:else
:builtin))
(defn- font-db-data
[font-id font-variant-id font-weight-fallback font-style-fallback]
(let [font (fonts/get-font-data font-id)
@@ -75,15 +48,6 @@
variant
closest-variant)))
(defn- font-id->uuid [font-id]
(case (font-backend font-id)
:google
(google-font-id->uuid font-id)
:custom
(custom-font-id->uuid font-id)
:builtin
uuid/zero))
(defn uuid->font-id
[font-uuid]
(if (= font-uuid uuid/zero)
@@ -100,11 +64,11 @@
"regular")))
(defn ^:private font-id->asset-id [font-id font-variant-id font-weight font-style]
(case (font-backend font-id)
(case (cfnt/font-id->backend font-id)
:google
font-id
:custom
(let [font-uuid (custom-font-id->uuid font-id)
(let [font-uuid (cfnt/font-id->uuid font-id)
matching-font (some (fn [[_ font]]
(and (= (:font-id font) font-uuid)
(= (str (:font-weight font)) (str font-weight))
@@ -194,13 +158,12 @@
(defn- google-font-ttf-url
[font-id font-variant-id font-weight font-style]
(let [variant (font-db-data font-id font-variant-id font-weight font-style)]
(if-let [ttf-url (:ttf-url variant)]
(str/replace ttf-url "https://fonts.gstatic.com/s/" (u/join cf/public-uri "internal/gfonts/font/"))
nil)))
(when-let [ttf-url (:ttf-url variant)]
(cfnt/gstatic->proxy-url ttf-url (u/join cf/public-uri "internal/gfonts/font")))))
(defn- font-id->ttf-url
[font-id asset-id font-variant-id font-weight font-style]
(case (font-backend font-id)
(case (cfnt/font-id->backend font-id)
:google
(google-font-ttf-url font-id font-variant-id font-weight font-style)
:custom
@@ -245,18 +208,6 @@
"italic" 1
0))
(defn normalize-font-id
[font-id]
(try
(if ^boolean (str/starts-with? font-id "gfont-")
(google-font-id->uuid font-id)
(let [no-prefix (subs font-id (inc (str/index-of font-id "-")))]
(if (or (nil? no-prefix) (not (string? no-prefix)) (str/blank? no-prefix))
uuid/zero
(uuid/parse no-prefix))))
(catch :default _e
uuid/zero)))
(defn normalize-span-font
[span paragraph]
(let [font-id (:font-id span)
@@ -358,7 +309,7 @@
emoji? (get font :is-emoji false)
fallback? (get font :is-fallback false)
font-data (font-db-data font-id normalized-variant-id font-weight-fallback font-style-fallback)
wasm-id (font-id->uuid font-id)
wasm-id (cfnt/font-id->uuid font-id)
raw-weight (or (:weight font-data) font-weight-fallback)
weight (serialize-font-weight raw-weight)
style (cond
@@ -415,7 +366,3 @@
(defn store-fonts
[fonts]
(keep (fn [font] (store-font font)) fonts))
(def add-emoji-font fbf/add-emoji-font)
(def noto-fonts fbf/noto-fonts)
(def add-noto-fonts fbf/add-noto-fonts)
+4 -12
View File
@@ -6,21 +6,13 @@
(ns app.render-wasm.api.texts
(:require
[app.render-wasm.api.fonts :as f]
[app.render-wasm.fallback-fonts :as fbf]
[app.render-wasm.text-content :as tc]))
[app.common.render-wasm.text-content :as tc]
[app.render-wasm.api.fonts :as f]))
(defn write-shape-text
"Workspace text serialization: the byte writing is shared via
`app.render-wasm.text-content`; font resolution is the workspace's (fonts DB)."
`app.common.render-wasm.text-content`; font resolution is the workspace's (fonts DB)."
[spans paragraph text]
(tc/write-shape-text! spans paragraph text
{:normalize-font-id f/normalize-font-id
:normalize-paragraph f/normalize-paragraph-font
{:normalize-paragraph f/normalize-paragraph-font
:normalize-span f/normalize-span-font}))
;; Emoji/script detection lives in the host-agnostic
;; `app.render-wasm.fallback-fonts`; kept re-exported here for existing
;; workspace callers.
(def contains-emoji? fbf/contains-emoji?)
(def collect-used-languages fbf/collect-used-languages)
+1 -1
View File
@@ -8,7 +8,7 @@
"WebGL utilities for pixel capture and rendering"
(:require
[app.common.logging :as log]
[app.render-wasm.wasm :as wasm]
[app.common.render-wasm.wasm :as wasm]
[promesa.core :as p]))
(defn get-webgl-context
+91 -19
View File
@@ -7,16 +7,18 @@
(ns app.render-wasm.text-editor
"Text editor WASM bindings"
(:require
[app.common.render-wasm.helpers :as h]
[app.common.render-wasm.mem :as mem]
[app.common.render-wasm.serializers :as sr]
[app.common.render-wasm.serializers.color :as sr-clr]
[app.common.render-wasm.wasm :as wasm]
[app.common.types.fills.impl :as types.fills.impl]
[app.common.types.text :as txt]
[app.common.uuid :as uuid]
[app.main.fonts :as main-fonts]
;; Required for side effects: binds the generated enums.
[app.render-wasm.api.enums]
[app.render-wasm.api.fonts :as fonts]
[app.render-wasm.helpers :as h]
[app.render-wasm.mem :as mem]
[app.render-wasm.serializers :as sr]
[app.render-wasm.serializers.color :as sr-clr]
[app.render-wasm.wasm :as wasm]
[app.util.color :as uc]
[app.util.dom :as dom]))
@@ -222,6 +224,12 @@
(when (wasm/ready?)
(h/call wasm/internal-module "_text_editor_pointer_down" x y)))
(defn text-editor-pointer-down-extend
"Extends the selection up to the pointer instead of collapsing the caret."
[{:keys [x y]}]
(when (wasm/ready?)
(h/call wasm/internal-module "_text_editor_pointer_down_extend" x y)))
(defn text-editor-pointer-move
[{:keys [x y]}]
(when (wasm/ready?)
@@ -623,10 +631,9 @@
{:start-para focus-para :start-offset focus-offset
:end-para anchor-para :end-offset anchor-offset}))
(defn- apply-attrs-to-paragraph
"Apply attrs to spans within [sel-start, sel-end) char range of a single paragraph.
Splits spans at boundaries as needed."
[para sel-start sel-end attrs]
(defn apply-attrs-to-paragraph
"Apply `styles` (attrs map, or a fn per span) within [sel-start, sel-end), splitting spans."
[para sel-start sel-end styles]
(let [spans (:children para)
result (loop [spans spans
@@ -645,8 +652,10 @@
(recur (rest spans) span-end (conj acc span))
(let [before (when (> ol-start pos)
(assoc span :text (subs text 0 (- ol-start pos))))
selected (merge span attrs
{:text (subs text (- ol-start pos) (- ol-end pos))})
selected (-> (if (fn? styles)
(styles span)
(merge span styles))
(assoc :text (subs text (- ol-start pos) (- ol-end pos))))
after (when (< ol-end span-end)
(assoc span :text (subs text (- ol-end pos))))]
(recur (rest spans) span-end
@@ -658,15 +667,50 @@
[para]
(apply + (map (fn [span] (count (:text span))) (:children para))))
(defn- paragraph-selected-spans
"Return the spans of `para` that overlap the [sel-start, sel-end) char range."
[para sel-start sel-end]
(loop [spans (:children para)
pos 0
acc []]
(if (empty? spans)
acc
(let [span (first spans)
span-end (+ pos (count (:text span)))
overlap? (< (max pos sel-start) (min span-end sel-end))]
(recur (rest spans) span-end (cond-> acc overlap? (conj span)))))))
(defn selection-fills
"The selection's fills: shared vector if all spans match, `:multiple` if not, nil if empty."
[content {:keys [start-para start-offset end-para end-offset]}]
(let [paragraphs (:children (first (:children content)))
selected (mapcat (fn [idx para]
(cond
(or (< idx start-para) (> idx end-para)) nil
(= start-para end-para) (paragraph-selected-spans para start-offset end-offset)
(= idx start-para) (paragraph-selected-spans para start-offset (para-char-count para))
(= idx end-para) (paragraph-selected-spans para 0 end-offset)
:else (paragraph-selected-spans para 0 (para-char-count para))))
(range (count paragraphs))
paragraphs)
fills-set (into #{} (map :fills) selected)]
(cond
(empty? selected) nil
(= 1 (count fills-set)) (first fills-set)
:else :multiple)))
(defn apply-styles-to-selection
[attrs use-shape-fn set-shape-text-content-fn]
"Apply `styles` (attrs map, or a fn per span) to the selected spans; `:with-fills?` also returns `:fills`."
[styles use-shape-fn set-shape-text-content-fn & [{:keys [with-fills?]}]]
(when (wasm/ready?)
(let [;; Drop nil-valued attrs so they are never merged onto text spans.
;; The DOM editor path strips these in `attrs->styles`; the WASM merge
;; here (`apply-attrs-to-paragraph`) does not, so an unresolved attr
;; (e.g. nil :font-family/:font-weight/:font-style from an unloaded
;; font) would corrupt the span and fail the backend schema.
attrs (into {} (remove (comp nil? val)) attrs)
styles (if (fn? styles)
styles
(into {} (remove (comp nil? val)) styles))
shape-id (text-editor-get-active-shape-id)
selection (text-editor-get-selection)]
@@ -691,19 +735,19 @@
;; same paragraph.
(= start-para end-para)
(apply-attrs-to-paragraph para start-offset end-offset attrs)
(apply-attrs-to-paragraph para start-offset end-offset styles)
;; first paragraph
(= idx start-para)
(apply-attrs-to-paragraph para start-offset (para-char-count para) attrs)
(apply-attrs-to-paragraph para start-offset (para-char-count para) styles)
;; final paragraph
(= idx end-para)
(apply-attrs-to-paragraph para 0 end-offset attrs)
(apply-attrs-to-paragraph para 0 end-offset styles)
;; any other paragraph
:else
(apply-attrs-to-paragraph para 0 (para-char-count para) attrs)))
(apply-attrs-to-paragraph para 0 (para-char-count para) styles)))
(range (count paragraphs))
paragraphs))
@@ -716,5 +760,33 @@
(update-cached-content! shape-id new-content)
(use-shape-fn shape-id)
(set-shape-text-content-fn shape-id new-content)
{:shape-id shape-id
:content new-content}))))))))
(cond-> {:shape-id shape-id
:content new-content}
with-fills?
(assoc :fills (selection-fills new-content normalized-selection)))))))))))
(defn apply-paragraph-attrs-to-selection
"Apply paragraph level attrs (text-align, text-direction) to the whole
paragraphs the editor selection touches; a collapsed caret means just the one
it sits in."
[attrs use-shape-fn set-shape-text-content-fn]
(when (wasm/ready?)
(let [shape-id (text-editor-get-active-shape-id)
selection (text-editor-get-selection)]
(when (and shape-id selection)
(when-let [content (get-cached-content shape-id)]
(let [{:keys [start-para end-para]} (normalize-selection selection)
paragraph-set (first (:children content))
new-paragraphs (into []
(map-indexed (fn [idx para]
(if (<= start-para idx end-para)
(merge para attrs)
para)))
(:children paragraph-set))
new-content (assoc content :children
[(assoc paragraph-set :children new-paragraphs)])]
(update-cached-content! shape-id new-content)
(use-shape-fn shape-id)
(set-shape-text-content-fn shape-id new-content)
{:shape-id shape-id
:content new-content}))))))
+1 -1
View File
@@ -11,6 +11,7 @@
[app.common.geom.rect :as grc]
[app.common.geom.shapes.bounds :as gsb]
[app.common.logging :as log]
[app.common.render-wasm.wasm :as wasm]
[app.common.types.color :as cc]
[app.common.uri :as u]
[app.common.uuid :as uuid]
@@ -18,7 +19,6 @@
[app.main.fonts :as fonts]
[app.main.render :as render]
[app.render-wasm.api :as wasm.api]
[app.render-wasm.wasm :as wasm]
[app.util.http :as http]
[app.worker.impl :as impl]
[beicon.v2.core :as rx]
+3 -3
View File
@@ -15,6 +15,9 @@
[app.common.json :as json]
[app.common.logging :as l]
[app.common.pprint :as pp]
[app.common.render-wasm.helpers :as wasm.h]
[app.common.render-wasm.mem :as wasm.mem]
[app.common.render-wasm.wasm :as wasm]
[app.common.transit :as t]
[app.common.types.component :as ctk]
[app.common.types.components-list :as ctkl]
@@ -36,9 +39,6 @@
[app.main.errors :as errors]
[app.main.repo :as rp]
[app.main.store :as st]
[app.render-wasm.helpers :as wasm.h]
[app.render-wasm.mem :as wasm.mem]
[app.render-wasm.wasm :as wasm]
[app.util.debug :as dbg]
[app.util.dom :as dom]
[app.util.http :as http]
@@ -15,9 +15,9 @@
font URL get no callback (fetch-font returns nil when the URL is already
in :fetching) and are permanently stuck with fallback-font layout metrics."
(:require
[app.common.render-wasm.mem :as mem]
[app.common.render-wasm.wasm :as wasm]
[app.render-wasm.api :as wasm.api]
[app.render-wasm.mem :as mem]
[app.render-wasm.wasm :as wasm]
[beicon.v2.core :as rx]
[cljs.test :as t :include-macros true]))
@@ -0,0 +1,102 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns frontend-tests.render-wasm.text-editor-apply-styles-test
"Unit tests for applying styles to a selection of text spans.
`apply-attrs-to-paragraph` splits the affected spans at the selection
boundaries and either merges a map of attrs onto the selected spans or, when
given a function, transforms each selected span. The function form is what
fill operations (add, remove, reorder...) rely on to preserve each span's
existing fills instead of overwriting them."
(:require
[app.render-wasm.text-editor :as text-editor]
[cljs.test :as t :include-macros true]))
(def ^:private apply-attrs-to-paragraph text-editor/apply-attrs-to-paragraph)
(defn- span [text fills]
{:text text :fills fills})
(def ^:private red {:fill-color "#ff0000" :fill-opacity 1})
(def ^:private green {:fill-color "#00ff00" :fill-opacity 1})
(defn- prepend-fill
"Mirrors the `add-fill` node transform: prepend a fill to the span's fills."
[fill]
(fn [node] (update node :fills #(into [fill] %))))
(t/deftest apply-map-attrs
(t/testing "a map of attrs is merged onto the selected span"
(let [para {:children [(span "hello world" [red])]}
result (apply-attrs-to-paragraph para 0 5 {:font-size "20"})]
(t/is (= [(assoc (span "hello" [red]) :font-size "20")
(span " world" [red])]
(:children result))))))
(t/deftest apply-fn-preserves-existing-fills
(t/testing "the fn form prepends to the selected span's existing fills"
(let [para {:children [(span "hello world" [red])]}
result (apply-attrs-to-paragraph para 0 5 (prepend-fill green))]
(t/is (= [(span "hello" [green red])
(span " world" [red])]
(:children result)))))
(t/testing "each selected span keeps its own fills across multiple spans"
(let [para {:children [(span "foo" [red])
(span "bar" [green])]}
;; select the whole paragraph (6 chars) and prepend green
result (apply-attrs-to-paragraph para 0 6 (prepend-fill green))]
(t/is (= [(span "foo" [green red])
(span "bar" [green green])]
(:children result)))))
(t/testing "a span outside the selection is left untouched"
(let [para {:children [(span "abcdef" [red])]}
;; select only "cd"
result (apply-attrs-to-paragraph para 2 4 (prepend-fill green))]
(t/is (= [(span "ab" [red])
(span "cd" [green red])
(span "ef" [red])]
(:children result))))))
(defn- content [paras]
{:children [{:children paras}]})
(defn- para [spans]
{:children spans})
(defn- selection [start-para start-offset end-para end-offset]
{:start-para start-para :start-offset start-offset
:end-para end-para :end-offset end-offset})
(t/deftest selection-fills
(t/testing "a selection where every span shares the same fills returns that vector"
(let [c (content [(para [(span "hello world" [red])])])]
(t/is (= [red] (text-editor/selection-fills c (selection 0 0 0 5))))))
(t/testing "a selection within a single span returns that span's fills"
(let [c (content [(para [(span "abcdef" [red])])])]
(t/is (= [red] (text-editor/selection-fills c (selection 0 2 0 4))))))
(t/testing "a selection spanning spans with different fills is :multiple"
(let [c (content [(para [(span "foo" [red])
(span "bar" [green])])])]
(t/is (= :multiple (text-editor/selection-fills c (selection 0 0 0 6))))))
(t/testing "a selection restricted to one uniform span is not :multiple"
(let [c (content [(para [(span "foo" [red])
(span "bar" [green])])])]
(t/is (= [green] (text-editor/selection-fills c (selection 0 3 0 6))))))
(t/testing "a selection across paragraphs with the same fills returns that vector"
(let [c (content [(para [(span "foo" [red])])
(para [(span "bar" [red])])])]
(t/is (= [red] (text-editor/selection-fills c (selection 0 0 1 3))))))
(t/testing "a collapsed selection has no selected spans"
(let [c (content [(para [(span "hello" [red])])])]
(t/is (nil? (text-editor/selection-fills c (selection 0 2 0 2)))))))
@@ -11,7 +11,7 @@
anything else (no fill, gradient, image fills, mixed selection) it falls back
to an inverted caret (white painted with a Difference blend)."
(:require
[app.render-wasm.serializers.color :as sr-clr]
[app.common.render-wasm.serializers.color :as sr-clr]
[app.render-wasm.text-editor :as text-editor]
[cljs.test :as t :include-macros true]))
+2
View File
@@ -53,6 +53,7 @@
[frontend-tests.plugins.value-objects-test]
[frontend-tests.render-dimensions-test]
[frontend-tests.render-wasm.process-objects-test]
[frontend-tests.render-wasm.text-editor-apply-styles-test]
[frontend-tests.render-wasm.text-editor-caret-color-test]
[frontend-tests.svg-fills-test]
[frontend-tests.text-editor-paste-guard-test]
@@ -144,6 +145,7 @@
'frontend-tests.plugins.utils-test
'frontend-tests.plugins.value-objects-test
'frontend-tests.render-wasm.process-objects-test
'frontend-tests.render-wasm.text-editor-apply-styles-test
'frontend-tests.render-wasm.text-editor-caret-color-test
'frontend-tests.svg-fills-test
'frontend-tests.tokens.copy-paste-props-test
@@ -0,0 +1,597 @@
# Backend Performance Test Plan
**Context:** Build a k6-based load/performance test suite that simulates realistic browser-to-backend HTTP flows for distinct Penpot user operations. The goal is to measure backend impact (latency, throughput, error rates, resource saturation) under synthetic user load. **Browser rendering performance is explicitly out of scope.** WebSocket testing is deferred.
**Date:** 2026-06-12
**Validated Requirements:**
- Tool: **k6** (confirmed).
- Environment: flexible — local devenv first, then remote staging/perf.
- Target scale: **1000 concurrent VUs** (ramping from lower baselines).
- Flows: **realistic CRUD lifecycle** — create, edit, upload, delete. Must include **image upload** and **font upload**.
- `update-file` is important but difficult because it requires **23 concurrent users editing the same file**, and **file size matters**.
- WebSocket: **deferred**.
---
## Current Progress
### Completed (2026-06-12)
Phase 1 done. Phase 2 done (all core flows + performance optimization). Phase 3 done (orchestrator). Phase 4 done (concurrent editing + file size matrix). Phase 5 remains.
**What was built:**
```
performance/
├── run.sh # Bash runner — all commands + orchestrator
├── README.md # Usage docs, configuration, architecture notes
├── lib/
│ └── penpot-client.js # ~590 lines — shared k6 HTTP client module
├── scripts/
│ ├── lifecycle.js # Full user lifecycle (register → CRUD → delete)
│ ├── workspace-open.js # Read-heavy: file open loop (get-file, libraries, thumbnails)
│ ├── workspace-edit.js # Write-heavy: file edit loop (get-file + update-file)
│ ├── workspace-edit-concurrent.js # Concurrent editing: same-file or multi-file mode
│ ├── file-size-matrix.js # File size matrix: latency vs shape count (10, 100, 500, 1000)
│ ├── media-upload.js # Image uploads: SVG/PNG direct, JPG chunked
│ ├── font-upload.js # Font uploads: TTF+OTF chunked, create-font-variant
│ └── compare-results.cjs # Compare two k6 JSON results for regression
├── results/ # k6 JSON output (gitignored)
└── baselines/ # for regression baselines
```
Fixtures are reused from `backend/test/backend_tests/test_files/` (no copies in `performance/`).
**Backend changes:**
- `backend/src/app/rpc/commands/demo.clj` — demo profile emails changed from timestamp-based to UUID-based (eliminates collisions). Uses `derive-password-weak` for fast password hashing.
- `backend/src/app/auth.clj` — added `derive-password-weak` using pbkdf2+sha256 (100 iterations, ~0.13ms/hash, ~700x faster than argon2id). Safe for demo users because `demo-users` flag is disabled by default in production.
**All scripts use `setup()` user pool:**
| Script | setup() creates | VU pattern |
|--------|----------------|------------|
| `lifecycle.js` | N users | Each VU picks `users[__VU-1]` → login → full CRUD |
| `workspace-open.js` | 1 user + 1 file with shape | All VUs share same user + file (realistic concurrent reads) |
| `workspace-edit.js` | N users + shared project | Each VU creates own file → edit loop |
| `media-upload.js` | N users | Each VU creates project/file → upload 3 images |
| `font-upload.js` | N users | Each VU uploads TTF+OTF → create-font-variant |
Setup is sequential (~0.13ms/user with `derive-password-weak`), excluded from k6 metrics. At 1000 VUs: ~0.13s setup, then pure measurement.
**All flows validated (smoke test, 1 VU, 1 iteration each):**
| Script | Checks | Failure Rate |
|--------|--------|-------------|
| `lifecycle.js` | 10/10 | 0% |
| `workspace-open.js` | 9/9 | 0% |
| `workspace-edit.js` | 5/5 | 0% |
| `media-upload.js` | 8/8 | 0% |
| `font-upload.js` | 11/11 | 0% |
**Orchestrator (`./run.sh all`) validated** — runs all 5 flows in parallel, 0% failure rate.
**Key discoveries (cumulative):**
1. **JSON transport works.** Backend accepts `Content-Type: application/json` (kebab-case keys auto-converted) and returns `application/json` (camelCase keys) via `Accept: application/json` or `_fmt=json`. No Transit encoder needed.
2. **`create-file` `features` param.** Sending `features: []` causes 400. Omit entirely — it's optional (`backend/src/app/rpc/commands/files_create.clj`).
3. **`update-file` shape schema is strict.** The `add-obj` change requires: `selrect`, `points` (4 corners), `transform`/`transform-inverse` (identity matrix), `parentId`/`frameId` inside `obj`, and `frameId` at the change top level. Schema: `common/src/app/common/files/changes.cljc:189`.
4. **`update-file` URL convention.** `POST /api/main/methods/update-file?id=<uuid>``id` in both query string and body.
5. **Two registration modes:** `demo` (fast, needs `demo-users` flag) and `register` (two-step, no flags).
6. **k6 at** `/home/penpot/.local/bin/k6` (v0.56.0). Use `PATH="/home/penpot/.local/bin:$PATH"` or `K6` env var.
7. **Demo profile race condition — solved.** Backend now uses `uuid/next` for demo emails (no collisions). k6 scripts use `setup()` to create user pool before VUs start. Both changes together eliminate the scaling bottleneck.
8. **Chunked upload threshold.** The client uses 50 KB chunk size. Files ≤50 KB use direct multipart; files >50 KB use `create-upload-session``upload-chunk` × N → `assemble-file-media-object`.
9. **Font upload flow.** Each MIME type (ttf, otf, woff) gets its own `create-upload-session`. All session IDs are passed in the `uploads` map to `create-font-variant`. The `font-id` is a client-generated UUID that groups variants into a family.
10. **MIME type validation.** The backend validates that the uploaded content MIME matches the declared MIME. `sample.jpg` must be sent as `image/jpeg`, not `image/png`.
11. **workspace-open uses shared user.** All VUs read the same file with the same user. Multiple demo users can't access each other's files without team sharing, so a single shared user is the correct pattern for read-heavy tests.
12. **Demo profile creation was slow due to argon2id — now solved.** `derive-password` in `backend/src/app/auth.clj` uses argon2id with 32 MiB memory, 3 iterations, parallelism 2 (~94ms/hash). Created `derive-password-weak` using pbkdf2+sha256 with 100 iterations (~0.13ms/hash) — **~700x faster**. `demo.clj` now uses `derive-password-weak` for all demo profiles. Safe because `demo-users` is already a development-only feature (disabled by default in production). At 1000 VUs, setup time drops from ~23 min to ~0.13 sec.
13. **bcrypt minimum cost factor is 4.** Can't go below 4 for bcrypt. pbkdf2+sha256 with 100 iterations is even faster (~0.13ms/hash vs ~2.7ms for bcrypt cost 4) and was chosen instead. Benchmark: argon2id ~94ms/hash, bcrypt cost 4 ~2.7ms/hash, pbkdf2+sha256 100 iter ~0.13ms/hash.
14. **revn conflicts don't happen in normal concurrent editing.** The conflict check in `files_update.clj` is `(> incoming stored)` — only fires when incoming revn is *greater* than stored. If two VUs both read revn=5 and VU A saves first (revn becomes 6), VU B saves with revn=5 → `5 > 6?` → false → no conflict. The real contention point is the **file-level advisory lock** (`db/xact-lock! conn id`) that serializes all `update-file` calls on the same file. More VUs = more lock queuing = higher latency.
15. **`update-file` response doesn't include `vern`.** The response is `{:revn N, :lagged [...]}`. `vern` only changes on snapshot restore, so it can be kept constant across iterations. Get it from the initial `get-file` call.
### Remaining Work
| Phase | Status | Next Actions |
|-------|--------|-------------|
| Phase 1 Discovery & Tooling | **Done** | — |
| Phase 2 Core HTTP Flows | **Done** | All 5 flows + orchestrator + setup() pool |
| Phase 2 Performance Optimization | **Done** | `derive-password-weak` using pbkdf2+sha256 (100 iter) — ~700x faster than argon2id |
| Phase 3 Scenarios | **Done** | `./run.sh all` runs all flows in parallel |
| Phase 4 Concurrent Editing | **Done** | `workspace-edit-concurrent.js` with same-file and multi-file modes |
| Phase 4 File Size Matrix | **Done** | `file-size-matrix.js` with 4 tiers (10, 100, 500, 1000 shapes) |
| Phase 5 Regression Guard | **Done** | `compare-results.cjs` + CI workflow (relative comparison) |
| Phase 5 Grafana Dashboards | **Deferred** | No Prometheus remote write or InfluxDB in current stack |
### Immediate Next Steps
1. ~~Phase 2 Fast password for demo users~~ ✅ Done
2. ~~Phase 4: File size matrix (`update-file` latency vs shape count: 10, 100, 500, 1000 shapes).~~ ✅ Done — `file-size-matrix.js` with 4 tiers
3. ~~Phase 4: Concurrent editing test (23 VUs per file, measure conflict rate).~~ ✅ Done — `workspace-edit-concurrent.js` with same-file and multi-file modes
4. ~~Phase 5: Regression guard — implement `compare-results.cjs` and CI workflow.~~ ✅ Done
5. ~~Add `--scenario` flag to `run.sh`~~ ✅ Done
6. Write `viewer.js``get-view-only-bundle` + `get-comment-threads` (deferred per user request).
---
## Affected Modules
| Module | Why it is involved |
|--------|---------------------|
| `backend/` | Target system. All HTTP RPC (`/api/main/methods/*`), auth, storage, media processing, DB, and Prometheus metrics (`/metrics`). |
| `frontend/` | Source of truth for user request flows. We inspect `app.main.repo` (RPC client), `app.main.data.*` (user flows), and `app.main.data.persistence` (save semantics). |
| `common/` | Shared schemas, Transit helpers, and data structures. Used to understand valid `update-file` `changes` payloads. |
---
## Approach
### Phase 1 Discovery & Tooling (Days 12)
#### 1.1. Read the frontend RPC flows to build a request catalog
Inspect these files to map every user action to its RPC command:
- `frontend/src/app/main/repo.cljs` — HTTP client conventions (headers, retry, GET vs POST rules, query params, form-data, multipart).
- `frontend/src/app/main/data/dashboard.cljs` — Dashboard init (`get-projects`, `fetch-fonts`, `search-files`).
- `frontend/src/app/main/data/workspace.cljs` — Workspace init (`get-file`, `get-file-libraries`, `get-file-object-thumbnails`, `resolve-file` via `get-file-fragment`).
- `frontend/src/app/main/data/persistence.cljs` — File save flow (`update-file` with `changes`, `revn`, `session-id`, debounce/buffer logic).
- `frontend/src/app/main/data/viewer.cljs` — Viewer flow (`get-view-only-bundle`).
- `frontend/src/app/main/data/comments.cljs` — Comment thread fetch (`get-comment-threads`).
- `frontend/src/app/main/data/media.cljs` / `upload.cljs` — Media upload flows (`upload-file-media-object`, `create-upload-session`, `upload-chunk`, `assemble-file-media-object`).
- `frontend/src/app/main/data/fonts.cljs` — Font upload flow (`create-font-variant` with `:uploads` map).
- `frontend/src/app/main/data/team.cljs` — Team creation (`create-team`), invitation (`create-team-invitations`).
- `frontend/src/app/main/data/project.cljs` — Project creation (`create-project`).
**Goal:** produce a **Request Catalog** mapping user actions to RPC command names, HTTP methods, payload shapes, and required preconditions (e.g., `team-id`, `file-id`).
#### 1.2. Confirm JSON compatibility for the test harness
The backend middleware (`app.http.middleware`) supports `application/json` request bodies and `application/json` responses (via `_fmt=json` or `Accept: application/json`).
- **Action:** Send a manual `curl` to `POST /api/main/methods/login-with-password` with `Content-Type: application/json` and verify the response format.
- **Action:** Verify `GET /api/main/methods/get-profile` with `Accept: application/json` returns plain JSON.
- **Action:** Verify `POST /api/main/methods/update-file` with `Content-Type: application/json` and `_fmt=json` works.
- **Action:** Verify `POST /api/main/methods/upload-file-media-object` with `multipart/form-data` works (k6 supports this natively).
#### 1.3. Set up the load testing directory and shared client
Create a directory `performance/` at the repo root.
Install **k6** (`k6` CLI or Docker image).
Create a shared `penpot-client.js` module that wraps:
- `login(email, password)` → returns session cookie / token.
- `rpc(cmd, params, opts)` → builds the correct URL, headers, body, and query params.
- `uploadFileMediaObject(fileId, filePath, name)` → multipart upload.
- `createUploadSession(totalChunks)` → chunked upload setup.
- `uploadChunk(sessionId, index, chunkBytes)` → multipart chunk upload.
- `assembleFileMediaObject(sessionId, fileId, name, isLocal)` → finalize chunked upload.
**Headers to replicate (critical for backend telemetry and session binding):**
- `x-session-id`: generated UUID per VU (must be consistent across requests for the same session).
- `x-external-session-id`: generated UUID per VU.
- `x-event-origin`: a string origin (e.g., `"perf-test"`)
- `accept`: `application/json` (for HTTP-only load path)
- `content-type`: `application/json` (or `multipart/form-data` for uploads)
- `credentials: "include"` (for cookie jar)
#### 1.4. Data seeding strategy for 1000 VU scale
Creating 1000 users/teams/files *inside* the load test is too slow and will distort the results.
**Recommended approach:**
- **Setup Phase (k6 `setup()`):** Run a pre-test script that creates a shared pool of test artifacts.
- Use `login-with-password` with a fixture account (e.g., `profile1@example.com` / `123123` if fixtures exist).
- Create `N` teams, `N` projects, `N` files of varying sizes (see **File Size Tiers** below).
- Export the IDs into a JSON file that k6 `setup()` reads.
- **Alternative:** Use the backend REPL / fixtures (`app.cli.fixtures/run {:preset :small}`) to create fixture data, then export the IDs via a small Clojure script.
- **Data pool per VU:** Each VU picks a random user from the pool, or uses a dedicated user (e.g., VU #1`profile1@example.com`, VU #2`profile2@example.com`). For 1000 VUs, we need at least 1000 pre-seeded users.
- **Cleanup:** A post-test script can delete the seeded data, or we can use a dedicated perf DB that is reset between runs.
**Action:** Document the seeding procedure in `performance/README.md` and create a `seed-data.js` script.
---
### Phase 2 Core HTTP Flow Scripts (Days 35)
Create one k6 script per user flow. Each script:
- Uses `setup()` to read the shared data pool and log in.
- Uses `vu` iterations to simulate the flow.
- Tags every request with the RPC command name so k6 metrics are sliced by endpoint.
- Uses `check()` assertions for HTTP 200 and valid JSON structure.
#### Flow 1: Realistic User Lifecycle (`lifecycle.js`)
This is the primary realistic flow. Each VU performs a full lifecycle:
1. **Auth**
- `POST /api/main/methods/login-with-password``{email, password}`
- `GET /api/main/methods/get-profile`
- `GET /api/main/methods/get-teams`
2. **Create Team**
- `POST /api/main/methods/create-team``{name: "Perf Team <uuid>"}`
- `GET /api/main/methods/get-team?team-id=<id>`
3. **Create Project**
- `POST /api/main/methods/create-project``{team-id, name}`
- `GET /api/main/methods/get-project?id=<id>`
4. **Create File**
- `POST /api/main/methods/create-file``{project-id, name, features}`
- `GET /api/main/methods/get-file?id=<file-id>&features=<...>`
5. **Edit File (Simple Update)**
- `POST /api/main/methods/update-file` with a minimal `changes` payload.
- **Changes payload:** Use a simple change like `{:type "add-obj", :id "<uuid>", :page-id "<page-id>", :parent-id "<parent-id>", :obj {:type "rect", ...}}`. Inspect `app.common.files.changes` for the exact schema. For a load test, we only need the shape to be structurally valid; the backend validates it.
- **Revn tracking:** Fetch the file first, read `revn`, then send `revn` in the update. If a conflict occurs (`409` or `:revn-conflict` error), retry once with the latest `revn`.
6. **Upload Image (Direct)**
- `POST /api/main/methods/upload-file-media-object` (multipart)
- Payload: `file-id`, `is-local: true`, `name`, `content` (the file bytes).
- Use a small dummy PNG/SVG (e.g., 1 KB, 100 KB, 1 MB) stored in `performance/fixtures/`.
7. **Upload Image (Chunked)**
- `POST /api/main/methods/create-upload-session``{total-chunks: N}`
- Loop `N` times: `POST /api/main/methods/upload-chunk` (multipart, `session-id`, `index`, `chunk`)
- `POST /api/main/methods/assemble-file-media-object``{session-id, file-id, name, is-local}`
- Use a larger dummy file (e.g., 5 MB) to stress the chunked pipeline.
8. **Upload Font**
- `POST /api/main/methods/create-upload-session` (chunked, because fonts can be large)
- `POST /api/main/methods/upload-chunk` for each chunk
- `POST /api/main/methods/create-font-variant``{team-id, font-id, font-family, font-weight, font-style, uploads: {"font/ttf": "<session-id>"}}`
- Use a small real TTF/OTF file from `performance/fixtures/`.
9. **Delete File**
- `DELETE /api/main/methods/delete-file` (verify the exact method name; it may be `update-file` with a deletion flag or a dedicated command). Inspect `frontend/src/app/main/data/dashboard.cljs` for the delete action.
10. **Delete Project**
- `DELETE /api/main/methods/delete-project?id=<id>`
11. **Delete Team**
- `POST /api/main/methods/delete-team?id=<id>`
12. **Logout**
- (Optional; session cookie expiry is usually sufficient)
**Pacing:** Add `sleep()` between steps to simulate realistic think time (e.g., 13 seconds between dashboard navigation, 35 seconds between edits).
#### Flow 2: Workspace Open (Read-heavy) (`workspace-open.js`)
For 1000 VUs, most will be read-only viewers or editors opening files.
1. Login (reuse token from `setup`).
2. `GET /api/main/methods/get-file?id=<file-id>&features=<...>`
3. `GET /api/main/methods/get-file-libraries?file-id=<file-id>`
4. For each library: `GET /api/main/methods/get-file?id=<lib-id>`
5. `GET /api/main/methods/get-file-object-thumbnails?file-id=<file-id>`
6. `GET /api/main/methods/get-file-data-for-thumbnail?file-id=<file-id>&page-id=<page-id>&object-id=<frame-id>`
**Data:** Use a pool of files of varying sizes (see **File Size Tiers**).
#### Flow 3: Workspace Edit (Write-heavy) (`workspace-edit.js`)
**Scenario A — Independent editors (default, easiest to scale):**
- Each VU creates its own file in `setup()`, or picks a dedicated file from the pool.
- Loop:
1. `GET /api/main/methods/get-file?id=<file-id>` (to refresh `revn`)
2. `POST /api/main/methods/update-file` with minimal changes
3. `sleep(3)`
- This measures the latency of the save path without concurrency conflicts.
**Scenario B — Concurrent editors (advanced, measures conflict resolution):**
- 23 VUs share the **same file ID**.
- Each VU:
1. `GET /api/main/methods/get-file?id=<file-id>` (to get latest `revn`)
2. `POST /api/main/methods/update-file` with changes
3. If `revn-conflict` (HTTP 400 or 409 with `:code :revn-conflict`), retry with the latest `revn`.
- **Problem:** k6 VUs are independent; they cannot easily share a mutable `revn` counter.
- **Solutions:**
1. **Optimistic concurrency:** Let conflicts happen naturally. Measure the conflict rate and retry latency. This is realistic for many-user editing.
2. **Shared state service:** Run a tiny Redis or in-memory service that stores the latest `revn` per file. VUs read/write it before each update. This adds coordination overhead but reduces conflicts.
3. **Sequential VU groups:** Use k6 `scenarios` with `executor: 'per-vu-iterations'` and a small shared file pool. Accept that some conflicts will occur and measure them as part of the benchmark.
- **Recommendation:** Start with **Solution 1** (optimistic). If the conflict rate is >10%, consider **Solution 2**.
**File Size Tiers for `update-file`:**
The backend `update-file` performance depends heavily on file data size (serialization, validation, pointer-map resolution, snapshotting).
| Tier | Size | How to create |
|------|------|---------------|
| Small | ~10 shapes | Create a file with a few rectangles. |
| Medium | ~100 shapes | Duplicate a page with many shapes. |
| Large | ~1000 shapes | Import a real-world design file or use a fixture. |
**Action:** Create a `create-file-fixture.js` helper that generates files of each tier via the `create-file` + `update-file` API (or by importing a `.penpot` file via the binfile import API if available).
#### Flow 4: Viewer (Read-heavy, anonymous or logged-in) (`viewer.js`)
1. Login (or use share-link token for anonymous).
2. `GET /api/main/methods/get-view-only-bundle?file-id=<id>&share-id=<id>&features=<...>`
3. `GET /api/main/methods/get-comment-threads?file-id=<id>&share-id=<id>`
#### Flow 5: Export (CPU/IO-heavy) (`export.js`)
1. Login.
2. `POST /api/export` with export payload.
- Inspect `frontend/src/app/main/data/export.cljs` for the exact payload shape.
- Common exports: `type: "png"`, `type: "svg"`, `type: "pdf"`.
- This hits the **exporter** service (Node.js/Playwright), which is a separate process. If the goal is to stress the **backend**, limit export tests or target the backend export queue endpoints.
#### Flow 6: Media Upload (Storage/IO-heavy) (`media-upload.js`)
1. Login.
2. Direct upload: `POST /api/main/methods/upload-file-media-object` (multipart, small PNG).
3. Chunked upload: `POST /api/main/methods/create-upload-session``upload-chunk` x N → `assemble-file-media-object` (large PNG).
4. URL-based upload: `POST /api/main/methods/create-file-media-object-from-url` (if a stable external image URL is available).
#### Flow 7: Font Upload (Storage/CPU-heavy) (`font-upload.js`)
1. Login.
2. `POST /api/main/methods/create-upload-session` (for the font file)
3. `POST /api/main/methods/upload-chunk` for each chunk
4. `POST /api/main/methods/create-font-variant``{team-id, font-id, font-family, font-weight, font-style, uploads: {"font/ttf": "<session-id>"}}`
5. `GET /api/main/methods/get-font-variants?team-id=<id>`
---
### Phase 2 Performance Optimization: Fast Password Hashing for Demo Users ✅ Done
**Goal:** Reduce `setup()` time for performance tests by making demo profile password derivation faster.
**Problem:** `derive-password` in `backend/src/app/auth.clj` uses argon2id with 32 MiB memory, 3 iterations, parallelism 2 (~94ms/hash). At 1000 VUs, creating the user pool in `setup()` takes ~23 minutes just for password hashing.
**Solution:**
Since `demo-users` is already a development-only feature (disabled by default in production), all demo profiles use a weaker, faster password algorithm. No special parameters or tenant checks needed.
1. In `backend/src/app/auth.clj`, added `derive-password-weak` using pbkdf2+sha256 with 100 iterations (~0.13ms/hash — **~700x faster** than argon2id).
2. In `backend/src/app/rpc/commands/demo.clj`, switched from `derive-password` to `derive-password-weak`.
**Files touched:**
- `backend/src/app/auth.clj` — added `weak-options` (pbkdf2+sha256, 100 iter) and `derive-password-weak`
- `backend/src/app/rpc/commands/demo.clj` — uses `derive-password-weak` instead of `derive-password`
**Impact:** Setup time for 1000 users dropped from ~23 min to ~0.13 sec (~700x improvement).
**Safety:** Demo users are already a development-only feature (disabled by default in production via `demo-users` config flag). Using weaker passwords for demo users only affects development/test environments where the flag is explicitly enabled.
---
### Phase 3 Scenarios & Orchestration (Day 6)
Define k6 `options.scenarios` that mix the flows to simulate realistic traffic.
**Example scenario mix for 1000 VUs:**
| Scenario | Script | VUs | Arrival Rate | Duration | Notes |
|----------|--------|-----|--------------|----------|-------|
| `lifecycle` | `lifecycle.js` | 100 | 1/s (ramp 0→100 over 5m) | 10m | Full CRUD, most realistic. |
| `workspace_open` | `workspace-open.js` | 400 | 5/s (ramp 0→400 over 5m) | 10m | Read-heavy, simulates many editors opening files. |
| `workspace_edit` | `workspace-edit.js` | 200 | 2/s (ramp 0→200 over 5m) | 10m | Write-heavy, independent files. |
| `workspace_edit_concurrent` | `workspace-edit.js` | 30 (10 groups of 3) | 0.5/s | 10m | 3 VUs per file, measures conflicts. |
| `viewer` | `viewer.js` | 200 | 3/s (ramp 0→200 over 5m) | 10m | Read-heavy, simulates public/private viewers. |
| `media_upload` | `media-upload.js` | 50 | 0.5/s | 10m | Storage stress. |
| `font_upload` | `font-upload.js` | 20 | 0.2/s | 10m | Font processing stress. |
**Thresholds:**
- `http_req_duration{p95} < 200ms` for `get-profile`, `get-teams`, `get-projects`.
- `http_req_duration{p95} < 500ms` for `get-file` (small), `search-files`.
- `http_req_duration{p95} < 2000ms` for `get-file` (large / 1000 shapes).
- `http_req_duration{p95} < 1000ms` for `update-file` (small).
- `http_req_duration{p95} < 3000ms` for `update-file` (large).
- `http_req_duration{p95} < 5000ms` for `upload-file-media-object` (1 MB).
- `http_req_duration{p95} < 10000ms` for `assemble-file-media-object` (5 MB chunked).
- `http_req_failed < 1%` globally.
- `http_req_failed{code:revn-conflict} < 5%` for `workspace_edit_concurrent`.
**Correlation with backend metrics:**
- Scrape `/metrics` before, during, and after the test.
- Key Prometheus metrics to watch:
- `rpc_main_timing_seconds` (histogram/summary, labeled by command name)
- `rpc_management_timing_seconds`
- `http_server_dispatch_timing_seconds`
- `websocket_active_connections` (if any WS is active)
- `websocket_messages_total`
- JVM hotspot metrics (`process_cpu_seconds_total`, `jvm_memory_bytes_used`, `jvm_threads_current`)
- HikariCP metrics (if exposed; check `com.zaxxer.hikari:type=Pool` via JMX or custom Prometheus exporter)
- PostgreSQL: `pg_stat_activity` count by state.
- Redis: `INFO` `connected_clients`, `used_memory`.
---
### Phase 4 Advanced `update-file` Testing (Days 78)
Because `update-file` is the core of the product and the user explicitly noted that **file size matters** and **concurrent editing is difficult**, we need a dedicated deep-dive.
#### 4.1. File Size Tiers
Create a `file-size-matrix.js` script that parameterizes the file size:
- `SMALL_FILE_ID`: 1 page, 10 shapes.
- `MEDIUM_FILE_ID`: 1 page, 100 shapes.
- `LARGE_FILE_ID`: 1 page, 500 shapes.
- `XLARGE_FILE_ID`: 1 page, 1000+ shapes, or a multi-page file.
Run `workspace-edit.js` against each tier separately and plot:
- `update-file` latency vs file size.
- `get-file` latency vs file size.
- Backend CPU and DB time vs file size.
#### 4.2. Concurrent Editing — Two Modes
**Key insight:** `revn` conflicts only occur when `incoming > stored` (should never happen in normal usage). The real contention point is the **file-level advisory lock** (`db/xact-lock! conn id`) that serializes all `update-file` calls on the same file.
**Mode 1: Same-file** — N VUs edit different pages in 1 file
- Measures lock contention on a single popular file
- Bottleneck: advisory lock serialization
**Mode 2: Multi-file** — G groups × M VUs per file, each group edits its own file
- Measures whole system responsiveness under parallel edit sessions
- Bottleneck: DB connection pool, CPU, memory
- More realistic: real usage has many files being edited concurrently
**Script:** `workspace-edit-concurrent.js`
**Configuration via env vars:**
- `PENPOT_EDIT_MODE=same-file | multi-file` (default: `same-file`)
- `PENPOT_FILE_COUNT=1` — number of files (for multi-file mode)
- `PENPOT_VUS_PER_FILE=3` — VUs per file (for multi-file mode)
**Setup logic:**
- `same-file`: create 1 file, add N pages (N = total VUs)
- `multi-file`: create G files, each with M pages (G = FILE_COUNT, M = VUS_PER_FILE)
**VU loop:**
1. Login with assigned user
2. Get file → pick assigned page
3. Loop (10 iterations):
- `get-file` → get latest `revn`
- `sleep(0.3)` (think time)
- `update-file` with change to assigned page (add rectangle)
- Track: success on first try (should always succeed)
- `sleep(1)` (edit pacing)
**Scenario ladder — same-file mode:**
| Run | VUs | Iterations | What we measure |
|-----|-----|-----------|-----------------|
| 1 | 3 | 10 | Baseline lock contention |
| 2 | 5 | 10 | Moderate contention |
| 3 | 10 | 10 | Higher contention |
| 4 | 20 | 10 | Stress level |
**Scenario ladder — multi-file mode:**
| Run | Files | VUs/file | Total VUs | What we measure |
|-----|-------|----------|-----------|-----------------|
| 1 | 3 | 2 | 6 | Light load |
| 2 | 5 | 3 | 15 | Moderate |
| 3 | 10 | 3 | 30 | Heavy |
| 4 | 10 | 5 | 50 | Stress |
**Metrics to track:**
- `http_req_duration{rpc_command:update-file}` — p50, p95, p99 at each VU level
- `http_req_duration{rpc_command:get-file}` — should be unaffected
- `http_req_failed` — should be 0%
- Latency growth curve: how much does p95 increase per additional VU?
**Expected results:**
- `get-file` latency: constant (no lock, read-only)
- `update-file` p95: grows with VU count in same-file mode (lock queuing)
- `update-file` p95: stable in multi-file mode (independent locks)
- Failure rate: 0% (no revn conflicts in this scenario)
**Files to create:**
- `performance/scripts/workspace-edit-concurrent.js`
**Files to modify:**
- `performance/run.sh` — add `concurrent-edit` command
---
### Phase 5 CI Integration & Reporting (Days 910)
1. **Runner script (`run.sh`):**
- `./run.sh smoke` for a 1-VU, 1-iteration smoke test. ✅ Done
- `./run.sh lifecycle -v 100 -n 10` for the standard run.
- Add `--scenario` flag to run individual flows or the full mix. ✅ Done
2. **Output:**
- k6 JSON/CSV output to `performance/results/<timestamp>/`.
- Prometheus snapshot diff (before vs after).
- Grafana screenshot or dashboard export.
3. **Grafana Dashboard:** *(Deferred — no Prometheus remote write or InfluxDB configured in current stack)*
- Panel: `p95 latency by RPC command` (from `rpc_main_timing_seconds`).
- Panel: `HTTP requests/sec` (from k6).
- Panel: `Error rate by command` (from k6).
- Panel: `DB connection pool` (if available).
- Panel: `JVM heap used`.
- Panel: `update-file conflict rate` (custom metric from k6).
- Panel: `File size vs latency` (from the matrix test).
4. **Regression guard (relative comparison):**
- **Approach:** Run performance tests twice in the same CI job — once on base branch, once on PR branch. Compare p95/p99 directly. No stored baselines needed.
- **Trigger:** Only when backend files change (`backend/src/**`).
- **Comparison script:** `scripts/compare-results.js` — parses two k6 JSON outputs, compares p50/p95/p99 for each RPC command.
- **Threshold:** Fail if p95 increases >20% for any critical command (`get-file`, `update-file`, `login-with-password`, `create-demo-profile`).
- **Workflow:**
1. Checkout base branch (main)
2. Run performance tests → store as "baseline"
3. Checkout PR branch
4. Run performance tests → store as "current"
5. Compare baseline vs current
6. If p95 increases >20% → fail CI
- **Advantages:** Same hardware, same conditions. No stored baselines. Only runs when backend changes.
---
## Risks & Considerations
| Risk | Mitigation |
|------|------------|
| **Scale: 1000 VUs creating data simultaneously will exhaust DB connection pool or storage quota.** | Pre-seed the data pool. Use a dedicated perf DB. Monitor `pg_stat_activity` and HikariCP metrics. |
| **Media upload (images/fonts) will saturate network I/O before the backend is stressed.** | Run the load test from the same datacenter/VPC as the backend. Use small dummy files for most tests; reserve large files for a dedicated storage-stress scenario. |
| **`update-file` conflicts under 1000 VUs may be so high that the test becomes a conflict test, not a latency test.** | Measure both. The conflict rate is itself a critical metric. If it is too high, we can add jitter or use independent files. |
| **Exporter service is a separate bottleneck.** | `export.js` should target the backend queue endpoint, not the full export pipeline, unless we want to test the exporter too. If exporter is in scope, run it as a separate scenario. |
| **Chunked upload creates many temporary DB rows (`upload_chunk` table).** | The backend has a `upload-session-gc` cron job. Ensure it runs after the test, or clean up manually. |
| **Font upload shells out to FontForge and WOFF tools.** | This is CPU-intensive and may be a bottleneck. Run font upload as a separate, low-VU scenario to measure the processing time without blocking other tests. |
| **Prometheus metrics may not expose DB pool wait time.** | Add a custom JMX exporter for HikariCP if needed, or query `pg_stat_activity` directly. |
| **Cleanup:** 1000 VUs creating teams/files will leave logical deletions or orphaned storage objects.** | Use a dedicated perf environment. Run a cleanup script after the test that deletes all seeded data via the RPC API. |
---
## Testing Strategy
### How to verify the test harness itself works
1. **Smoke test:** Run each k6 script with `1 VU, 1 iteration` against a local devenv. Verify all requests return `200` and the response body is valid JSON.
2. **Baseline run:** Run `workspace-open.js` with `10 VUs, 60 s` against a clean devenv. Record baseline p95 and p99 latencies.
3. **Regression guard:** After any backend change, re-run the baseline. If p95 increases by >20%, flag it.
4. **Saturation test:** Ramp `workspace-edit.js` to 100 VUs editing independent files. Monitor backend CPU and DB connection pool. The test should reveal the breaking point where `update-file` latency spikes.
5. **Media upload stress test:** Run `media-upload.js` with 50 VUs uploading 1 MB files. Verify storage throughput and no `413` errors.
6. **Font upload stress test:** Run `font-upload.js` with 10 VUs. Verify FontForge CPU usage and no timeouts.
### Manual validation checklist
- [x] `POST /api/main/methods/login-with-password` with JSON body returns a session cookie. (Validated via k6 lifecycle)
- [x] `GET /api/main/methods/get-profile` with `Accept: application/json` returns JSON. (Validated via k6 lifecycle)
- [ ] `curl -H "Accept: application/json" http://localhost:6060/metrics` returns Prometheus text.
- [ ] Backend fixtures create at least 100 test users and 100 test files.
- [x] A `update-file` request with a minimal `changes` payload succeeds and returns `{"revn": N}`. (Validated — needs full shape with selrect, points, transform, frame-id)
- [x] A `upload-file-media-object` multipart request succeeds and returns a media object ID. (Validated via k6 lifecycle + media-upload)
- [x] A chunked upload (`create-upload-session``upload-chunk``assemble-file-media-object`) succeeds. (Validated via media-upload with JPG 305 KB, and font-upload with TTF 68 KB + OTF 82 KB)
- [x] A `create-font-variant` request with chunked uploads succeeds. (Validated via font-upload — TTF + OTF, returns variant with id)
---
## Immediate Next Steps (if approved)
1. ~~Create `performance/` directory and `README.md`.~~ ✅ Done
2. ~~Write `penpot-client.js` (k6 shared module) with `login()`, `rpc()`, `uploadMultipart()`, and `uploadChunked()` helpers.~~ ✅ Done (~590 lines, JSON transport, cookie auth, session headers, tagged metrics, direct + chunked upload, file library/thumbnail methods)
3. ~~Write a manual `curl` validation script~~ — Skipped; JSON compatibility confirmed via k6 smoke test.
4. ~~Write a data seeding script~~ — Not needed. User pool created in k6 `setup()` phase (sequential, excluded from metrics). Each VU picks `data.users[__VU - 1]` to login.
5. ~~Write the first k6 script: `lifecycle.js`~~ ✅ Done (11 checks, 22 HTTP requests, 0% failure)
6. ~~Run a 1-VU smoke test against local devenv and commit the baseline results.~~ ✅ Done
7. ~~Write `workspace-open.js` and `workspace-edit.js`.~~ ✅ Done (both validated, 0% failure)
8. ~~Write `media-upload.js` and `font-upload.js`.~~ ✅ Done (both validated, 0% failure)
9. ~~Define the `1000-VU` scenario mix in `options.js` (shared scenario config).~~ ✅ Done (`./run.sh all` orchestrator runs all 5 flows in parallel)
10. Run the first 100-VU ramp test and capture Prometheus metrics.
---
**Plan Author:** Senior Software Architect
**Status:** Phase 15 complete. Regression guard implemented (relative comparison). Grafana dashboards deferred.
+4
View File
@@ -42,3 +42,7 @@ opt-level = 3
lto = "fat"
strip = true
codegen-units = 1
[profile.size]
inherits = "release"
opt-level = "z"
+28
View File
@@ -29,6 +29,34 @@ You can also use `./watch` to run the build on every change.
The build script will compile the project and copy the `.js` and `.wasm` files to their correct location within the frontend app.
### Render targets
The same Rust source produces two artifacts, which differ only in compiler
options:
| Target | Tuned for | Cargo profile | Consumed by |
| ---------- | --------- | ----------------- | ------------------------------ |
| `frontend` | speed | `release` (`-O3`) | `frontend/resources/public/js` |
| `export` | size | `size` (`-Oz`) | `exporter/resources/wasm` |
```sh
./build # both targets, frontend first
./build frontend # workspace / viewer renderer
./build export # headless exporter renderer
```
`./watch` still follows a single target (`frontend` unless you pass one),
since watching both would rebuild twice on every keystroke.
Each target keeps its own `CARGO_TARGET_DIR` (`target/<target>`), so switching
between them does not invalidate the other's cache. Set `BUILD_MODE=release`
(or `NODE_ENV=production`) for an optimized build; the default is `debug`.
Each target writes its own generated `shared.js` (the enum discriminants the
CLJS side compiles against) next to the code that imports it — respectively
`frontend/src/app/render_wasm/api/shared.js` and
`exporter/src/app/wasm/shared.js`. Neither build writes to the other's paths.
![Architecture overview](docs/images/architecture_schema.png)
+65 -15
View File
@@ -1,15 +1,25 @@
#!/usr/bin/env bash
export VERSION_TAG=${VERSION:-develop};
export RENDER_TARGET="${RENDER_TARGET:-${1:-frontend}}";
case "$RENDER_TARGET" in
frontend|export) ;;
*)
echo "ERROR: unknown render target '$RENDER_TARGET' (expected 'frontend' or 'export')" >&2;
exit 1;
;;
esac
if [ "$NODE_ENV" = "production" ]; then
export BUILD_MODE="release";
else
export BUILD_MODE=${1:-debug};
export BUILD_MODE=${BUILD_MODE:-debug};
fi
export BUILD_NAME="${BUILD_NAME:-render-wasm}"
export CARGO_BUILD_TARGET=${CARGO_BUILD_TARGET:-"wasm32-unknown-emscripten"};
export CARGO_TARGET_DIR=${CARGO_TARGET_DIR:-"target/$RENDER_TARGET"};
export SKIA_BINARIES_URL=${SKIA_BINARIES_URL:-"https://github.com/penpot/skia-binaries/releases/download/0.93.1/skia-binaries-319323662b1685a112f5-wasm32-unknown-emscripten-gl-svg-textlayout-binary-cache-webp.tar.gz"}
# 256 MB of initial heap to perform less
@@ -51,9 +61,21 @@ export EM_CACHE="/tmp/emsdk_cache";
export CARGO_PARAMS="${@:2}";
export CARGO_PROFILE_DIR="debug";
if [ "$BUILD_MODE" = "release" ]; then
export CARGO_PARAMS="--release $CARGO_PARAMS"
export EMCC_CFLAGS="-O3 -sASSERTIONS=0 $EMCC_CFLAGS"
case "$RENDER_TARGET" in
frontend)
export CARGO_PARAMS="--release $CARGO_PARAMS";
export CARGO_PROFILE_DIR="release";
export EMCC_CFLAGS="-O3 -sASSERTIONS=0 $EMCC_CFLAGS";
;;
export)
export CARGO_PARAMS="--profile size $CARGO_PARAMS";
export CARGO_PROFILE_DIR="size";
export EMCC_CFLAGS="-Oz -sASSERTIONS=0 $EMCC_CFLAGS";
;;
esac
else
# TODO: Extra parameters that could be good to look into:
# -gseparate-dwarf
@@ -62,6 +84,12 @@ else
export EMCC_CFLAGS="-g -sASSERTIONS=1 -sVERBOSE=1 $EMCC_CFLAGS"
fi
export FRONTEND_DEST="../frontend/resources/public/js";
export EXPORT_DEST="../exporter/resources/wasm";
export FRONTEND_SHARED_DEST="../frontend/src/app/render_wasm/api/shared.js";
export EXPORT_SHARED_DEST="../exporter/src/app/wasm/shared.js";
function clean {
cargo clean;
}
@@ -78,26 +106,48 @@ function build {
function copy_artifacts {
DEST=$1;
SRC="$CARGO_TARGET_DIR/$CARGO_BUILD_TARGET/$CARGO_PROFILE_DIR";
mkdir -p $DEST;
cp target/wasm32-unknown-emscripten/$BUILD_MODE/render_wasm.js $DEST/$BUILD_NAME.js;
cp target/wasm32-unknown-emscripten/$BUILD_MODE/render_wasm.wasm $DEST/$BUILD_NAME.wasm;
if [ -f target/wasm32-unknown-emscripten/$BUILD_MODE/render_wasm.wasm.map ]; then
cp target/wasm32-unknown-emscripten/$BUILD_MODE/render_wasm.wasm.map $DEST/$BUILD_NAME.wasm.map;
cp $SRC/render_wasm.js $DEST/$BUILD_NAME.js;
cp $SRC/render_wasm.wasm $DEST/$BUILD_NAME.wasm;
if [ -f $SRC/render_wasm.wasm.map ]; then
cp $SRC/render_wasm.wasm.map $DEST/$BUILD_NAME.wasm.map;
fi
sed -i "s/render_wasm.wasm/$BUILD_NAME.wasm?version=$VERSION_TAG/g" $DEST/$BUILD_NAME.js;
pnpm exec esbuild target/wasm32-unknown-emscripten/$BUILD_MODE/render_wasm.js \
--log-level=error \
--outfile=$DEST/worker/render.js \
--platform=neutral \
--format=iife \
--global-name=WasmModule;
# The worker bundle is a browser concern; the exporter imports the ESM
# module directly under Node.
if [ "$RENDER_TARGET" = "frontend" ]; then
pnpm exec esbuild $SRC/render_wasm.js \
--log-level=error \
--outfile=$DEST/worker/render.js \
--platform=neutral \
--format=iife \
--global-name=WasmModule;
fi
}
function copy_shared_artifact {
SHARED_FILE=$(find target/wasm32-unknown-emscripten -name render_wasm_shared.js | head -n 1);
cp $SHARED_FILE ../frontend/src/app/render_wasm/api/shared.js;
DEST=$1;
SHARED_FILE=$(find $CARGO_TARGET_DIR/$CARGO_BUILD_TARGET -name render_wasm_shared.js | head -n 1);
cp $SHARED_FILE $DEST;
}
# Copies whatever the current RENDER_TARGET produced to where that target's
# consumer reads it.
function copy_target_artifacts {
case "$RENDER_TARGET" in
frontend)
copy_artifacts "$FRONTEND_DEST";
copy_shared_artifact "$FRONTEND_SHARED_DEST";
;;
export)
copy_artifacts "$EXPORT_DEST";
copy_shared_artifact "$EXPORT_SHARED_DEST";
;;
esac
}
+20 -3
View File
@@ -1,8 +1,26 @@
#!/usr/bin/env bash
# Usage: ./build [frontend|export] [extra cargo params...]
#
# With no target, builds both. Set BUILD_MODE=release (or NODE_ENV=production)
# for an optimized build. See `_build_env` for what each target changes.
_SCRIPT_DIR=$(dirname $0);
# Each target needs its own `_build_env`, so re-enter per target.
case "${1:-}" in
frontend|export)
;;
*)
for _target in frontend export; do
"$_SCRIPT_DIR/build" "$_target" "$@" || exit $?;
done
exit 0;
;;
esac
EMSDK_QUIET=1 . /opt/emsdk/emsdk_env.sh
_SCRIPT_DIR=$(dirname $0);
pushd $_SCRIPT_DIR;
. ./_build_env
@@ -11,8 +29,7 @@ set -ex;
setup;
build;
copy_artifacts "../frontend/resources/public/js";
copy_shared_artifact;
copy_target_artifacts;
exit $?;
+4 -1
View File
@@ -80,7 +80,10 @@ flowchart TB
The client-side WASM export — rendering in the browser through the vector path
(`render_shape_pdf` / `render_shape_pixels`) — is wired **only for single
exports** (`request-simple-export` in `frontend/.../exports/assets.cljs`), and
only when render-wasm is active and the `:wasm-export` flag is set.
only when render-wasm is active and the `wasm-export/v1` feature is enabled
(instance-wide through the `enable-feature-wasm-export` flag, or per team
through the team `features` column). The exporter service keeps its own
`:wasm-export` flag to decide whether it can serve the headless WASM path.
**Multiple/batch export** (`request-multiple-export`) always runs **server-side**
via the `:export-shapes` command; it merely passes an `:is-wasm` hint so the
+1 -1
View File
@@ -8,7 +8,7 @@ if [[ "$1" == "--debug" ]]; then
set -x
fi
. ./_build_env
. ./_build_env frontend
export CARGO_BUILD_TARGET=${CARGO_BUILD_TARGET:-"wasm32-unknown-emscripten"};
export SKIA_BINARIES_URL=${SKIA_BINARIES_URL:-"https://github.com/penpot/skia-binaries/releases/download/0.93.1/skia-binaries-319323662b1685a112f5-wasm32-unknown-emscripten-gl-svg-textlayout-binary-cache-webp.tar.gz"}
+11
View File
@@ -40,6 +40,17 @@ pub(crate) fn get_render_state() -> &'static mut RenderState {
}
}
#[inline(always)]
pub(crate) fn current_browser() -> u8 {
unsafe {
if DESIGN_STATE.is_null() {
0
} else {
(*DESIGN_STATE).current_browser
}
}
}
#[inline(always)]
pub(crate) fn has_render_state() -> bool {
unsafe { !RENDER_STATE.is_null() }
+304 -130
View File
@@ -413,6 +413,10 @@ pub(crate) struct RenderState {
/// a tile before its text glyph uploads complete (blank first/center tile).
/// One explicit flush warms the submit path for the rest of the pass.
pub tile_atlas_flushed: bool,
/// DropShadows→Current touch once per tile when no shape composites a real
/// shadow. A full skip made flush_and_submit very slow (Skia ops-task
/// ordering); doing it per shape was wasted GPU work.
pub drop_shadows_ops_warmed: bool,
}
pub struct InteractiveDragCrop {
@@ -596,6 +600,7 @@ impl RenderState {
preserve_target_during_render: false,
backbuffer_crop_cache: HashMap::default(),
tile_atlas_flushed: false,
drop_shadows_ops_warmed: false,
})
}
@@ -930,10 +935,6 @@ impl RenderState {
Ok(())
}
pub fn flush(&mut self) {
self.surfaces.flush(SurfaceId::Backbuffer);
}
pub fn flush_and_submit(&mut self) {
self.surfaces.flush_and_submit(SurfaceId::Target);
}
@@ -1224,11 +1225,16 @@ impl RenderState {
}
fn get_inherited_drop_shadows(&self) -> Option<Vec<skia_safe::Paint>> {
let scale = self.get_scale();
let drop_shadows: Vec<&Shadow> = self
.nested_shadows
.iter()
.flat_map(|shadows| shadows.iter())
.filter(|shadow| !shadow.hidden() && shadow.style() == crate::shapes::ShadowStyle::Drop)
.filter(|shadow| {
!shadow.hidden()
&& shadow.style() == crate::shapes::ShadowStyle::Drop
&& shadow.is_perceptible_at_scale(scale)
})
.collect();
if drop_shadows.is_empty() {
@@ -1248,6 +1254,66 @@ impl RenderState {
)
}
/// Apply frame clip stack in document space on the given surface bitmask.
/// Caller must already have those surfaces in doc transform (Fills-style
/// scale + tile translation, or Current after the same). Hard (non-AA)
/// clips avoid alpha seams on semi-transparent overflow.
fn apply_clip_stack_to_surfaces(
&mut self,
clips: &ClipStack,
surface_ids: u32,
scale: f32,
debug_fill_surface: Option<SurfaceId>,
) {
for (mut bounds, corners, transform) in clips.iter() {
self.surfaces.apply_mut(surface_ids, |s| {
s.canvas().concat(transform);
});
// Outset clip by ~0.5 to include edge pixels that
// aliased clip misclassifies as outside (causing artifacts).
let outset = 0.5 / scale;
bounds.outset((outset, outset));
// Hard clip edge (antialias = false) to avoid alpha seam when clipping
// semi-transparent content larger than the frame.
if let Some(corners) = corners {
let rrect = RRect::new_rect_radii(bounds, corners);
self.surfaces.apply_mut(surface_ids, |s| {
s.canvas().clip_rrect(rrect, skia::ClipOp::Intersect, false);
});
} else {
self.surfaces.apply_mut(surface_ids, |s| {
s.canvas().clip_rect(bounds, skia::ClipOp::Intersect, false);
});
}
// This renders a red line around clipped
// shapes (frames).
if self.options.is_debug_visible() {
if let Some(fills_surface_id) = debug_fill_surface {
let mut paint = skia::Paint::default();
paint.set_style(skia::PaintStyle::Stroke);
paint.set_color(skia::Color::from_argb(255, 255, 0, 0));
paint.set_stroke_width(4.);
self.surfaces
.canvas(fills_surface_id)
.draw_rect(bounds, &paint);
}
}
// Uncomment to debug the render_position_data
// if let Type::Text(text_content) = &shape.shape_type {
// text::render_position_data(self, fills_surface_id, &shape, text_content);
// }
self.surfaces.apply_mut(surface_ids, |s| {
s.canvas()
.concat(&transform.invert().unwrap_or(Matrix::default()));
});
}
}
#[allow(clippy::too_many_arguments)]
pub fn render_shape(
&mut self,
@@ -1271,17 +1337,8 @@ impl RenderState {
| innershadows_surface_id as u32
| text_drop_shadows_surface_id as u32;
// Only save canvas state if we have clipping or transforms
// For simple shapes without clipping, skip expensive save/restore
let needs_save =
clip_bounds.is_some() || offset.is_some() || !shape.transform.is_identity();
if needs_save {
self.surfaces.apply_mut(surface_ids, |s| {
s.canvas().save();
});
}
let fast_mode = self.options.is_fast_mode();
let skip_drop_shadows = self.should_skip_drop_shadows();
// Skip anti-aliasing entirely during fast_mode (interactive
// gestures + pan/zoom). AA edge sampling is per-pixel and adds
// up across many shapes; reverts to full quality on commit.
@@ -1297,29 +1354,58 @@ impl RenderState {
&& self.nested_blurs.iter().flatten().any(|blur| {
!blur.hidden && blur.blur_type == BlurType::LayerBlur && blur.value > 0.0
});
let can_render_directly = apply_to_current_surface
&& clip_bounds.is_none()
&& offset.is_none()
&& parent_shadows.is_none()
&& !shape.needs_layer()
// Empty non-masked groups paint nothing here (children are separate walker
// nodes). Skip the layered Fills/Strokes path entirely.
if matches!(shape.shape_type, Type::Group(g) if !g.masked)
&& shape.fills.is_empty()
&& !shape.has_visible_strokes()
&& shape.shadows.is_empty()
&& shape.blur.is_none()
&& shape.background_blur.is_none()
&& !has_inherited_blur
&& shape.shadows.is_empty()
&& shape.transform.is_identity()
&& parent_shadows.is_none()
{
return Ok(());
}
// Only perceptible shadows need the layered Fills/Strokes path. Use the
// same footprint LOD as when painting drop and inner shadows.
let scale = self.get_scale();
let shadows_need_layered = !skip_drop_shadows
&& (shape
.drop_shadows_visible()
.any(|s| s.is_perceptible_at_scale_for(scale, shape.is_recursive()))
|| shape
.inner_shadows_visible()
.any(|s| s.is_perceptible_at_scale_for(scale, shape.is_recursive())));
// Clip is allowed: we apply the same stack on Current after scale+translate.
// Opacity < 1 with SrcOver is OK: render_shape_enter already opened a
// save_layer on Current; painting fills/strokes into that layer matches
// the layered path without Fills/Strokes blits.
// Non-SrcOver blend, frame clip blur, and masked groups stay layered.
// Stroke-only (fills_none) can go direct: empty fills are a no-op and
// strokes paint into Current. Large files need mid-walk GPU drains so
// release builds do not backlog a huge ops buffer in one Partial.
let can_render_directly = apply_to_current_surface
&& offset.is_none()
&& parent_shadows.is_none()
&& shape.blend_mode().0 == skia::BlendMode::SrcOver
&& !shape.has_frame_clip_layer_blur()
&& !matches!(shape.shape_type, Type::Group(g) if g.masked)
&& shape.blur.is_none()
&& shape.background_blur.is_none()
&& !has_inherited_blur
&& !shadows_need_layered
&& matches!(
shape.shape_type,
Type::Rect(_) | Type::Circle | Type::Path(_) | Type::Bool(_)
Type::Rect(_) | Type::Circle | Type::Path(_) | Type::Bool(_) | Type::Frame(_)
)
&& !(shape.fills.is_empty() && has_nested_fills)
&& !shape
.svg_attrs
.as_ref()
.is_some_and(|attrs| attrs.fill_none)
&& target_surface != SurfaceId::Export;
if can_render_directly {
let scale = self.get_scale();
let translation = self
.surfaces
.get_render_context_translation(self.render_area, scale);
@@ -1331,17 +1417,36 @@ impl RenderState {
canvas.translate(translation);
});
if let Some(clips) = clip_bounds.as_ref() {
self.apply_clip_stack_to_surfaces(clips, target_surface as u32, scale, None);
}
if !shape.transform.is_identity() {
let center = shape.center();
let mut matrix = shape.transform;
matrix.post_translate(center);
matrix.pre_translate(-center);
self.surfaces.apply_mut(target_surface as u32, |s| {
s.canvas().concat(&matrix);
});
}
fills::render(self, shape, &shape.fills, antialias, target_surface, None)?;
// Pass strokes in natural order; stroke merging handles top-most ordering internally.
let visible_strokes: Vec<&Stroke> = shape.visible_strokes().collect();
strokes::render(
self,
shape,
&visible_strokes,
Some(target_surface),
antialias,
outset,
)?;
// Clipped frames draw strokes in render_shape_exit over children.
let skip_strokes = matches!(shape.shape_type, Type::Frame(_)) && shape.clip_content;
if !skip_strokes {
// Pass strokes in natural order; stroke merging handles top-most ordering internally.
let visible_strokes: Vec<&Stroke> = shape.visible_strokes().collect();
strokes::render(
self,
shape,
&visible_strokes,
Some(target_surface),
antialias,
outset,
)?;
}
self.surfaces.apply_mut(target_surface as u32, |s| {
s.canvas().restore();
@@ -1352,62 +1457,24 @@ impl RenderState {
debug::render_debug_shape(self, Some(shape_selrect_bounds), None);
}
if needs_save {
self.surfaces.apply_mut(surface_ids, |s| {
s.canvas().restore();
});
}
return Ok(());
}
// Only save canvas state if we have clipping or transforms
// For simple shapes without clipping, skip expensive save/restore
let needs_save =
clip_bounds.is_some() || offset.is_some() || !shape.transform.is_identity();
if needs_save {
self.surfaces.apply_mut(surface_ids, |s| {
s.canvas().save();
});
}
// set clipping
if let Some(clips) = clip_bounds.as_ref() {
let scale = self.get_scale();
for (mut bounds, corners, transform) in clips.iter() {
self.surfaces.apply_mut(surface_ids, |s| {
s.canvas().concat(transform);
});
// Outset clip by ~0.5 to include edge pixels that
// aliased clip misclassifies as outside (causing artifacts).
let outset = 0.5 / scale;
bounds.outset((outset, outset));
// Hard clip edge (antialias = false) to avoid alpha seam when clipping
// semi-transparent content larger than the frame.
if let Some(corners) = corners {
let rrect = RRect::new_rect_radii(bounds, corners);
self.surfaces.apply_mut(surface_ids, |s| {
s.canvas().clip_rrect(rrect, skia::ClipOp::Intersect, false);
});
} else {
self.surfaces.apply_mut(surface_ids, |s| {
s.canvas().clip_rect(bounds, skia::ClipOp::Intersect, false);
});
}
// This renders a red line around clipped
// shapes (frames).
if self.options.is_debug_visible() {
let mut paint = skia::Paint::default();
paint.set_style(skia::PaintStyle::Stroke);
paint.set_color(skia::Color::from_argb(255, 255, 0, 0));
paint.set_stroke_width(4.);
self.surfaces
.canvas(fills_surface_id)
.draw_rect(bounds, &paint);
}
// Uncomment to debug the render_position_data
// if let Type::Text(text_content) = &shape.shape_type {
// text::render_position_data(self, fills_surface_id, &shape, text_content);
// }
self.surfaces.apply_mut(surface_ids, |s| {
s.canvas()
.concat(&transform.invert().unwrap_or(Matrix::default()));
});
}
self.apply_clip_stack_to_surfaces(clips, surface_ids, scale, Some(fills_surface_id));
}
// We don't want to change the value in the global state
@@ -1605,10 +1672,25 @@ impl RenderState {
);
}
} else {
let mut drop_shadows = shape.drop_shadow_paints();
let shape_scale = self.get_scale();
let mut drop_shadows = if skip_drop_shadows {
Vec::new()
} else {
shape
.drop_shadows_visible()
.filter(|s| s.is_perceptible_at_scale(shape_scale))
.map(|shadow| {
let mut paint = skia_safe::Paint::default();
paint.set_image_filter(shadow.get_drop_shadow_filter());
paint
})
.collect()
};
if let Some(inherited_shadows) = self.get_inherited_drop_shadows() {
drop_shadows.extend(inherited_shadows);
if !skip_drop_shadows {
if let Some(inherited_shadows) = self.get_inherited_drop_shadows() {
drop_shadows.extend(inherited_shadows);
}
}
let inner_shadows = shape.inner_shadow_paints();
@@ -1632,32 +1714,34 @@ impl RenderState {
.unzip();
if let Some(parent_shadows) = parent_shadows {
if !shape.has_visible_strokes() {
for shadow in parent_shadows {
text::render(
Some(self),
None,
if !skip_drop_shadows {
if !shape.has_visible_strokes() {
for shadow in parent_shadows {
text::render(
Some(self),
None,
&shape,
&mut paragraphs_with_shadows,
text_drop_shadows_surface_id.into(),
Some(&shadow),
blur_filter.as_ref(),
None,
None,
)?;
}
} else {
shadows::render_text_shadows(
self,
&shape,
&mut paragraphs_with_shadows,
&mut stroke_paragraphs_with_shadows_list,
text_drop_shadows_surface_id.into(),
Some(&shadow),
blur_filter.as_ref(),
None,
None,
&parent_shadows,
&blur_filter,
&stroke_kinds,
text_content,
)?;
}
} else {
shadows::render_text_shadows(
self,
&shape,
&mut paragraphs_with_shadows,
&mut stroke_paragraphs_with_shadows_list,
text_drop_shadows_surface_id.into(),
&parent_shadows,
&blur_filter,
&stroke_kinds,
text_content,
)?;
}
} else {
// 1. Text drop shadows
@@ -2357,6 +2441,7 @@ impl RenderState {
allow_stop: bool,
) -> Result<FrameType> {
performance::begin_measure!("continue_render_loop");
let timestamp = self.render_budget_start(timestamp);
let frame_type =
self.render_shape_tree_partial(base_object, tree, timestamp, allow_stop)?;
@@ -2376,9 +2461,9 @@ impl RenderState {
panic!("FrameType::None");
}
FrameType::Partial => {
// Partial frame: just flush GPU work. The display shows the last
// fully submitted frame; no need to copy or draw UI overlays here.
self.flush();
// Final soft drain for this yield (mid-walk also drains; see
// `drain_partial_gpu_soft`). Full still submits via present_frame.
Self::drain_partial_gpu_soft();
}
FrameType::Full => {
// A full-quality frame is now complete. Rebuild the per-shape crop
@@ -2404,6 +2489,7 @@ impl RenderState {
tree: ShapesPoolRef,
timestamp: i32,
) -> Result<FrameType> {
let timestamp = self.render_budget_start(timestamp);
self.render_shape_tree_partial(base_object, tree, timestamp, false)?;
// Same composition as `continue_render_loop` for full frames: snapshot only the
@@ -2538,6 +2624,24 @@ impl RenderState {
Ok((data.as_bytes().to_vec(), width, height))
}
/// Anchor the progressive render budget to wall-clock now when the
/// caller-provided timestamp is unusable:
/// - Frontend sometimes passes `0` (finalize-view / debounced zoom-end).
/// - rAF may hand a timestamp that is already older than the budget when
/// the handler runs late. Using that stamp made `should_stop_rendering`
/// yield after a few nodes with ~0ms of real work.
#[inline]
fn render_budget_start(&self, timestamp: i32) -> i32 {
let now = performance::get_time();
if timestamp <= 0 {
return now;
}
if now - timestamp > self.options.max_blocking_time_ms {
return now;
}
timestamp
}
#[inline]
pub fn should_stop_rendering(&self, iteration: i32, timestamp: i32) -> bool {
if iteration % self.options.node_batch_threshold != 0 {
@@ -2562,6 +2666,28 @@ impl RenderState {
true
}
/// Soft-drain GPU command buffers during progressive tile walks.
/// Release packs far more cheap Current draws (e.g. fills_none paths) into
/// one Partial than debug; flushing only at Partial end then stalls. Call
/// periodically so each flush stays small. Full present still submits.
#[inline]
fn drain_partial_gpu_soft() {
crate::get_gpu_state().context.flush(None);
}
/// Skip all drop/inner shadows in fast mode, or when even a large design-space
/// shadow would be subpixel. Otherwise filter per shadow via
/// [`Shadow::is_perceptible_at_scale_for`] (stricter for recursive shapes).
#[inline]
pub(crate) fn should_skip_drop_shadows(&self) -> bool {
if self.options.is_fast_mode() {
return true;
}
let scale = self.get_scale();
scale * crate::shapes::DROP_SHADOW_LARGE_DESIGN_PX
< crate::shapes::DROP_SHADOW_MIN_DEVICE_PX
}
#[inline]
fn clip_target_surface_to_stack(
&mut self,
@@ -3097,6 +3223,7 @@ impl RenderState {
/// Renders element drop shadows to DropShadows surface and composites to Current.
/// Used for both normal shadow rendering and pre-layer rendering (frame_clip_layer_blur).
/// Returns `true` when at least one visible drop shadow was composited.
#[allow(clippy::too_many_arguments)]
fn render_element_drop_shadows_and_composite(
&mut self,
@@ -3107,14 +3234,32 @@ impl RenderState {
scale: f32,
node_render_state: &NodeRenderState,
target_surface: SurfaceId,
) -> Result<()> {
) -> Result<bool> {
// Avoid a blank DropShadows→Current blit + clear when nothing will paint
// (no shadows, fast/overview skip, or all footprints subpixel). Callers
// must still touch DropShadows once per tile when this returns false
// (see `drop_shadows_ops_warmed`).
if self.should_skip_drop_shadows()
|| !element
.drop_shadows_visible()
.any(|s| s.is_perceptible_at_scale_for(scale, element.is_recursive()))
{
return Ok(false);
}
let element_extrect = extrect.get_or_insert_with(|| element.extrect(tree, scale));
let inherited_layer_blur = match element.shape_type {
Type::Frame(_) | Type::Group(_) => element.blur,
_ => None,
};
let recursive = element.is_recursive();
let mut rendered_any = false;
for shadow in element.drop_shadows_visible() {
if !shadow.is_perceptible_at_scale_for(scale, recursive) {
continue;
}
rendered_any = true;
let paint = skia::Paint::default();
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint);
self.surfaces
@@ -3206,6 +3351,10 @@ impl RenderState {
self.surfaces.canvas(SurfaceId::DropShadows).restore();
}
if !rendered_any {
return Ok(false);
}
if let Some(clips) = clip_bounds.as_ref() {
let antialias = !self.options.is_fast_mode()
&& element.should_use_antialias(scale, self.options.antialias_threshold);
@@ -3222,7 +3371,7 @@ impl RenderState {
self.surfaces
.canvas(SurfaceId::DropShadows)
.clear(skia::Color::TRANSPARENT);
Ok(())
Ok(true)
}
pub fn render_shape_tree_partial_uncached(
@@ -3457,13 +3606,15 @@ impl RenderState {
// the layer blur (which would make it more diffused than without clipping)
let shadow_before_layer = !node_render_state.is_root()
&& self.focus_mode.is_active()
&& !self.options.is_fast_mode()
&& !self.should_skip_drop_shadows()
&& !matches!(element.shape_type, Type::Text(_))
&& Self::frame_clip_layer_blur(element).is_some()
&& element.drop_shadows_visible().next().is_some();
&& element
.drop_shadows_visible()
.any(|s| s.is_perceptible_at_scale_for(scale, element.is_recursive()));
if shadow_before_layer {
self.render_element_drop_shadows_and_composite(
if shadow_before_layer
&& self.render_element_drop_shadows_and_composite(
element,
tree,
&mut extrect,
@@ -3471,7 +3622,9 @@ impl RenderState {
scale,
&node_render_state,
target_surface,
)?;
)?
{
self.drop_shadows_ops_warmed = true;
}
// Render background blur BEFORE save_layer so it modifies
@@ -3484,8 +3637,8 @@ impl RenderState {
}
if !node_render_state.is_root() && self.focus_mode.is_active() {
// Skip expensive drop shadow rendering in fast mode (during pan/zoom).
let skip_shadows = self.options.is_fast_mode();
// Skip expensive drop shadows in fast mode and at overview zooms.
let skip_shadows = self.should_skip_drop_shadows();
// Skip shadow block when already rendered before the layer (frame_clip_layer_blur)
let shadows_already_rendered = Self::frame_clip_layer_blur(element).is_some();
@@ -3494,8 +3647,7 @@ impl RenderState {
if !skip_shadows
&& !shadows_already_rendered
&& !matches!(element.shape_type, Type::Text(_))
{
self.render_element_drop_shadows_and_composite(
&& self.render_element_drop_shadows_and_composite(
element,
tree,
&mut extrect,
@@ -3503,11 +3655,26 @@ impl RenderState {
scale,
&node_render_state,
target_surface,
)?;
} else {
// This is necessary or the later flush_and_submit will be very slow
)?
{
// Real shadow composite already clears DropShadows.
self.drop_shadows_ops_warmed = true;
}
if !self.drop_shadows_ops_warmed {
// Touch DropShadows→Current once per tile when no shape has
// composited real shadows yet. Omitting this entirely made
// flush_and_submit very slow (ops-task ordering); repeating
// it per shape was waste.
self.surfaces.draw_into(
SurfaceId::DropShadows,
target_surface,
Some(&skia::Paint::default()),
);
self.surfaces
.draw_into(SurfaceId::DropShadows, target_surface, None);
.canvas(SurfaceId::DropShadows)
.clear(skia::Color::TRANSPARENT);
self.drop_shadows_ops_warmed = true;
}
// For frames without clip_content, inner strokes must render after children in
@@ -3614,6 +3781,12 @@ impl RenderState {
if allow_stop && self.should_stop_rendering(iteration, timestamp) {
return Ok((is_empty, true));
}
// Keep GPU ops buffers bounded when many shapes paint cheaply to
// Current (release packs far more per Partial than debug).
let drain_every = self.options.partial_gpu_drain_every_n;
if allow_stop && drain_every > 0 && iteration > 0 && iteration % drain_every == 0 {
Self::drain_partial_gpu_soft();
}
iteration += 1;
}
@@ -3709,6 +3882,7 @@ impl RenderState {
// empty tile.
self.current_tile_had_shapes = false;
self.tile_atlas_flushed = false;
self.drop_shadows_ops_warmed = false;
let viewer_masked_pass = self.viewer_masked_pass();
+8
View File
@@ -11,6 +11,10 @@ const VIEWPORT_INTEREST_AREA_THRESHOLD: i32 = 1;
const MIN_DPR_VIEWPORT_INTEREST_AREA_THRESHOLD: i32 = 2;
const MAX_BLOCKING_TIME_MS: i32 = 32;
const NODE_BATCH_THRESHOLD: i32 = 3;
/// Soft-drain GPU every N walker nodes on progressive Partials. Keeps ops
/// buffers bounded when many shapes paint cheaply to Current (release packs
/// far more per budget than debug).
const PARTIAL_GPU_DRAIN_EVERY_N: i32 = 64;
const BLUR_DOWNSCALE_THRESHOLD: f32 = 8.0;
const ANTIALIAS_THRESHOLD: f32 = 7.0;
#[derive(Debug, Copy, Clone, PartialEq)]
@@ -29,6 +33,9 @@ pub struct RenderOptions {
pub dpr_viewport_interest_area_threshold: i32,
pub max_blocking_time_ms: i32,
pub node_batch_threshold: i32,
/// Soft-flush GPU every N nodes during progressive tile walks (see
/// [`PARTIAL_GPU_DRAIN_EVERY_N`]).
pub partial_gpu_drain_every_n: i32,
pub blur_downscale_threshold: f32,
pub capture_frames: i32,
}
@@ -45,6 +52,7 @@ impl Default for RenderOptions {
dpr_viewport_interest_area_threshold: VIEWPORT_INTEREST_AREA_THRESHOLD,
max_blocking_time_ms: MAX_BLOCKING_TIME_MS,
node_batch_threshold: NODE_BATCH_THRESHOLD,
partial_gpu_drain_every_n: PARTIAL_GPU_DRAIN_EVERY_N,
blur_downscale_threshold: BLUR_DOWNSCALE_THRESHOLD,
capture_frames: 0,
}
+27 -15
View File
@@ -13,10 +13,16 @@ pub fn render_fill_inner_shadows(
antialias: bool,
surface_id: SurfaceId,
) {
if shape.has_fills() {
for shadow in shape.inner_shadows_visible() {
render_fill_inner_shadow(render_state, shape, shadow, antialias, surface_id);
if !shape.has_fills() || render_state.should_skip_drop_shadows() {
return;
}
let scale = render_state.get_scale();
let recursive = shape.is_recursive();
for shadow in shape.inner_shadows_visible() {
if !shadow.is_perceptible_at_scale_for(scale, recursive) {
continue;
}
render_fill_inner_shadow(render_state, shape, shadow, antialias, surface_id);
}
}
@@ -38,19 +44,25 @@ pub fn render_stroke_inner_shadows(
antialias: bool,
surface_id: SurfaceId,
) -> Result<()> {
if !shape.has_fills() {
for shadow in shape.inner_shadows_visible() {
let filter = shadow.get_inner_shadow_filter();
strokes::render_single(
render_state,
shape,
stroke,
Some(surface_id),
filter.as_ref(),
antialias,
None, // Inner shadows don't use spread
)?;
if shape.has_fills() || render_state.should_skip_drop_shadows() {
return Ok(());
}
let scale = render_state.get_scale();
let recursive = shape.is_recursive();
for shadow in shape.inner_shadows_visible() {
if !shadow.is_perceptible_at_scale_for(scale, recursive) {
continue;
}
let filter = shadow.get_inner_shadow_filter();
strokes::render_single(
render_state,
shape,
stroke,
Some(surface_id),
filter.as_ref(),
antialias,
None, // Inner shadows don't use spread
)?;
}
Ok(())
}
+12 -2
View File
@@ -561,6 +561,13 @@ fn draw_image_stroke_in_container(
surface_id: SurfaceId,
) -> Result<()> {
let scale = render_state.get_scale();
let lod_stroke;
let stroke = if matches!(shape.shape_type, Type::Path(_) | Type::Bool(_)) {
lod_stroke = stroke.path_lod_at_scale(shape.is_open(), scale);
&lod_stroke
} else {
stroke
};
let Some(image) = get_resources().images.get(&image_fill.id()) else {
return Ok(());
};
@@ -938,12 +945,13 @@ fn render_merged(
shape_type @ (Type::Path(_) | Type::Bool(_)) => {
if let Some(path) = shape_type.path() {
let is_open = path.is_open();
let lod_stroke = representative.path_lod_at_scale(is_open, scale);
let mut paint =
representative.to_stroked_paint(is_open, &selrect, svg_attrs, antialias);
lod_stroke.to_stroked_paint(is_open, &selrect, svg_attrs, antialias);
paint.set_shader(merged.shader());
draw_stroke_on_path(
canvas,
representative,
&lod_stroke,
path,
&paint,
path_transform.as_ref(),
@@ -1097,6 +1105,8 @@ fn render_single_internal(
shape_type @ (Type::Path(_) | Type::Bool(_)) => {
if let Some(path) = shape_type.path() {
let is_open = path.is_open();
let lod_stroke = stroke.path_lod_at_scale(is_open, scale);
let stroke = &lod_stroke;
let mut paint =
stroke.to_stroked_paint(is_open, &selrect, svg_attrs, antialias);
// Apply outset by increasing stroke width
+71 -56
View File
@@ -27,6 +27,29 @@ const TILE_DRAWABLE_RECT: IRect = IRect {
};
const DOC_ATLAS_MAX_DIM: i32 = 4096;
/// GPU→GPU copy of `src` from `from` into `dst` on `to_canvas`, without
/// `image_snapshot` (avoids per-tile sync stalls on WebGL).
fn draw_surface_src_rect_to_dst(
from: &mut skia::Surface,
to_canvas: &skia::Canvas,
src: skia::Rect,
dst: skia::Rect,
sampling: skia::SamplingOptions,
) {
if src.is_empty() || dst.is_empty() {
return;
}
to_canvas.save();
to_canvas.clip_rect(dst, None, true);
let sx = dst.width() / src.width();
let sy = dst.height() / src.height();
to_canvas.translate((dst.left, dst.top));
to_canvas.scale((sx, sy));
to_canvas.translate((-src.left, -src.top));
from.draw(to_canvas, (0.0, 0.0), sampling, None);
to_canvas.restore();
}
pub fn get_cache_size(viewbox: &Viewbox, interest: i32) -> skia::ISize {
// First we retrieve the extended area of the viewport that we could render.
let TileRect(isx, isy, iex, iey) =
@@ -238,17 +261,20 @@ impl DocAtlas {
Ok(())
}
fn blit_tile_image_into_atlas(
/// Blit a Current-surface drawable rect into the doc atlas without
/// `image_snapshot` (GPU→GPU draw; avoids per-tile sync stalls).
fn blit_current_drawable_into_atlas(
&mut self,
gpu_state: &mut GpuState,
tile_image: &skia::Image,
current: &mut skia::Surface,
drawable_src: skia::Rect,
tile_doc_rect: skia::Rect,
sampling: skia::SamplingOptions,
) -> Result<()> {
if tile_doc_rect.is_empty() {
if tile_doc_rect.is_empty() || drawable_src.is_empty() {
return Ok(());
}
// Clamp to document bounds (if any) and compute a matching source-rect in tile pixels.
let mut clipped_doc_rect = tile_doc_rect;
if let Some(bounds) = self.doc_bounds {
if !clipped_doc_rect.intersect(bounds) {
@@ -261,7 +287,6 @@ impl DocAtlas {
self.ensure_atlas_contains(gpu_state, clipped_doc_rect)?;
// Destination is document-space rect mapped into atlas pixel coords.
let dst = skia::Rect::from_xywh(
(clipped_doc_rect.left - self.origin.x) * self.scale,
(clipped_doc_rect.top - self.origin.y) * self.scale,
@@ -269,24 +294,18 @@ impl DocAtlas {
clipped_doc_rect.height() * self.scale,
);
// Compute source rect in tile_image pixel coordinates.
let img_w = tile_image.width() as f32;
let img_h = tile_image.height() as f32;
let tw = tile_doc_rect.width().max(1.0);
let th = tile_doc_rect.height().max(1.0);
let sx = ((clipped_doc_rect.left - tile_doc_rect.left) / tw) * img_w;
let sy = ((clipped_doc_rect.top - tile_doc_rect.top) / th) * img_h;
let sw = (clipped_doc_rect.width() / tw) * img_w;
let sh = (clipped_doc_rect.height() / th) * img_h;
let src = skia::Rect::from_xywh(sx, sy, sw, sh);
self.surface.canvas().draw_image_rect(
tile_image,
Some((&src, skia::canvas::SrcRectConstraint::Fast)),
dst,
&skia::Paint::default(),
let dw = drawable_src.width();
let dh = drawable_src.height();
let src = skia::Rect::from_xywh(
drawable_src.left + ((clipped_doc_rect.left - tile_doc_rect.left) / tw) * dw,
drawable_src.top + ((clipped_doc_rect.top - tile_doc_rect.top) / th) * dh,
(clipped_doc_rect.width() / tw) * dw,
(clipped_doc_rect.height() / th) * dh,
);
draw_surface_src_rect_to_dst(current, self.surface.canvas(), src, dst, sampling);
Ok(())
}
@@ -827,28 +846,24 @@ impl Surfaces {
pub fn update_render_context(&mut self, render_area: skia::Rect, scale: f32) {
let translation = self.get_render_context_translation(render_area, scale);
// When context changes (zoom/pan/tile), clear all render surfaces first
// to remove any residual content from previous tiles, then mark as dirty
// so they get redrawn with new transformations
// When context changes (zoom/pan/tile), clear intermediate surfaces so
// residual content from the previous tile cannot leak into the next.
let surface_ids = SurfaceId::Fills as u32
| SurfaceId::Strokes as u32
| SurfaceId::InnerShadows as u32
| SurfaceId::TextDropShadows as u32
| SurfaceId::DropShadows as u32;
// Clear surfaces before updating transformations to remove residual content
self.apply_mut(surface_ids, |s| {
s.canvas().clear(skia::Color::TRANSPARENT);
});
// Mark all render surfaces as dirty so they get redrawn
self.mark_dirty(SurfaceId::Fills);
self.mark_dirty(SurfaceId::Strokes);
self.mark_dirty(SurfaceId::InnerShadows);
self.mark_dirty(SurfaceId::TextDropShadows);
self.mark_dirty(SurfaceId::DropShadows);
// Dirty means "has content to composite", not "transform was updated".
// After a clear the surfaces are empty; leaving them dirty made the
// first `draw_shape_surface_stack_into` on each tile blit empty
// Fills/Strokes/shadows into Current (useless GPU ops / ops-task noise).
self.clear_dirty(surface_ids);
// Update transformations
self.apply_mut(surface_ids, |s| {
let canvas = s.canvas();
canvas.reset_matrix();
@@ -1198,34 +1213,34 @@ impl Surfaces {
tile_doc_rect: skia::Rect,
) {
let gpu_state = get_gpu_state();
let rect = TILE_DRAWABLE_RECT;
let src = skia::Rect::from(TILE_DRAWABLE_RECT);
let sampling = self.sampling_options;
let tile_image_opt = self.current.image_snapshot_with_bounds(rect);
if let Some(tile_image) = tile_image_opt {
if !skip_cache_surface {
// Draw to cache surface for render_from_cache
self.cache.canvas().draw_image_rect(
&tile_image,
None,
tile_rect,
&skia::Paint::default(),
);
}
// DocAtlas + tile atlas via Surface::draw (no image_snapshot sync).
let _ = self.atlas.blit_current_drawable_into_atlas(
gpu_state,
&mut self.current,
src,
tile_doc_rect,
sampling,
);
self.atlas.tile_doc_rects.insert(*tile, tile_doc_rect);
// Incrementally update persistent 1:1 atlas in document space.
// `tile_doc_rect` is in world/document coordinates (1 unit == 1 px at 100%).
let _ = self
.atlas
.blit_tile_image_into_atlas(gpu_state, &tile_image, tile_doc_rect);
self.atlas.tile_doc_rects.insert(*tile, tile_doc_rect);
let tile_ref = self.tiles.add(tile_viewbox, tile);
let dst = tile_ref.rect;
let mut current = self.current.clone();
draw_surface_src_rect_to_dst(&mut current, self.tile_atlas.canvas(), src, dst, sampling);
// Draws current tile into tile atlas
let tile_ref = self.tiles.add(tile_viewbox, tile);
self.tile_atlas.canvas().draw_image_rect(
&tile_image,
None,
tile_ref.rect,
&skia::Paint::default(),
if !skip_cache_surface {
// Optional legacy Cache surface fill (debug). Pan/zoom preview
// uses DocAtlas + tile-atlas textures via render_from_cache.
let mut current = self.current.clone();
draw_surface_src_rect_to_dst(
&mut current,
self.cache.canvas(),
src,
*tile_rect,
sampling,
);
}
}
+59 -17
View File
@@ -3,8 +3,8 @@ use crate::{
error::Result,
math::Rect,
shapes::{
calculate_text_layout_data, set_paint_fill, ParagraphBuilderGroup, ParagraphLayout, Stroke,
StrokeKind, TextContent,
add_text_with_tabs, calculate_text_layout_data, set_paint_fill, ParagraphBuilderGroup,
ParagraphLayout, Stroke, StrokeKind, TextContent,
},
utils::{get_fallback_fonts, get_font_collection},
};
@@ -55,7 +55,7 @@ pub fn stroke_paragraph_builder_group_from_text(
paragraph.line_height(),
);
builder.push_style(&stroke_style);
builder.add_text(&text);
add_text_with_tabs(builder, &text, span.font_size);
}
}
@@ -329,15 +329,21 @@ fn render_text_on_canvas(
layer_opacity: Option<f32>,
overlay_emoji: bool,
) {
let layer_bounds = shape.layer_bounds();
if let Some(blur_filter) = blur {
let mut blur_paint = Paint::default();
blur_paint.set_image_filter(blur_filter.clone());
let blur_layer = SaveLayerRec::default().paint(&blur_paint);
let blur_layer = SaveLayerRec::default()
.bounds(&layer_bounds)
.paint(&blur_paint);
canvas.save_layer(&blur_layer);
}
if let Some(shadow_paint) = shadow {
let layer_rec = SaveLayerRec::default().paint(shadow_paint);
let layer_rec = SaveLayerRec::default()
.bounds(&layer_bounds)
.paint(shadow_paint);
canvas.save_layer(&layer_rec);
draw_text(
canvas,
@@ -351,7 +357,9 @@ fn render_text_on_canvas(
if let Some(erode) = skia_safe::image_filters::erode((eps, eps), None, None) {
let mut layer_paint = Paint::default();
layer_paint.set_image_filter(erode);
let layer_rec = SaveLayerRec::default().paint(&layer_paint);
let layer_rec = SaveLayerRec::default()
.bounds(&layer_bounds)
.paint(&layer_paint);
canvas.save_layer(&layer_rec);
draw_text(
canvas,
@@ -582,7 +590,10 @@ fn draw_decoration_stroke(
skia::BlendMode::SrcOut
};
canvas.save_layer(&SaveLayerRec::default());
let outset = stroke_paint.stroke_width().max(0.0);
let layer_bounds = bar.with_outset((outset, outset));
canvas.save_layer(&SaveLayerRec::default().bounds(&layer_bounds));
let mut mask_paint = Paint::default();
mask_paint.set_color(skia::Color::BLACK);
mask_paint.set_anti_alias(true);
@@ -590,7 +601,11 @@ fn draw_decoration_stroke(
let mut blend_paint = Paint::default();
blend_paint.set_blend_mode(blend);
canvas.save_layer(&SaveLayerRec::default().paint(&blend_paint));
canvas.save_layer(
&SaveLayerRec::default()
.bounds(&layer_bounds)
.paint(&blend_paint),
);
canvas.draw_rect(bar, stroke_paint);
canvas.restore();
canvas.restore();
@@ -705,7 +720,12 @@ pub fn render_emoji_overlay(
if let Some(blur_filter) = blur {
let mut blur_paint = Paint::default();
blur_paint.set_image_filter(blur_filter.clone());
canvas.save_layer(&SaveLayerRec::default().paint(&blur_paint));
let layer_bounds = shape.layer_bounds();
canvas.save_layer(
&SaveLayerRec::default()
.bounds(&layer_bounds)
.paint(&blur_paint),
);
}
for (emoji_para, deco_para) in emoji_layout
@@ -728,13 +748,17 @@ fn draw_text(
layer_opacity: Option<f32>,
overlay_emoji: bool,
) {
let layer_bounds = shape.layer_bounds();
if let Some(opacity) = layer_opacity {
let mut opacity_paint = Paint::default();
opacity_paint.set_alpha_f(opacity);
let layer_rec = SaveLayerRec::default().paint(&opacity_paint);
let layer_rec = SaveLayerRec::default()
.bounds(&layer_bounds)
.paint(&opacity_paint);
canvas.save_layer(&layer_rec);
} else {
canvas.save_layer(&SaveLayerRec::default());
canvas.save_layer(&SaveLayerRec::default().bounds(&layer_bounds));
}
paint_text_with_emoji_overlay(canvas, shape, paragraph_builder_groups, overlay_emoji);
@@ -759,27 +783,41 @@ fn render_masked_stroke_on_canvas(
blur: Option<&ImageFilter>,
layer_opacity: Option<f32>,
) {
let layer_bounds = shape.layer_bounds();
if let Some(blur_filter) = blur {
let mut blur_paint = Paint::default();
blur_paint.set_image_filter(blur_filter.clone());
canvas.save_layer(&SaveLayerRec::default().paint(&blur_paint));
canvas.save_layer(
&SaveLayerRec::default()
.bounds(&layer_bounds)
.paint(&blur_paint),
);
}
if let Some(opacity) = layer_opacity {
let mut opacity_paint = Paint::default();
opacity_paint.set_alpha_f(opacity);
canvas.save_layer(&SaveLayerRec::default().paint(&opacity_paint));
canvas.save_layer(
&SaveLayerRec::default()
.bounds(&layer_bounds)
.paint(&opacity_paint),
);
}
canvas.save_layer(&SaveLayerRec::default());
canvas.save_layer(&SaveLayerRec::default().bounds(&layer_bounds));
canvas.save_layer(&SaveLayerRec::default());
canvas.save_layer(&SaveLayerRec::default().bounds(&layer_bounds));
paint_text(canvas, shape, mask_builders);
let mut stroke_paint = Paint::default();
stroke_paint.set_blend_mode(stroke_mask_blend);
canvas.save_layer(&SaveLayerRec::default().paint(&stroke_paint));
canvas.save_layer(
&SaveLayerRec::default()
.bounds(&layer_bounds)
.paint(&stroke_paint),
);
paint_text(canvas, shape, stroke_builders);
@@ -789,7 +827,11 @@ fn render_masked_stroke_on_canvas(
if let Some(fill_builders) = fill_builders {
let mut dst_over_paint = Paint::default();
dst_over_paint.set_blend_mode(skia::BlendMode::DstOver);
canvas.save_layer(&SaveLayerRec::default().paint(&dst_over_paint));
canvas.save_layer(
&SaveLayerRec::default()
.bounds(&layer_bounds)
.paint(&dst_over_paint),
);
paint_text(canvas, shape, fill_builders);
+8 -4
View File
@@ -156,12 +156,13 @@ fn calculate_cursor_rect(
.map(|span| span.text.chars().count())
.sum();
// Skia ranges are UTF-16 code units, not characters.
let (cursor_x, cursor_y, cursor_width, cursor_height) = if para_char_count == 0 {
// Empty paragraph - use default height
(0.0, 0.0, 1.0, laid_out_para.height())
} else if char_pos == 0 {
let rects = laid_out_para.get_rects_for_range(
0..1,
0..para.char_utf16_len_at(0),
RectHeightStyle::Max,
RectWidthStyle::Tight,
);
@@ -172,8 +173,10 @@ fn calculate_cursor_rect(
(0.0, 0.0, 1.0, laid_out_para.height())
}
} else if char_pos >= para_char_count {
let last_char = para_char_count.saturating_sub(1);
let last_start = para.char_offset_to_utf16(last_char);
let rects = laid_out_para.get_rects_for_range(
para_char_count.saturating_sub(1)..para_char_count,
last_start..last_start + para.char_utf16_len_at(last_char),
RectHeightStyle::Max,
RectWidthStyle::Tight,
);
@@ -189,8 +192,9 @@ fn calculate_cursor_rect(
)
}
} else {
let utf16_pos = para.char_offset_to_utf16(char_pos);
let rects = laid_out_para.get_rects_for_range(
char_pos..char_pos + 1,
utf16_pos..utf16_pos + para.char_utf16_len_at(char_pos),
RectHeightStyle::Max,
RectWidthStyle::Tight,
);
@@ -264,7 +268,7 @@ fn calculate_selection_rects(
if range_start < range_end {
use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle};
let text_boxes = laid_out_para.get_rects_for_range(
range_start..range_end,
para.char_offset_to_utf16(range_start)..para.char_offset_to_utf16(range_end),
RectHeightStyle::Max,
RectWidthStyle::Tight,
);
Loaded 100 of 112 files, more files were not shown because too many files have changed in this diff. Show more