Merge remote-tracking branch 'origin/staging' into develop

This commit is contained in:
Alejandro Alonso 2026-03-27 10:06:54 +01:00
commit 9c1f2e9af8
75 changed files with 699 additions and 17 deletions

View File

@ -165,7 +165,8 @@
:nitrate
:mcp
:background-blur})
:background-blur
:stroke-path})
(def all-flags
(set/union email login varia))

View File

@ -5,6 +5,9 @@ EMSDK_QUIET=1 . /opt/emsdk/emsdk_env.sh;
export PATH="/home/penpot/.cargo/bin:/opt/jdk/bin:/opt/utils/bin:/opt/clojure/bin:/opt/node/bin:/opt/imagick/bin:/opt/cargo/bin:$PATH"
export CARGO_HOME="/home/penpot/.cargo"
export PENPOT_MCP_PLUGIN_SERVER_HOST=0.0.0.0
export PENPOT_MCP_SERVER_HOST=0.0.0.0
alias l='ls --color -GFlh'
alias ll='ls --color -GFlh'
alias rm='rm -rf'

View File

@ -5,7 +5,8 @@ ENV LANG=en_US.UTF-8 \
LC_ALL=en_US.UTF-8 \
NODE_VERSION=v22.21.1 \
DEBIAN_FRONTEND=noninteractive \
PATH=/opt/node/bin:$PATH
PATH=/opt/node/bin:$PATH \
PENPOT_MCP_SERVER_HOST=0.0.0.0
RUN set -ex; \
useradd -U -M -u 1001 -s /bin/false -d /opt/penpot penpot; \

View File

