mirror of
https://github.com/penpot/penpot.git
synced 2026-09-08 11:54:36 -04:00
Compare commits
37
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b2991e13b | ||
|
|
b5bec4f983 | ||
|
|
30bc2a4bc3 | ||
|
|
43b12bc4b9 | ||
|
|
e1c51442cd | ||
|
|
88697794ce | ||
|
|
38b990ef90 | ||
|
|
a76401596e | ||
|
|
de8d8ca401 | ||
|
|
314a2a245f | ||
|
|
614d619173 | ||
|
|
2392015c63 | ||
|
|
11fc090bc4 | ||
|
|
10a2c19f92 | ||
|
|
4b413299c2 | ||
|
|
31c9ab4701 | ||
|
|
8b64b0f84f | ||
|
|
649f4bebef | ||
|
|
5b26913cd3 | ||
|
|
36e76da26c | ||
|
|
35bdcde183 | ||
|
|
6df045b194 | ||
|
|
1b26b69b25 | ||
|
|
6f2bfb617c | ||
|
|
34702fd46b | ||
|
|
648c8e2152 | ||
|
|
7ae57a035f | ||
|
|
14a6ea5c52 | ||
|
|
ca29f734c7 | ||
|
|
0811b1cda6 | ||
|
|
6e843faba3 | ||
|
|
c6c8a38544 | ||
|
|
0fed63eeb3 | ||
|
|
0835c51e11 | ||
|
|
501284169c | ||
|
|
8e739e01e1 | ||
|
|
05fa07f911 |
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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/
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Executable
+444
@@ -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.`);
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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)})))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}))))))))
|
||||
@@ -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
@@ -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
|
||||
|
||||
File renamed without changes.
@@ -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))
|
||||
|
||||
@@ -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"))))
|
||||
|
||||
|
||||
|
||||
+153
-7
@@ -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.
|
||||
+8
-8
@@ -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))
|
||||
|
||||
+8
-12
@@ -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))))
|
||||
+2
-2
@@ -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"
|
||||
+1
-1
@@ -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]
|
||||
+3
-3
@@ -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`
|
||||
+5
-5
@@ -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
-1
@@ -1,4 +1,4 @@
|
||||
(ns app.render-wasm.serializers.color
|
||||
(ns app.common.render-wasm.serializers.color
|
||||
(:require
|
||||
[app.common.math :as mth]))
|
||||
|
||||
+15
-12
@@ -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)
|
||||
+16
-39
@@ -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)))
|
||||
|
||||
@@ -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
|
||||
|
||||
+3
-3
@@ -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)))))))
|
||||
@@ -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)"; \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)))))
|
||||
|
||||
@@ -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))))
|
||||
@@ -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))))
|
||||
@@ -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))
|
||||
@@ -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)))
|
||||
@@ -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
@@ -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"}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -30,7 +30,7 @@ mkdir -p target/dist;
|
||||
|
||||
# Build render wasm binary
|
||||
pushd ../render-wasm;
|
||||
./build
|
||||
./build frontend
|
||||
popd
|
||||
|
||||
pushd ../mcp;
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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}))))))
|
||||
@@ -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]
|
||||
|
||||
@@ -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]))
|
||||
|
||||
|
||||
@@ -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 **2–3 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 ~2–3 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 (2–3 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 1–2)
|
||||
|
||||
#### 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 3–5)
|
||||
|
||||
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., 1–3 seconds between dashboard navigation, 3–5 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):**
|
||||
- 2–3 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 ~2–3 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 ~2–3 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 7–8)
|
||||
|
||||
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 9–10)
|
||||
|
||||
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 1–5 complete. Regression guard implemented (relative comparison). Grafana dashboards deferred.
|
||||
|
||||
@@ -42,3 +42,7 @@ opt-level = 3
|
||||
lto = "fat"
|
||||
strip = true
|
||||
codegen-units = 1
|
||||
|
||||
[profile.size]
|
||||
inherits = "release"
|
||||
opt-level = "z"
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
+65
-15
@@ -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
@@ -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 $?;
|
||||
|
||||
|
||||
@@ -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
@@ -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"}
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
Reference in new issue
Block a user