@ -0,0 +1,64 @@
{
"~:features": {
"~#set": [
"fdata/path-data",
"fdata/objects-map",
"fdata/shape-data-type",
"render-wasm/v1",
"layout/grid",
"styles/v2",
"components/v2"
]
},
"~:team-id": "~ud7430f09-4f59-8049-8007-6277bb7586f6",
"~: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": "stroke_to_path",
"~:revn": 34,
"~:modified-at": "~m1774513054500",
"~:vern": 0,
"~:id": "~ud9a19a61-ed94-818f-8007-c590e153a27f",
"~:is-shared": false,
"~:version": 67,
"~:project-id": "~ud7430f09-4f59-8049-8007-6277bb765abd",
"~:created-at": "~m1774512709966",
"~:backend": "legacy-db",
"~:data": {
"~:pages": [
"~ud9a19a61-ed94-818f-8007-c590e153a280"
],
"~:pages-index": {
"~ud9a19a61-ed94-818f-8007-c590e153a280": {
"~:objects": {
"~#penpot/objects-map/v2": {
"~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]],[\"^:\",[\"^ \",\"~:x\",0.01,\"~:y\",0.0]],[\"^:\",[\"^ \",\"~:x\",0.01,\"~:y\",0.01]],[\"^:\",[\"^ \",\"~:x\",0.0,\"~:y\",0.01]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^3\",[\"^ \",\"~: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,\"^6\",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,\"^H\",0.01,\"~:flip-y\",null,\"~:shapes\",[\"~u25475404-d141-8046-8007-c590e43ccb53\",\"~u25475404-d141-8046-8007-c590e8c99f60\",\"~u25475404-d141-8046-8007-c590eac93f27\",\"~u25475404-d141-8046-8007-c5911aba8cc3\",\"~u25475404-d141-8046-8007-c5912a27368c\",\"~u25475404-d141-8046-8007-c5912e12bb56\",\"~u334565ad-19c1-804c-8007-c591d0ab5634\",\"~u334565ad-19c1-804c-8007-c591fecf5ce9\",\"~u334565ad-19c1-804c-8007-c59204711ada\"]]]",
"~u25475404-d141-8046-8007-c590e43ccb53": "[\"~#shape\",[\"^ \",\"~:y\",343,\"~: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\",\"~:hide-in-viewer\",false,\"~:name\",\"rectangle-inner\",\"~:width\",260,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",482,\"~:y\",343]],[\"^<\",[\"^ \",\"~:x\",742,\"~:y\",343]],[\"^<\",[\"^ \",\"~:x\",742,\"~:y\",513]],[\"^<\",[\"^ \",\"~:x\",482,\"~:y\",513]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u25475404-d141-8046-8007-c590e43ccb53\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-alignment\",\"~:inner\",\"~:stroke-style\",\"~:solid\",\"~:stroke-color\",\"#000000\",\"~:stroke-opacity\",1,\"~:stroke-width\",20]],\"~:x\",482,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",482,\"~:y\",343,\"^8\",260,\"~:height\",170,\"~:x1\",482,\"~:y1\",343,\"~:x2\",742,\"~:y2\",513]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#B1B2B5\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^Q\",170,\"~:flip-y\",null]]",
"~u334565ad-19c1-804c-8007-c591d0ab5634": "[\"~#shape\",[\"^ \",\"~:y\",null,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:content\",[\"~#penpot/path-data\",\"~bAQAAAAAAAAAAAAAAAAAAAAAAAACjiDFEYtF3RAMAAABuujBE8KN2RHuFL0QVwnZEe4UvRBXCdkQCAAAAAAAAAAAAAAAAAAAAAAAAAEaEAkSSwHZEAwAAAEaEAkSSwHZEd0QBRHKmdkShdwBEBNN3RAMAAADUVv9Dtv94RIUiAETlKXpEhSIAROUpekQCAAAAAAAAAAAAAAAAAAAAAAAAAEB7CkTT5YlEAwAAAG08DkSaq49EQBkjRC67j0SGjydE0+WJRAIAAAAAAAAAAAAAAAAAAAAAAAAAXdsxROotekQDAAAAXdsxROotekS3VjJEtv94RKOIMURi0XdEBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==\"],\"~:name\",\"path-inner\",\"~:width\",null,\"~:type\",\"~:path\",\"~:svg-attrs\",[\"^ \",\"~:className\",\"fills\"],\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",512.0000270184611,\"~:y\",987.0000230312763]],[\"^=\",[\"^ \",\"~:x\",711.9999730908373,\"~:y\",987.0000230312763]],[\"^=\",[\"^ \",\"~:x\",711.9999730908373,\"~:y\",1137.9999747273034]],[\"^=\",[\"^ \",\"~:x\",512.0000270184611,\"~:y\",1137.9999747273034]]],\"~:proportion-lock\",true,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:svg-transform\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:id\",\"~u334565ad-19c1-804c-8007-c591d0ab5634\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:svg-viewbox\",[\"~#rect\",[\"^ \",\"~:x\",82.53356005440122,\"~:y\",38.214569380883994,\"^7\",50.00989933583486,\"~:height\",37.56881532385584,\"~:x1\",82.53356005440122,\"~:y1\",38.214569380883994,\"~:x2\",132.54345939023608,\"~:y2\",75.78338470473983]],\"~:svg-defs\",[\"^ \"],\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-alignment\",\"~:inner\",\"~:stroke-style\",\"~:solid\",\"~:stroke-color\",\"#000000\",\"~:stroke-opacity\",1,\"~:stroke-width\",20]],\"~:x\",null,\"~:proportion\",1.3253018434530361,\"~:selrect\",[\"^D\",[\"^ \",\"~:x\",512.0000270184612,\"~:y\",987.0000230312764,\"^7\",199.99994607237613,\"^E\",150.9999516960272,\"^F\",512.0000270184612,\"^G\",987.0000230312764,\"^H\",711.9999730908373,\"^I\",1137.9999747273037]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#b1b2b5\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^E\",null,\"~:flip-y\",null]]",
"~u25475404-d141-8046-8007-c5912e12bb56": "[\"~#shape\",[\"^ \",\"~:y\",650.0000177025795,\"~: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\",\"~:hide-in-viewer\",false,\"~:name\",\"ellipse-outer\",\"~:width\",200.00000125169754,\"~:type\",\"~:circle\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",1123.9999994039536,\"~:y\",650.0000177025795]],[\"^<\",[\"^ \",\"~:x\",1324.000000655651,\"~:y\",650.0000177025795]],[\"^<\",[\"^ \",\"~:x\",1324.000000655651,\"~:y\",850.0000233650208]],[\"^<\",[\"^ \",\"~:x\",1123.9999994039536,\"~:y\",850.0000233650208]]],\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:id\",\"~u25475404-d141-8046-8007-c5912e12bb56\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-alignment\",\"~:outer\",\"~:stroke-style\",\"~:solid\",\"~:stroke-color\",\"#000000\",\"~:stroke-opacity\",1,\"~:stroke-width\",20]],\"~:x\",1123.9999994039536,\"~:proportion\",1,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",1123.9999994039536,\"~:y\",650.0000177025795,\"^8\",200.00000125169754,\"~:height\",200.00000566244125,\"~:x1\",1123.9999994039536,\"~:y1\",650.0000177025795,\"~:x2\",1324.000000655651,\"~:y2\",850.0000233650208]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#b1b2b5\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^M\",200.00000566244125,\"~:flip-y\",null]]",
"~u334565ad-19c1-804c-8007-c59204711ada": "[\"~#shape\",[\"^ \",\"~:y\",null,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:content\",[\"~#penpot/path-data\",\"~bAQAAAAAAAAAAAAAAAAAAAAAAAABSRKVEYtF3RAMAAAA43aRE8KN2RL5CpEQVwnZEvkKkRBXCdkQCAAAAAAAAAAAAAAAAAAAAAAAAACPCjUSSwHZEAwAAACPCjUSSwHZEOyKNRHKmdkTQu4xEBNN3RAMAAAC0VYxEtv94REKRjETlKXpEQpGMROUpekQCAAAAAAAAAAAAAAAAAAAAAAAAAKC9kUTT5YlEAwAAADaek0Saq49EoAyeRC67j0TDR6BE0+WJRAIAAAAAAAAAAAAAAAAAAAAAAAAAr22lROotekQDAAAAr22lROotekRcq6VEtv94RFJEpURi0XdEBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==\"],\"~:name\",\"path-outer\",\"~:width\",null,\"~:type\",\"~:path\",\"~:svg-attrs\",[\"^ \",\"~:className\",\"fills\"],\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",1123.9999674138348,\"~:y\",987.0000591039244]],[\"^=\",[\"^ \",\"~:x\",1324.0000326954682,\"~:y\",987.0000591039244]],[\"^=\",[\"^ \",\"~:x\",1324.0000326954682,\"~:y\",1138.0000107999515]],[\"^=\",[\"^ \",\"~:x\",1123.9999674138348,\"~:y\",1138.0000107999515]]],\"~:proportion-lock\",true,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:svg-transform\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:id\",\"~u334565ad-19c1-804c-8007-c59204711ada\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:svg-viewbox\",[\"~#rect\",[\"^ \",\"~:x\",82.53356005440122,\"~:y\",38.214569380883994,\"^7\",50.00989933583486,\"~:height\",37.56881532385584,\"~:x1\",82.53356005440122,\"~:y1\",38.214569380883994,\"~:x2\",132.54345939023608,\"~:y2\",75.78338470473983]],\"~:svg-defs\",[\"^ \"],\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-alignment\",\"~:outer\",\"~:stroke-style\",\"~:solid\",\"~:stroke-color\",\"#000000\",\"~:stroke-opacity\",1,\"~:stroke-width\",20]],\"~:x\",null,\"~:proportion\",1.3253018434530361,\"~:selrect\",[\"^D\",[\"^ \",\"~:x\",1123.9999674138348,\"~:y\",987.0000591039245,\"^7\",200.0000652816334,\"^E\",150.9999516960272,\"^F\",1123.9999674138348,\"^G\",987.0000591039245,\"^H\",1324.0000326954682,\"^I\",1138.0000107999517]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#b1b2b5\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^E\",null,\"~:flip-y\",null]]",
"~u25475404-d141-8046-8007-c5911aba8cc3": "[\"~#shape\",[\"^ \",\"~:y\",650.0000177025795,\"~: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\",\"~:hide-in-viewer\",false,\"~:name\",\"ellipse-inner\",\"~:width\",200.00000125169754,\"~:type\",\"~:circle\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",511.99999940395355,\"~:y\",650.0000177025795]],[\"^<\",[\"^ \",\"~:x\",712.0000006556511,\"~:y\",650.0000177025795]],[\"^<\",[\"^ \",\"~:x\",712.0000006556511,\"~:y\",850.0000233650208]],[\"^<\",[\"^ \",\"~:x\",511.99999940395355,\"~:y\",850.0000233650208]]],\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:id\",\"~u25475404-d141-8046-8007-c5911aba8cc3\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-alignment\",\"~:inner\",\"~:stroke-style\",\"~:solid\",\"~:stroke-color\",\"#000000\",\"~:stroke-opacity\",1,\"~:stroke-width\",20]],\"~:x\",511.99999940395355,\"~:proportion\",1,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",511.99999940395355,\"~:y\",650.0000177025795,\"^8\",200.00000125169754,\"~:height\",200.00000566244125,\"~:x1\",511.99999940395355,\"~:y1\",650.0000177025795,\"~:x2\",712.0000006556511,\"~:y2\",850.0000233650208]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#B1B2B5\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^M\",200.00000566244125,\"~:flip-y\",null]]",
"~u25475404-d141-8046-8007-c590e8c99f60": "[\"~#shape\",[\"^ \",\"~:y\",343,\"~: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\",\"~:hide-in-viewer\",false,\"~:name\",\"rectangle-center\",\"~:width\",260,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",788,\"~:y\",343]],[\"^<\",[\"^ \",\"~:x\",1048,\"~:y\",343]],[\"^<\",[\"^ \",\"~:x\",1048,\"~:y\",513]],[\"^<\",[\"^ \",\"~:x\",788,\"~:y\",513]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u25475404-d141-8046-8007-c590e8c99f60\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-alignment\",\"~:center\",\"~:stroke-style\",\"~:solid\",\"~:stroke-color\",\"#000000\",\"~:stroke-opacity\",1,\"~:stroke-width\",20]],\"~:x\",788,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",788,\"~:y\",343,\"^8\",260,\"~:height\",170,\"~:x1\",788,\"~:y1\",343,\"~:x2\",1048,\"~:y2\",513]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#B1B2B5\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^Q\",170,\"~:flip-y\",null]]",
"~u25475404-d141-8046-8007-c590eac93f27": "[\"~#shape\",[\"^ \",\"~:y\",343,\"~: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\",\"~:hide-in-viewer\",false,\"~:name\",\"rectangle-outer\",\"~:width\",260,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",1094,\"~:y\",343]],[\"^<\",[\"^ \",\"~:x\",1354,\"~:y\",343]],[\"^<\",[\"^ \",\"~:x\",1354,\"~:y\",513]],[\"^<\",[\"^ \",\"~:x\",1094,\"~:y\",513]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u25475404-d141-8046-8007-c590eac93f27\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-alignment\",\"~:outer\",\"~:stroke-style\",\"~:solid\",\"~:stroke-color\",\"#000000\",\"~:stroke-opacity\",1,\"~:stroke-width\",20]],\"~:x\",1094,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",1094,\"~:y\",343,\"^8\",260,\"~:height\",170,\"~:x1\",1094,\"~:y1\",343,\"~:x2\",1354,\"~:y2\",513]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#B1B2B5\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^Q\",170,\"~:flip-y\",null]]",
"~u334565ad-19c1-804c-8007-c591fecf5ce9": "[\"~#shape\",[\"^ \",\"~:y\",null,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:content\",[\"~#penpot/path-data\",\"~bAQAAAAAAAAAAAAAAAAAAAAAAAACkCH5EYtF3RAMAAABwOn1E8KN2RHwFfEQVwnZEfAV8RBXCdkQCAAAAAAAAAAAAAAAAAAAAAAAAAEYET0SSwHZEAwAAAEYET0SSwHZEd8RNRHKmdkSg90xEBNN3RAMAAABpK0xEtv94RISiTETlKXpEhKJMROUpekQCAAAAAAAAAAAAAAAAAAAAAAAAAED7VkTT5YlEAwAAAG28WkSaq49EQZlvRC67j0SHD3RE0+WJRAIAAAAAAAAAAAAAAAAAAAAAAAAAXlt+ROotekQDAAAAXlt+ROotekS41n5Etv94RKQIfkRi0XdEBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==\"],\"~:name\",\"path-center\",\"~:width\",null,\"~:type\",\"~:path\",\"~:svg-attrs\",[\"^ \",\"~:className\",\"fills\"],\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",817.9999676522441,\"~:y\",987.0000410676004]],[\"^=\",[\"^ \",\"~:x\",1018.0000329338777,\"~:y\",987.0000410676004]],[\"^=\",[\"^ \",\"~:x\",1018.0000329338777,\"~:y\",1137.9999927636275]],[\"^=\",[\"^ \",\"~:x\",817.9999676522441,\"~:y\",1137.9999927636275]]],\"~:proportion-lock\",true,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:svg-transform\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:id\",\"~u334565ad-19c1-804c-8007-c591fecf5ce9\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:svg-viewbox\",[\"~#rect\",[\"^ \",\"~:x\",82.53356005440122,\"~:y\",38.214569380883994,\"^7\",50.00989933583486,\"~:height\",37.56881532385584,\"~:x1\",82.53356005440122,\"~:y1\",38.214569380883994,\"~:x2\",132.54345939023608,\"~:y2\",75.78338470473983]],\"~:svg-defs\",[\"^ \"],\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-alignment\",\"~:center\",\"~:stroke-style\",\"~:solid\",\"~:stroke-color\",\"#000000\",\"~:stroke-opacity\",1,\"~:stroke-width\",20]],\"~:x\",null,\"~:proportion\",1.3253018434530361,\"~:selrect\",[\"^D\",[\"^ \",\"~:x\",817.9999676522442,\"~:y\",987.0000410676005,\"^7\",200.0000652816335,\"^E\",150.9999516960272,\"^F\",817.9999676522442,\"^G\",987.0000410676005,\"^H\",1018.0000329338777,\"^I\",1137.9999927636277]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#b1b2b5\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^E\",null,\"~:flip-y\",null]]",
"~u25475404-d141-8046-8007-c5912a27368c": "[\"~#shape\",[\"^ \",\"~:y\",650.0000177025795,\"~: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\",\"~:hide-in-viewer\",false,\"~:name\",\"ellipse-center\",\"~:width\",200.00000125169754,\"~:type\",\"~:circle\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",818.0000041723251,\"~:y\",650.0000177025795]],[\"^<\",[\"^ \",\"~:x\",1018.0000054240227,\"~:y\",650.0000177025795]],[\"^<\",[\"^ \",\"~:x\",1018.0000054240227,\"~:y\",850.0000233650208]],[\"^<\",[\"^ \",\"~:x\",818.0000041723251,\"~:y\",850.0000233650208]]],\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:id\",\"~u25475404-d141-8046-8007-c5912a27368c\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-alignment\",\"~:center\",\"~:stroke-style\",\"~:solid\",\"~:stroke-color\",\"#000000\",\"~:stroke-opacity\",1,\"~:stroke-width\",20]],\"~:x\",818.0000041723251,\"~:proportion\",1,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",818.0000041723251,\"~:y\",650.0000177025795,\"^8\",200.00000125169754,\"~:height\",200.00000566244125,\"~:x1\",818.0000041723251,\"~:y1\",650.0000177025795,\"~:x2\",1018.0000054240227,\"~:y2\",850.0000233650208]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#B1B2B5\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^M\",200.00000566244125,\"~:flip-y\",null]]"
}
},
"~:id": "~ud9a19a61-ed94-818f-8007-c590e153a280",
"~:name": "Page 1"
}
},
"~:id": "~ud9a19a61-ed94-818f-8007-c590e153a27f",
"~:options": {
"~:components-v2": true,
"~:base-font-size": "16px"
}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 212 KiB

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 318 KiB

After

Width:  |  Height:  |  Size: 291 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 348 KiB

After

Width:  |  Height:  |  Size: 324 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 595 KiB

After

Width:  |  Height:  |  Size: 645 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 260 KiB

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 247 KiB

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 175 KiB

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 173 KiB

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 220 KiB

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 430 KiB

After

Width:  |  Height:  |  Size: 450 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 523 KiB

After

Width:  |  Height:  |  Size: 519 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 322 KiB

After

Width:  |  Height:  |  Size: 305 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -0,0 +1,80 @@
import { test, expect } from "@playwright/test";
import { WasmWorkspacePage } from "../pages/WasmWorkspacePage";
test.beforeEach(async ({ page }) => {
await WasmWorkspacePage.init(page);
await WasmWorkspacePage.mockConfigFlags(page, [
"enable-feature-render-wasm",
"enable-render-wasm-dpr",
"enable-stroke-path",
]);
});
async function setupShapesWithStrokes(page) {
const workspace = new WasmWorkspacePage(page);
await workspace.setupEmptyFile();
await workspace.mockGetFile("workspace/shapes-with-strokes.json");
await workspace.mockRPC(
"update-file?id=*",
"workspace/shapes-with-strokes.json",
);
await workspace.goToWorkspace();
await workspace.waitForFirstRender();
return workspace;
}
async function strokeToPath(workspace, page, shapeName, expectedStrokeName) {
await workspace.clickLayers();
await workspace.clickLeafLayer(shapeName, { button: "right" });
await page.getByText("Stroke to path").click();
await expect(
workspace.layers.getByText(expectedStrokeName),
).toBeVisible();
await expect(workspace.layers.getByText(shapeName).first()).toBeVisible();
}
test("Stroke to path: rectangle with center stroke", async ({ page }) => {
const workspace = await setupShapesWithStrokes(page);
await strokeToPath(workspace, page, "rectangle-center", "rectangle-center (stroke)");
});
test("Stroke to path: rectangle with inner stroke", async ({ page }) => {
const workspace = await setupShapesWithStrokes(page);
await strokeToPath(workspace, page, "rectangle-inner", "rectangle-inner (stroke)");
});
test("Stroke to path: rectangle with outer stroke", async ({ page }) => {
const workspace = await setupShapesWithStrokes(page);
await strokeToPath(workspace, page, "rectangle-outer", "rectangle-outer (stroke)");
});
test("Stroke to path: circle with center stroke", async ({ page }) => {
const workspace = await setupShapesWithStrokes(page);
await strokeToPath(workspace, page, "ellipse-center", "ellipse-center (stroke)");
});
test("Stroke to path: circle with inner stroke", async ({ page }) => {
const workspace = await setupShapesWithStrokes(page);
await strokeToPath(workspace, page, "ellipse-inner", "ellipse-inner (stroke)");
});
test("Stroke to path: circle with outer stroke", async ({ page }) => {
const workspace = await setupShapesWithStrokes(page);
await strokeToPath(workspace, page, "ellipse-outer", "ellipse-outer (stroke)");
});
test("Stroke to path: path with center stroke", async ({ page }) => {
const workspace = await setupShapesWithStrokes(page);
await strokeToPath(workspace, page, "path-center", "path-center (stroke)");
});
test("Stroke to path: path with inner stroke", async ({ page }) => {
const workspace = await setupShapesWithStrokes(page);
await strokeToPath(workspace, page, "path-inner", "path-inner (stroke)");
});
test("Stroke to path: path with outer stroke", async ({ page }) => {
const workspace = await setupShapesWithStrokes(page);
await strokeToPath(workspace, page, "path-outer", "path-outer (stroke)");
});

View File

@ -1508,6 +1508,7 @@
;; Shapes to path
(dm/export dwps/convert-selected-to-path)
(dm/export dwps/convert-selected-strokes-to-path)
;; Guides
(dm/export dwgu/update-guides)

View File

@ -9,11 +9,15 @@
[app.common.data :as d]
[app.common.files.changes-builder :as pcb]
[app.common.files.helpers :as cph]
[app.common.geom.shapes :as gsh]
[app.common.types.container :as ctn]
[app.common.types.path :as path]
[app.common.types.shape :as cts]
[app.common.types.text :as txt]
[app.common.uuid :as uuid]
[app.main.data.changes :as dch]
[app.main.data.helpers :as dsh]
[app.main.data.workspace.selection :as dws]
[app.main.features :as features]
[app.render-wasm.api :as wasm.api]
[beicon.v2.core :as rx]
@ -81,3 +85,135 @@
(pcb/remove-objects children-ids))]
(rx/of (dch/commit-changes changes))))))))
(defn- stroke->fill
"Converts stroke color properties to fill color properties."
[stroke]
(d/without-nils
{:fill-color (:stroke-color stroke)
:fill-opacity (:stroke-opacity stroke)
:fill-color-gradient (:stroke-color-gradient stroke)
:fill-image (:stroke-image stroke)
:fill-color-ref-id (:stroke-color-ref-id stroke)
:fill-color-ref-file (:stroke-color-ref-file stroke)}))
(defn- make-stroke-paths
"Given a shape with strokes, returns a vector of new path shapes
created from each stroke. Uses the provided parent-id and frame-id."
[shape parent-id frame-id]
(into []
(keep-indexed
(fn [idx stroke]
(let [content (wasm.api/stroke-to-path (:id shape) idx)]
(when (some? content)
(cts/setup-shape
{:type :path
:id (uuid/next)
:name (str (:name shape) " (stroke)")
:parent-id parent-id
:frame-id frame-id
:content content
:fills [(stroke->fill stroke)]
:strokes []})))))
(:strokes shape)))
(defn convert-selected-strokes-to-path
"For each selected shape, converts each stroke into a new sibling
path shape. When the selected shape is a group/frame with stroked
descendants, a new group is created as a sibling containing all
the stroke paths. Strokes are then removed from processed shapes."
([]
(convert-selected-strokes-to-path nil))
([ids]
(ptk/reify ::convert-selected-strokes-to-path
ptk/WatchEvent
(watch [it state _]
(when (features/active-feature? state "render-wasm/v1")
(let [page-id (:current-page-id state)
objects (dsh/lookup-page-objects state)
selected (->> (or ids (dsh/lookup-selected state))
(remove #(ctn/has-any-copy-parent? objects (get objects %))))
result
(reduce
(fn [acc shape-id]
(let [shape (get objects shape-id)]
(if (seq (:strokes shape))
;; Shape itself has strokes: create stroke paths as siblings
(let [position (cph/get-position-on-parent objects shape-id)
new-shapes (make-stroke-paths shape (:parent-id shape) (:frame-id shape))]
(-> acc
(update :entries into (map-indexed #(hash-map :new-shape %2 :index (+ (inc position) %1)) new-shapes))
(update :updated-ids conj shape-id)))
;; Check descendants for strokes (groups, SVGs, etc.)
(let [child-ids (->> (cph/get-children-ids objects shape-id)
(filter #(seq (:strokes (get objects %)))))
group-id (uuid/next)
new-shapes (into []
(mapcat (fn [cid]
(make-stroke-paths (get objects cid)
group-id
(:frame-id shape))))
child-ids)]
(if (seq new-shapes)
;; Wrap all stroke paths in a new group
(let [position (cph/get-position-on-parent objects shape-id)
selrect (gsh/shapes->rect new-shapes)
group (cts/setup-shape
{:id group-id
:type :group
:name (str (:name shape) " (strokes)")
:shapes (mapv :id new-shapes)
:selrect selrect
:x (:x selrect)
:y (:y selrect)
:width (:width selrect)
:height (:height selrect)
:parent-id (:parent-id shape)
:frame-id (:frame-id shape)})]
(-> acc
(update :groups conj {:group group :children new-shapes :index (inc position)})
(update :updated-ids into child-ids)))
acc)))))
{:entries []
:groups []
:updated-ids []}
selected)
new-shape-ids (into []
(concat
(map (comp :id :new-shape) (:entries result))
(map (comp :id :group) (:groups result))))
changes
(as-> (pcb/empty-changes it page-id) changes
(pcb/with-objects changes objects)
;; Add ungrouped stroke path shapes as siblings
(reduce
(fn [changes {:keys [new-shape index]}]
(pcb/add-object changes new-shape {:index index}))
changes
(:entries result))
;; Add groups with their stroke path children
(reduce
(fn [changes {:keys [group children index]}]
(as-> changes changes
(pcb/add-object changes group {:index index})
(reduce
(fn [changes child]
(pcb/add-object changes child {:parent-id (:id group)}))
changes
children)))
changes
(:groups result))
;; Remove strokes from original shapes
(pcb/update-shapes changes
(:updated-ids result)
(fn [shape] (assoc shape :strokes []))))]
(rx/of (dch/commit-changes changes)
(dws/select-shapes (into (d/ordered-set) new-shape-ids)))))))))

View File

@ -28,6 +28,7 @@
[app.main.data.workspace.shapes :as dwsh]
[app.main.data.workspace.shortcuts :as sc]
[app.main.data.workspace.variants :as dwv]
[app.main.features :as features]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.components.dropdown :refer [dropdown]]
@ -411,7 +412,7 @@
(mf/defc context-menu-path*
{::mf/props :obj
::mf/private true}
[{:keys [shapes disable-flatten disable-booleans]}]
[{:keys [shapes objects disable-flatten disable-booleans]}]
(let [multiple? (> (count shapes) 1)
single? (= (count shapes) 1)
@ -424,8 +425,17 @@
is-bool? (and single? has-bool?)
is-frame? (and single? has-frame?)
has-strokes? (or (->> shapes (d/seek #(seq (:strokes %))))
(when objects
(->> shapes
(d/seek
(fn [shape]
(->> (cfh/get-children-ids objects (:id shape))
(d/seek #(seq (:strokes (get objects %))))))))))
do-start-editing (fn [] (timers/schedule #(st/emit! (dw/start-editing-selected))))
do-transform-to-path #(st/emit! (dw/convert-selected-to-path))
do-strokes-to-path #(st/emit! (dw/convert-selected-strokes-to-path))
make-do-bool
(fn [bool-type]
@ -448,6 +458,12 @@
[:> menu-entry* {:title (tr "workspace.shape.menu.flatten")
:on-click do-transform-to-path}])
(when (and has-strokes?
(features/active-feature? @st/state "render-wasm/v1")
(contains? cf/flags :stroke-path))
[:> menu-entry* {:title (tr "workspace.shape.menu.stroke-to-path")
:on-click do-strokes-to-path}])
(when (and (not has-frame?)
(not disable-booleans)
(or multiple? (and single? (or is-group? is-bool?))))
@ -652,6 +668,7 @@
is-not-variant-container? (->> shapes (d/seek #(not (ctk/is-variant-container? %))))
props (mf/props
{:shapes shapes
:objects objects
:disable-booleans disable-booleans
:disable-flatten disable-flatten})]
(when-not (empty? shapes)

View File

@ -23,6 +23,7 @@
[app.common.uuid :as uuid]
[app.config :as cf]
[app.main.refs :as refs]
[app.main.router :as rt]
[app.main.store :as st]
[app.main.ui.shapes.text]
[app.main.worker :as mw]
@ -1410,6 +1411,15 @@
(contains? cf/flags :render-wasm-info)
(bit-or 2r00000000000000000000000000001000)))
(defn- wasm-aa-threshold-from-route-params
"Reads optional `aa_threshold` query param from the router"
[]
(when-let [raw (let [p (rt/get-params @st/state)]
(:aa_threshold p))]
(let [n (if (string? raw) (js/parseFloat raw) raw)]
(when (and (number? n) (not (js/isNaN n)) (pos? n))
n))))
(defn set-canvas-size
[canvas]
(let [width (or (.-clientWidth ^js canvas) (.-width ^js canvas))
@ -1445,6 +1455,8 @@
;; Initialize Wasm Render Engine
(h/call wasm/internal-module "_init" (/ (.-width ^js canvas) dpr) (/ (.-height ^js canvas) dpr))
(h/call wasm/internal-module "_set_render_options" flags dpr)
(when-let [t (wasm-aa-threshold-from-route-params)]
(h/call wasm/internal-module "_set_antialias_threshold" t))
;; Set browser and canvas size only after initialization
(h/call wasm/internal-module "_set_browser" browser)
@ -1540,6 +1552,25 @@
(mem/free)
content))
(defn stroke-to-path
"Converts a shape's stroke at the given index into a filled path.
Returns the stroke outline as PathData content."
[id stroke-index]
(use-shape id)
(let [offset (-> (h/call wasm/internal-module "_convert_stroke_to_path" stroke-index)
(mem/->offset-32))
heap (mem/get-heap-u32)
length (aget heap offset)]
(if (pos? length)
(let [data (mem/slice heap
(+ offset 1)
(* length path.impl/SEGMENT-U32-SIZE))
content (path/from-bytes data)]
(mem/free)
content)
(do (mem/free)
nil))))
(defn calculate-bool*
[bool-type ids]
(let [size (mem/get-alloc-size ids UUID-U8-SIZE)

View File

@ -291,6 +291,13 @@ export class TextNodeIterator {
break;
}
}
// The loop exits when currentNode === endNode without yielding endNode.
// Callers (e.g. selection style merge) must visit every text/BR node in the
// range, including the last one, or the final span is omitted (e.g. empty
// paragraph with only <br>) and the sidebar shows "mixed" incorrectly.
if (this.#currentNode === endNode) {
yield this.#currentNode;
}
}
}

View File

@ -70,4 +70,29 @@ describe("TextNodeIterator", () => {
textNodeIterator.nextNode();
expect(textNodeIterator.currentNode.nodeValue).toBe("Whatever");
});
test("collectFrom includes the end node (iterateFrom must yield end inclusive)", () => {
const rootNode = createRoot([
createParagraph([createTextSpan(new Text("Hello"))]),
createParagraph([createTextSpan(createLineBreak())]),
]);
const firstText = rootNode.firstChild.firstChild.firstChild;
const br = rootNode.lastChild.firstChild.firstChild;
const textNodeIterator = new TextNodeIterator(rootNode);
const nodes = textNodeIterator.collectFrom(firstText, br);
expect(nodes.length).toBe(2);
expect(nodes[0]).toBe(firstText);
expect(nodes[1]).toBe(br);
});
test("collectFrom with identical start and end returns one node", () => {
const rootNode = createRoot([
createParagraph([createTextSpan(new Text("Hi"))]),
]);
const text = rootNode.firstChild.firstChild.firstChild;
const textNodeIterator = new TextNodeIterator(rootNode);
const nodes = textNodeIterator.collectFrom(text, text);
expect(nodes.length).toBe(1);
expect(nodes[0]).toBe(text);
});
});

View File

@ -403,7 +403,12 @@ export class SelectionController extends EventTarget {
this.#updateCurrentStyle(textSpan);
} else {
// SELECTION.
this.#updateCurrentStyleFrom(this.#anchorNode, this.#focusNode);
// Use range boundaries normalized to text nodes, not anchor/focus.
// Firefox may set anchorNode on the paragraph element and focusNode on a
// text node for word selection; passing those to #updateCurrentStyleFrom
// breaks TextNodeIterator and yields wrong styles (e.g. default 14px).
const { startNode, endNode } = this.getRanges();
this.#updateCurrentStyleFrom(startNode, endNode);
}
this.dispatchEvent(
new CustomEvent("stylechange", {

View File

@ -1683,6 +1683,30 @@ describe("SelectionController", () => {
expect(selectionController.focusAtEnd).toBeTruthy();
})
test("`currentStyle` uses text span font-size when anchor is paragraph (Firefox-style word selection)", () => {
const textEditorMock = TextEditorMock.createTextEditorMockWithParagraphs([
createParagraph([
createTextSpan(new Text("Hello World"), { "font-size": "36" }),
]),
]);
const root = textEditorMock.root;
const paragraph = root.firstChild;
const textNode = paragraph.firstChild.firstChild;
const selection = document.getSelection();
const selectionController = new SelectionController(
textEditorMock,
selection,
);
textEditorMock.element.focus();
// Anchor on the paragraph (child offset 0) and focus in the text node — matches
// Firefox when double-click selects a word; anchor/focus are not both text nodes.
selection.setBaseAndExtent(paragraph, 0, textNode, 5);
document.dispatchEvent(new Event("selectionchange"));
expect(selectionController.currentStyle.getPropertyValue("font-size")).toBe(
"36px",
);
});
test("`dispose` should release every held reference", () => {
const textEditorMock = TextEditorMock.createTextEditorMockWithParagraphs([
createParagraphWith(["Hello, "], {

View File

@ -7687,6 +7687,10 @@ msgstr "Exclude"
msgid "workspace.shape.menu.flatten"
msgstr "Flatten"
#: src/app/main/ui/workspace/context_menu.cljs
msgid "workspace.shape.menu.stroke-to-path"
msgstr "Stroke to path"
#: src/app/main/ui/workspace/context_menu.cljs:299
msgid "workspace.shape.menu.flip-horizontal"
msgstr "Flip horizontal"

View File

@ -7604,6 +7604,10 @@ msgstr "Exclusión"
msgid "workspace.shape.menu.flatten"
msgstr "Aplanar"
#: src/app/main/ui/workspace/context_menu.cljs
msgid "workspace.shape.menu.stroke-to-path"
msgstr "Borde a ruta"
#: src/app/main/ui/workspace/context_menu.cljs:299
msgid "workspace.shape.menu.flip-horizontal"
msgstr "Voltear horizontal"

View File

@ -4,7 +4,7 @@
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "cross-env PENPOT_MCP_PLUGIN_SERVER_HOST=0.0.0.0 vite build --watch --config vite.config.ts",
"start": "vite build --watch --config vite.config.ts",
"start:multi-user": "pnpm run start",
"build": "tsc && vite build --config vite.release.config.ts",
"types:check": "tsc --noEmit",

View File

@ -38,6 +38,7 @@
"zod": "^4.3.6"
},
"devDependencies": {
"cross-env": "^7.0.3",
"@penpot/mcp-common": "workspace:../common",
"@types/express": "^4.17.0",
"@types/js-yaml": "^4.0.9",

3
mcp/pnpm-lock.yaml generated
View File

@ -97,6 +97,9 @@ importers:
'@types/ws':
specifier: ^8.5.10
version: 8.18.1
cross-env:
specifier: ^7.0.3
version: 7.0.3
esbuild:
specifier: ^0.25.0
version: 0.25.12

View File

@ -145,6 +145,15 @@ pub extern "C" fn set_render_options(debug: u32, dpr: f32) -> Result<()> {
Ok(())
}
#[no_mangle]
#[wasm_error]
pub extern "C" fn set_antialias_threshold(threshold: f32) -> Result<()> {
with_state_mut!(state, {
state.render_state_mut().set_antialias_threshold(threshold);
});
Ok(())
}
#[no_mangle]
#[wasm_error]
pub extern "C" fn set_canvas_background(raw_color: u32) -> Result<()> {

View File

@ -617,6 +617,10 @@ impl RenderState {
Ok(())
}
pub fn set_antialias_threshold(&mut self, value: f32) {
self.options.set_antialias_threshold(value);
}
pub fn set_background_color(&mut self, color: skia::Color) {
self.background_color = color;
}
@ -798,7 +802,8 @@ impl RenderState {
});
}
let antialias = shape.should_use_antialias(self.get_scale());
let antialias =
shape.should_use_antialias(self.get_scale(), self.options.antialias_threshold);
let fast_mode = self.options.is_fast_mode();
let has_nested_fills = self
.nested_fills
@ -2164,7 +2169,7 @@ impl RenderState {
}
if let Some(clips) = clip_bounds.as_ref() {
let antialias = element.should_use_antialias(scale);
let antialias = element.should_use_antialias(scale, self.options.antialias_threshold);
self.surfaces.canvas(target_surface).save();
for (bounds, corners, transform) in clips.iter() {
if target_surface == SurfaceId::Export {

View File

@ -4,14 +4,20 @@ use crate::shapes::modifiers::grid_layout::grid_cell_data;
use crate::shapes::Shape;
use crate::state::ShapesPoolRef;
pub fn render_overlay(zoom: f32, canvas: &skia::Canvas, shape: &Shape, shapes: ShapesPoolRef) {
pub fn render_overlay(
zoom: f32,
antialias_threshold: f32,
canvas: &skia::Canvas,
shape: &Shape,
shapes: ShapesPoolRef,
) {
let cells: Vec<crate::shapes::grid_layout::CellData<'_>> = grid_cell_data(shape, shapes, true);
let bounds = shape.bounds();
let mut paint = skia::Paint::default();
paint.set_style(skia::PaintStyle::Stroke);
paint.set_color(skia::Color::from_rgb(255, 111, 224));
paint.set_anti_alias(shape.should_use_antialias(zoom));
paint.set_anti_alias(shape.should_use_antialias(zoom, antialias_threshold));
paint.set_stroke_width(1.0 / zoom);

View File

@ -4,11 +4,24 @@ const PROFILE_REBUILD_TILES: u32 = 0x02;
const TEXT_EDITOR_V3: u32 = 0x04;
const SHOW_WASM_INFO: u32 = 0x08;
#[derive(Debug, Copy, Clone, PartialEq, Default)]
#[derive(Debug, Copy, Clone, PartialEq)]
pub struct RenderOptions {
pub flags: u32,
pub dpr: Option<f32>,
fast_mode: bool,
/// Minimum on-screen size (CSS px at 1:1 zoom) above which vector antialiasing is enabled.
pub antialias_threshold: f32,
}
impl Default for RenderOptions {
fn default() -> Self {
Self {
flags: 0,
dpr: None,
fast_mode: false,
antialias_threshold: 15.0,
}
}
}
impl RenderOptions {
@ -40,4 +53,10 @@ impl RenderOptions {
pub fn show_wasm_info(&self) -> bool {
self.flags & SHOW_WASM_INFO == SHOW_WASM_INFO
}
pub fn set_antialias_threshold(&mut self, value: f32) {
if value.is_finite() && value > 0.0 {
self.antialias_threshold = value;
}
}
}

View File

@ -23,7 +23,13 @@ pub fn render(render_state: &mut RenderState, shapes: ShapesPoolRef) {
if let Some(id) = show_grid_id {
if let Some(shape) = shapes.get(&id) {
grid_layout::render_overlay(zoom, canvas, shape, shapes);
grid_layout::render_overlay(
zoom,
render_state.options.antialias_threshold,
canvas,
shape,
shapes,
);
}
}
@ -50,7 +56,13 @@ pub fn render(render_state: &mut RenderState, shapes: ShapesPoolRef) {
}
if let Some(shape) = shapes.get(&shape.id) {
grid_layout::render_overlay(zoom, canvas, shape, shapes);
grid_layout::render_overlay(
zoom,
render_state.options.antialias_threshold,
canvas,
shape,
shapes,
);
}
}

View File

@ -22,6 +22,7 @@ mod paths;
mod rects;
mod shadows;
mod shape_to_path;
mod stroke_paths;
mod strokes;
mod svg_attrs;
mod svgraw;
@ -43,6 +44,7 @@ pub use paths::*;
pub use rects::*;
pub use shadows::*;
pub use shape_to_path::*;
pub use stroke_paths::*;
pub use strokes::*;
pub use svg_attrs::*;
pub use svgraw::*;
@ -54,7 +56,6 @@ use crate::math::{self, Bounds, Matrix, Point};
use crate::state::ShapesPoolRef;
const MIN_VISIBLE_SIZE: f32 = 2.0;
const ANTIALIAS_THRESHOLD: f32 = 15.0;
const MIN_STROKE_WIDTH: f32 = 0.001;
#[derive(Debug, Clone, PartialEq)]
@ -766,9 +767,8 @@ impl Shape {
extrect.width() * scale < MIN_VISIBLE_SIZE && extrect.height() * scale < MIN_VISIBLE_SIZE
}
pub fn should_use_antialias(&self, scale: f32) -> bool {
self.selrect.width() * scale > ANTIALIAS_THRESHOLD
|| self.selrect.height() * scale > ANTIALIAS_THRESHOLD
pub fn should_use_antialias(&self, scale: f32, threshold: f32) -> bool {
self.selrect.width() * scale > threshold || self.selrect.height() * scale > threshold
}
pub fn calculate_bounds(&self, apply_transform: bool) -> Bounds {

View File

@ -114,6 +114,109 @@ impl Path {
Path::new(segments)
}
/// Like `from_skia_path` but properly converts conics to cubic beziers
/// (using Skia's conic-to-quad + quad-to-cubic elevation). Use this when
/// accurate curve conversion matters (e.g. stroke-to-path on circles).
pub fn from_skia_path_accurate(path: skia::Path) -> Self {
let verbs = path.verbs();
let points = path.points();
let conic_weights = path.conic_weights();
let mut segments = Vec::new();
let mut current_point = 0;
let mut current_conic = 0;
let mut last_point = skia::Point::new(0.0, 0.0);
for verb in verbs {
match verb {
skia::PathVerb::Move => {
let p = points[current_point];
segments.push(Segment::MoveTo((p.x, p.y)));
last_point = p;
current_point += 1;
}
skia::PathVerb::Line => {
let p = points[current_point];
segments.push(Segment::LineTo((p.x, p.y)));
last_point = p;
current_point += 1;
}
skia::PathVerb::Quad => {
let ctrl = points[current_point];
let end = points[current_point + 1];
let cp1x = last_point.x + (2.0 / 3.0) * (ctrl.x - last_point.x);
let cp1y = last_point.y + (2.0 / 3.0) * (ctrl.y - last_point.y);
let cp2x = end.x + (2.0 / 3.0) * (ctrl.x - end.x);
let cp2y = end.y + (2.0 / 3.0) * (ctrl.y - end.y);
segments.push(Segment::CurveTo((
(cp1x, cp1y),
(cp2x, cp2y),
(end.x, end.y),
)));
last_point = end;
current_point += 2;
}
skia::PathVerb::Conic => {
let ctrl = points[current_point];
let end = points[current_point + 1];
let w = conic_weights[current_conic];
current_conic += 1;
// pow2=0: 1 quad per conic. A circle (4 conics) becomes
// 4 cubics, matching the standard bezier approximation.
const POW2: usize = 0;
let quad_count = 1 << POW2;
let pts_count = 1 + 2 * quad_count;
let mut quad_pts = vec![skia::Point::default(); pts_count];
if skia::Path::convert_conic_to_quads(
last_point,
ctrl,
end,
w,
&mut quad_pts,
POW2,
)
.is_some()
{
let mut qp = last_point;
for i in 0..quad_count {
let qctrl = quad_pts[1 + i * 2];
let qend = quad_pts[2 + i * 2];
let cp1x = qp.x + (2.0 / 3.0) * (qctrl.x - qp.x);
let cp1y = qp.y + (2.0 / 3.0) * (qctrl.y - qp.y);
let cp2x = qend.x + (2.0 / 3.0) * (qctrl.x - qend.x);
let cp2y = qend.y + (2.0 / 3.0) * (qctrl.y - qend.y);
segments.push(Segment::CurveTo((
(cp1x, cp1y),
(cp2x, cp2y),
(qend.x, qend.y),
)));
qp = qend;
}
last_point = qp;
} else {
segments.push(Segment::LineTo((end.x, end.y)));
last_point = end;
}
current_point += 2;
}
skia::PathVerb::Cubic => {
let p1 = points[current_point];
let p2 = points[current_point + 1];
let p3 = points[current_point + 2];
segments.push(Segment::CurveTo(((p1.x, p1.y), (p2.x, p2.y), (p3.x, p3.y))));
last_point = p3;
current_point += 3;
}
skia::PathVerb::Close => {
segments.push(Segment::Close);
}
}
}
Path::new(segments)
}
pub fn to_skia_path(&self) -> skia::Path {
self.skia_path.snapshot()
}

View File

@ -0,0 +1,87 @@
use skia_safe::{self as skia};
use super::paths::Path;
use super::strokes::{Stroke, StrokeKind};
use super::svg_attrs::SvgAttrs;
use crate::math::Rect;
/// Converts a stroke into a filled path outline.
///
/// Uses Skia's `fill_path_with_paint` to expand the stroke into a filled region,
/// then clips it via boolean ops for inner/outer alignment. The optional
/// `path_transform` maps from local shape coords to the drawing space (and back).
pub fn stroke_to_path(
stroke: &Stroke,
shape_path: &Path,
path_transform: Option<&skia::Matrix>,
selrect: &Rect,
svg_attrs: Option<&SvgAttrs>,
) -> Option<Path> {
let skia_shape_path = shape_path.to_skia_path();
let transformed_shape_path = if let Some(pt) = path_transform {
skia_shape_path.make_transform(pt)
} else {
skia_shape_path.clone()
};
let is_open = shape_path.is_open();
let mut paint = stroke.to_paint(selrect, svg_attrs, true);
let render_kind = stroke.render_kind(is_open);
if render_kind != StrokeKind::Center {
paint.set_stroke_width(stroke.width * 2.0);
}
let mut stroke_outline = skia::Path::default();
let success = skia::path_utils::fill_path_with_paint(
&transformed_shape_path,
&paint,
&mut stroke_outline,
None,
None,
);
if !success {
return None;
}
// For inner/outer strokes, use boolean ops to clip
// the 2×-width stroke outline to the correct region.
// Set EvenOdd to preserve the annular ring's inner hole,
// then as_winding() on the result fixes contour winding
// for Penpot's NonZero fill rule.
let final_path = match render_kind {
StrokeKind::Inner => {
stroke_outline.set_fill_type(skia::PathFillType::EvenOdd);
let inner = stroke_outline
.op(&transformed_shape_path, skia::PathOp::Intersect)
.unwrap_or(stroke_outline);
inner.as_winding().unwrap_or(inner)
}
StrokeKind::Outer => {
stroke_outline.set_fill_type(skia::PathFillType::EvenOdd);
let outer = stroke_outline
.op(&transformed_shape_path, skia::PathOp::Difference)
.unwrap_or(stroke_outline);
outer.as_winding().unwrap_or(outer)
}
StrokeKind::Center => {
stroke_outline.set_fill_type(skia::PathFillType::EvenOdd);
stroke_outline.as_winding().unwrap_or(stroke_outline)
}
};
// If there was a path_transform, invert it back to local coords
let final_path = if let Some(pt) = path_transform {
if let Some(inv) = pt.invert() {
final_path.make_transform(&inv)
} else {
final_path
}
} else {
final_path
};
Some(Path::from_skia_path_accurate(final_path))
}

View File

@ -5,7 +5,7 @@ use std::mem::size_of;
use std::sync::{Mutex, OnceLock};
use crate::error::{Error, Result};
use crate::shapes::{Path, Segment, ToPath};
use crate::shapes::{stroke_to_path, Path, Segment, ToPath};
use crate::{mem, with_current_shape, with_current_shape_mut, STATE};
const RAW_SEGMENT_DATA_SIZE: usize = size_of::<RawSegmentData>();
@ -235,6 +235,40 @@ pub extern "C" fn current_to_path() -> *mut u8 {
mem::write_vec(result)
}
/// Converts a shape's stroke (at the given index) into a filled path.
///
/// This uses Skia's `fill_path_with_paint` to convert the stroke outline
/// into a filled path, properly handling inner/outer/center alignment
/// via boolean path operations.
#[no_mangle]
pub extern "C" fn convert_stroke_to_path(stroke_index: i32) -> *mut u8 {
let mut result = Vec::<RawSegmentData>::default();
with_current_shape!(state, |shape: &Shape| {
let idx = stroke_index as usize;
if let Some(stroke) = shape.strokes.get(idx) {
let shape_path = shape.to_path(&state.shapes);
let path_transform = shape.to_path_transform();
if let Some(path) = stroke_to_path(
stroke,
&shape_path,
path_transform.as_ref(),
&shape.selrect,
shape.svg_attrs.as_ref(),
) {
result = path
.segments()
.iter()
.copied()
.map(RawSegmentData::from_segment)
.collect();
}
}
});
mem::write_vec(result)
}
#[cfg(test)]
mod tests {
use super::*;