mirror of
https://github.com/penpot/penpot.git
synced 2026-09-08 11:54:36 -04:00
Compare commits
4
Commits
issue-11444
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e5157e6aa | ||
|
|
efa2518fe8 | ||
|
|
5b97fb9408 | ||
|
|
269aa36f82 |
No files matched your search
@@ -25,6 +25,7 @@
|
||||
[app.common.uuid :as uuid]
|
||||
;; Required for side effects: binds the generated enums.
|
||||
[app.wasm.enums]
|
||||
[cuerdas.core :as str]
|
||||
[promesa.core :as p]
|
||||
[shadow.esm :refer [dynamic-import]]))
|
||||
|
||||
@@ -190,6 +191,22 @@
|
||||
(aget buf 0) (aget buf 1) (aget buf 2) (aget buf 3)
|
||||
false)))))
|
||||
|
||||
(defn store-image-url!
|
||||
"Registers the public URL an image was loaded from. The SVG export emits
|
||||
linked `<image href>` from these, and falls back to Skia base64 when missing,
|
||||
so this should run for every media id the scene references (including
|
||||
already-cached images).
|
||||
|
||||
Does NOT call `mem/free`, for the same reason as `store-font-url!`."
|
||||
[image-id url]
|
||||
(when (and (some? url) (not (str/blank? url)))
|
||||
(let [bytes (js/Buffer.from url "utf-8")
|
||||
ptr (mem/alloc (.-byteLength bytes))
|
||||
quart (uuid/get-u32 image-id)]
|
||||
(mem/write-buffer ptr (mem/get-heap-u8) bytes)
|
||||
(h/call wasm/internal-module "_store_image_url"
|
||||
(aget quart 0) (aget quart 1) (aget quart 2) (aget quart 3)))))
|
||||
|
||||
(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:
|
||||
|
||||
@@ -369,13 +369,18 @@
|
||||
"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."
|
||||
and repeated exports of a file reuse them.
|
||||
|
||||
Always registers a public media URL for each id so SVG export can emit linked
|
||||
`<image href>` even when the encoded bytes were already cached."
|
||||
[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)))
|
||||
(doseq [image-id all-ids]
|
||||
(wasm/store-image-url! image-id (public-uri (str "assets/by-file-media-id/" image-id))))
|
||||
(->> new-ids
|
||||
(map (fn [image-id]
|
||||
(->> (fetch-file-media-bytes image-id params)
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
[app.common.uuid :as uuid]
|
||||
[app.main.ui.components.dropdown :refer [dropdown]]
|
||||
[app.main.ui.components.numeric-input :as deprecated-input]
|
||||
[app.main.ui.icons :as deprecated-icon]
|
||||
[app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i]
|
||||
[app.util.dom :as dom]
|
||||
[app.util.keyboard :as kbd]
|
||||
[app.util.timers :as timers]
|
||||
@@ -182,7 +182,10 @@
|
||||
|
||||
[:span {:class (stl/css :dropdown-button)
|
||||
:on-click toggle-dropdown}
|
||||
deprecated-icon/arrow]
|
||||
[:> icon* {:icon-id i/arrow-down
|
||||
:size "m"
|
||||
:aria-hidden true
|
||||
:class (stl/css :dropdown-icon)}]]
|
||||
|
||||
[:& dropdown {:show (or is-open? false)
|
||||
:on-close close-dropdown}
|
||||
@@ -196,9 +199,12 @@
|
||||
[:li
|
||||
{:key (str element-id "-" index)
|
||||
:class (stl/css-case :dropdown-element true
|
||||
:is-selected (= (dm/str value) current-value))
|
||||
:is-selected (= (dm/str value) (dm/str current-value)))
|
||||
:data-value value
|
||||
:on-click select-item}
|
||||
[:span {:class (stl/css :label)} label]
|
||||
[:span {:class (stl/css :check-icon)}
|
||||
deprecated-icon/tick]])))]]]))
|
||||
[:> icon* {:icon-id i/tick
|
||||
:aria-hidden true
|
||||
:size "s"
|
||||
:class (stl/css :check-tick)}]]])))]]]))
|
||||
@@ -4,87 +4,126 @@
|
||||
//
|
||||
// Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
// FIXME: we need this import for %asset-element
|
||||
@use "refactor/basic-rules.scss" as deprecated;
|
||||
@use "ds/_borders.scss" as *;
|
||||
@use "ds/_sizes.scss" as *;
|
||||
@use "ds/_utils.scss" as *;
|
||||
@use "ds/spacing.scss" as *;
|
||||
@use "ds/typography.scss" as *;
|
||||
|
||||
.editable-select {
|
||||
@extend %asset-element;
|
||||
@include use-typography("body-small");
|
||||
|
||||
--editable-select-background-color: var(--color-background-tertiary);
|
||||
|
||||
margin: 0;
|
||||
border: $b-1 solid var(--input-border-color);
|
||||
border: $b-1 solid var(--color-background-tertiary);
|
||||
position: relative;
|
||||
display: flex;
|
||||
height: $sz-32;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
inline-size: 100%;
|
||||
block-size: $sz-32;
|
||||
padding: var(--sp-s);
|
||||
border-radius: $br-8;
|
||||
cursor: pointer;
|
||||
background-color: var(--editable-select-background-color);
|
||||
color: var(--color-foreground-primary);
|
||||
|
||||
.dropdown-button {
|
||||
display: flex;
|
||||
place-content: center;
|
||||
|
||||
svg {
|
||||
@extend %button-icon-small;
|
||||
|
||||
transform: rotate(90deg);
|
||||
stroke: var(--icon-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.custom-select-dropdown {
|
||||
@extend %dropdown-wrapper;
|
||||
|
||||
width: max-content;
|
||||
max-height: px2rem(320); // TODO: when this gets addressed in the DS, use a token
|
||||
.separator {
|
||||
margin: 0;
|
||||
height: $sz-12;
|
||||
}
|
||||
|
||||
.dropdown-element {
|
||||
@extend %dropdown-element-base;
|
||||
|
||||
color: var(--menu-foreground-color-rest);
|
||||
|
||||
.label {
|
||||
flex-grow: 1;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.check-icon {
|
||||
display: flex;
|
||||
place-content: center;
|
||||
|
||||
svg {
|
||||
@extend %button-icon-small;
|
||||
|
||||
visibility: hidden;
|
||||
stroke: var(--icon-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
&.is-selected {
|
||||
color: var(--menu-foreground-color);
|
||||
|
||||
.check-icon svg {
|
||||
stroke: var(--menu-foreground-color);
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--menu-background-color-hover);
|
||||
color: var(--menu-foreground-color-hover);
|
||||
|
||||
.check-icon svg {
|
||||
stroke: var(--menu-foreground-color-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
--editable-select-background-color: var(--color-background-quaternary);
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-button {
|
||||
display: flex;
|
||||
place-content: center;
|
||||
}
|
||||
|
||||
.dropdown-icon {
|
||||
color: var(--color-foreground-secondary);
|
||||
}
|
||||
|
||||
.custom-select-dropdown {
|
||||
position: absolute;
|
||||
inset-block-start: $sz-32;
|
||||
inset-inline-start: 0;
|
||||
inline-size: 100%;
|
||||
min-inline-size: px2rem(70);
|
||||
max-block-size: px2rem(320); // TODO: when this gets addressed in the DS, use a token
|
||||
padding: var(--sp-xxs);
|
||||
margin: 0;
|
||||
margin-block-start: px2rem(1);
|
||||
border: $b-2 solid var(--color-background-quaternary);
|
||||
border-radius: $br-8;
|
||||
z-index: var(--z-index-dropdown);
|
||||
overflow: hidden auto;
|
||||
background-color: var(--color-background-tertiary);
|
||||
color: var(--color-foreground-primary);
|
||||
box-shadow: 0 0 $sz-12 0 var(--color-shadow-dark);
|
||||
}
|
||||
|
||||
.separator {
|
||||
margin: 0;
|
||||
block-size: $sz-12;
|
||||
}
|
||||
|
||||
.dropdown-element {
|
||||
--dropdown-element-color: var(--color-foreground-secondary);
|
||||
--dropdown-element-icon-color: var(--color-foreground-secondary);
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-s);
|
||||
block-size: $sz-32;
|
||||
padding-block: 0;
|
||||
padding-inline: var(--sp-s);
|
||||
border-radius: $br-6;
|
||||
cursor: pointer;
|
||||
color: var(--dropdown-element-color);
|
||||
|
||||
@include use-typography("body-small");
|
||||
|
||||
&.is-selected {
|
||||
--dropdown-element-color: var(--color-foreground-primary);
|
||||
--dropdown-element-icon-color: var(--color-foreground-primary);
|
||||
|
||||
.check-tick {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
--dropdown-element-color: var(--color-foreground-primary);
|
||||
--dropdown-element-icon-color: var(--color-foreground-primary);
|
||||
|
||||
background-color: var(--color-background-quaternary);
|
||||
}
|
||||
}
|
||||
|
||||
.label,
|
||||
.check-icon {
|
||||
display: block;
|
||||
max-inline-size: 99%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.label {
|
||||
flex-grow: 1;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.check-icon {
|
||||
display: flex;
|
||||
place-content: center;
|
||||
}
|
||||
|
||||
.check-tick {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
fill: none;
|
||||
stroke-width: 1.33px;
|
||||
visibility: hidden;
|
||||
color: var(--dropdown-element-icon-color);
|
||||
}
|
||||
@@ -12,16 +12,18 @@
|
||||
[app.main.data.workspace.grid :as dw]
|
||||
[app.main.refs :as refs]
|
||||
[app.main.store :as st]
|
||||
[app.main.ui.components.dropdown :refer [dropdown]]
|
||||
[app.main.ui.components.editable-select :refer [editable-select]]
|
||||
[app.main.ui.components.numeric-input :as deprecated-input]
|
||||
[app.main.ui.components.select :refer [select]]
|
||||
[app.main.ui.components.title-bar :refer [title-bar*]]
|
||||
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
|
||||
[app.main.ui.ds.foundations.assets.icon :as i]
|
||||
[app.main.ui.icons :as deprecated-icon]
|
||||
[app.main.ui.ds.controls.numeric-input :refer [numeric-input*]]
|
||||
[app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i]
|
||||
[app.main.ui.workspace.sidebar.options.common :refer [advanced-options*]]
|
||||
[app.main.ui.workspace.sidebar.options.rows.color-row :refer [color-row*]]
|
||||
[app.util.dom :as dom]
|
||||
[app.util.i18n :as i18n :refer [tr]]
|
||||
[app.util.keyboard :as kbd]
|
||||
[okulary.core :as l]
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
@@ -33,6 +35,49 @@
|
||||
:separator
|
||||
18 12 10 8 6 4 3 2])
|
||||
|
||||
(mf/defc default-options-toggle*
|
||||
"Toggle button that opens the reset/save-as-default menu for a grid's
|
||||
params. Shared by the square, column and row grid-type layouts, each of
|
||||
which places it at a different point in their own layout."
|
||||
[{:keys [show disabled on-toggle]}]
|
||||
[:button {:class (stl/css-case :show-more-options true
|
||||
:selected show)
|
||||
:on-click on-toggle
|
||||
:disabled disabled}
|
||||
[:> icon* {:icon-id i/menu
|
||||
:size "m"
|
||||
:aria-hidden true
|
||||
:class (stl/css :show-options-icon)}]])
|
||||
|
||||
(mf/defc default-options-dropdown*
|
||||
"Dropdown menu with the reset/save-as-default options for a grid's params.
|
||||
Shared by the square, column and row grid-type layouts, each of which
|
||||
places it at a different point in their own layout (its `:class` controls
|
||||
the panel's positioning, which differs per layout)."
|
||||
[{:keys [class show on-close on-use-default on-set-as-default]}]
|
||||
(let [handle-key-down
|
||||
(fn [action]
|
||||
(fn [event]
|
||||
(when (or (kbd/enter? event) (kbd/space? event))
|
||||
(dom/prevent-default event)
|
||||
(action))))]
|
||||
[:& dropdown {:show show
|
||||
:on-close on-close}
|
||||
[:ul {:class class
|
||||
:role "menu"}
|
||||
[:li {:class (stl/css :option-btn)
|
||||
:role "menuitem"
|
||||
:tab-index 0
|
||||
:on-click on-use-default
|
||||
:on-key-down (handle-key-down on-use-default)}
|
||||
(tr "workspace.options.grid.params.use-default")]
|
||||
[:li {:class (stl/css :option-btn)
|
||||
:role "menuitem"
|
||||
:tab-index 0
|
||||
:on-click on-set-as-default
|
||||
:on-key-down (handle-key-down on-set-as-default)}
|
||||
(tr "workspace.options.grid.params.set-default")]]]))
|
||||
|
||||
(mf/defc grid-options*
|
||||
{::mf/wrap [mf/memo]}
|
||||
[{:keys [shape-id index grid frame-width frame-height default-grid-params]}]
|
||||
@@ -151,7 +196,10 @@
|
||||
[:button {:class (stl/css-case :show-options true
|
||||
:selected open?)
|
||||
:on-click toggle-advanced-options}
|
||||
deprecated-icon/menu]
|
||||
[:> icon* {:icon-id i/menu
|
||||
:size "m"
|
||||
:aria-hidden true
|
||||
:class (stl/css :show-options-icon)}]]
|
||||
[:div {:class (stl/css :type-select-wrapper)}
|
||||
[:& select
|
||||
{:class (stl/css :grid-type-select)
|
||||
@@ -163,11 +211,10 @@
|
||||
(if (= type :square)
|
||||
[:div {:class (stl/css :grid-size)
|
||||
:title (tr "workspace.options.size")}
|
||||
[:> deprecated-input/numeric-input* {:min 0.01
|
||||
:value (or (:size params) "")
|
||||
:no-validate true
|
||||
:class (stl/css :numeric-input)
|
||||
:on-change (handle-change :params :size)}]]
|
||||
[:> numeric-input* {:min 0.01
|
||||
:value (or (:size params) "")
|
||||
:inner-class (stl/css :numeric-input)
|
||||
:on-change (handle-change :params :size)}]]
|
||||
|
||||
[:div {:class (stl/css :editable-select-wrapper)}
|
||||
[:& editable-select {:value (:size params)
|
||||
@@ -204,22 +251,14 @@
|
||||
:origin :guides
|
||||
:on-change handle-change-color
|
||||
:on-detach handle-detach-color}]
|
||||
[:button {:class (stl/css-case :show-more-options true
|
||||
:selected show-more-options?)
|
||||
:on-click toggle-more-options}
|
||||
deprecated-icon/menu]]
|
||||
(when show-more-options?
|
||||
[:div {:class (stl/css :second-row)}
|
||||
[:button {:class (stl/css-case :btn-options true
|
||||
:disabled is-default)
|
||||
:disabled is-default
|
||||
:on-click handle-use-default}
|
||||
[:span (tr "workspace.options.grid.params.use-default")]]
|
||||
[:button {:class (stl/css-case :btn-options true
|
||||
:disabled is-default)
|
||||
:disabled is-default
|
||||
:on-click handle-set-as-default}
|
||||
[:span (tr "workspace.options.grid.params.set-default")]]])])
|
||||
[:> default-options-toggle* {:show show-more-options?
|
||||
:disabled is-default
|
||||
:on-toggle toggle-more-options}]]
|
||||
[:> default-options-dropdown* {:class (stl/css :second-row)
|
||||
:show show-more-options?
|
||||
:on-close close-more-options
|
||||
:on-use-default handle-use-default
|
||||
:on-set-as-default handle-set-as-default}]])
|
||||
|
||||
(when (or (= :column type) (= :row type))
|
||||
[:div {:class (stl/css :column-row)}
|
||||
@@ -252,49 +291,39 @@
|
||||
:title (if (= :row type)
|
||||
(tr "workspace.options.grid.params.height")
|
||||
(tr "workspace.options.grid.params.width"))}
|
||||
[:span {:class (stl/css :icon-text)}
|
||||
(if (= :row type)
|
||||
"H"
|
||||
"W")]
|
||||
[:> deprecated-input/numeric-input* {:placeholder "Auto"
|
||||
:on-change handle-change-item-length
|
||||
:is-nillable true
|
||||
:class (stl/css :numeric-input)
|
||||
:value (or (:item-length params) "")}]]
|
||||
[:> numeric-input* {:placeholder "Auto"
|
||||
:on-change handle-change-item-length
|
||||
:nillable true
|
||||
:icon (if (= :row type) i/character-h i/character-w)
|
||||
:inner-class (stl/css :numeric-input)
|
||||
:value (or (:item-length params) "")}]]
|
||||
|
||||
[:div {:class (stl/css :gutter)
|
||||
:title (tr "workspace.options.grid.params.gutter")}
|
||||
[:span {:class (stl/css-case :icon true
|
||||
:rotated (= type :row))}
|
||||
deprecated-icon/gap-horizontal]
|
||||
[:> deprecated-input/numeric-input* {:placeholder "0"
|
||||
:on-change (handle-change :params :gutter)
|
||||
:is-nillable true
|
||||
:class (stl/css :numeric-input)
|
||||
:value (or (:gutter params) 0)}]]
|
||||
[:> numeric-input* {:placeholder "0"
|
||||
:on-change (handle-change :params :gutter)
|
||||
:nillable true
|
||||
:icon (if (= type :row) i/gap-vertical i/gap-horizontal)
|
||||
:inner-class (stl/css :numeric-input)
|
||||
:value (or (:gutter params) 0)}]]
|
||||
|
||||
[:div {:class (stl/css :margin)
|
||||
:title (tr "workspace.options.grid.params.margin")}
|
||||
[:span {:class (stl/css-case :icon true
|
||||
:rotated (= type :column))}
|
||||
deprecated-icon/grid-margin]
|
||||
[:> deprecated-input/numeric-input* {:placeholder "0"
|
||||
:on-change (handle-change :params :margin)
|
||||
:is-nillable true
|
||||
:class (stl/css :numeric-input)
|
||||
:value (or (:margin params) 0)}]]
|
||||
[:> numeric-input* {:placeholder "0"
|
||||
:on-change (handle-change :params :margin)
|
||||
:nillable true
|
||||
:icon (if (= type :column) i/margin-left-right i/margin-top-bottom)
|
||||
:inner-class (stl/css :numeric-input)
|
||||
:value (or (:margin params) 0)}]]
|
||||
|
||||
[:button {:class (stl/css-case :show-more-options true
|
||||
:selected show-more-options?)
|
||||
:on-click toggle-more-options
|
||||
:disabled is-default}
|
||||
deprecated-icon/menu]
|
||||
(when show-more-options?
|
||||
[:div {:class (stl/css :more-options)}
|
||||
[:button {:class (stl/css :option-btn)
|
||||
:on-click handle-use-default} (tr "workspace.options.grid.params.use-default")]
|
||||
[:button {:class (stl/css :option-btn)
|
||||
:on-click handle-set-as-default} (tr "workspace.options.grid.params.set-default")]])]])])]))
|
||||
[:> default-options-toggle* {:show show-more-options?
|
||||
:disabled is-default
|
||||
:on-toggle toggle-more-options}]
|
||||
[:> default-options-dropdown* {:class (stl/css :more-options)
|
||||
:show show-more-options?
|
||||
:on-close close-more-options
|
||||
:on-use-default handle-use-default
|
||||
:on-set-as-default handle-set-as-default}]]])])]))
|
||||
|
||||
(defn- check-frame-grid-props
|
||||
[old-props new-props]
|
||||
|
||||
@@ -4,9 +4,24 @@
|
||||
//
|
||||
// Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
@use "refactor/common-refactor.scss" as deprecated;
|
||||
@use "ds/_borders.scss" as *;
|
||||
@use "ds/_sizes.scss" as *;
|
||||
@use "ds/_utils.scss" as *;
|
||||
@use "ds/z-index.scss" as *;
|
||||
@use "ds/spacing.scss" as *;
|
||||
@use "ds/typography.scss" as *;
|
||||
@use "../../../sidebar/common/sidebar.scss" as sidebar;
|
||||
|
||||
// Shared by every disabled-looking control in the .hidden state below.
|
||||
@mixin hidden-control {
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
box-sizing: border-box;
|
||||
color: var(--color-foreground-secondary);
|
||||
stroke: var(--color-foreground-secondary);
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.element-set {
|
||||
@include sidebar.option-grid-structure;
|
||||
}
|
||||
@@ -16,15 +31,17 @@
|
||||
}
|
||||
|
||||
.title-spacing-board-grid {
|
||||
padding-left: deprecated.$s-2;
|
||||
padding-inline-start: var(--sp-xxs);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.element-set-content {
|
||||
@include deprecated.flex-column;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-xs);
|
||||
grid-column: span 8;
|
||||
margin: deprecated.$s-4 0 deprecated.$s-8 0;
|
||||
margin-block: var(--sp-xs) var(--sp-s);
|
||||
margin-inline: 0;
|
||||
}
|
||||
|
||||
.grid-title {
|
||||
@@ -35,266 +52,388 @@
|
||||
grid-column: span 6;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: deprecated.$s-1;
|
||||
border-radius: deprecated.$br-8;
|
||||
background-color: var(--input-details-color);
|
||||
gap: px2rem(1);
|
||||
border-radius: $br-8;
|
||||
background-color: var(--color-background-primary);
|
||||
}
|
||||
|
||||
.show-options {
|
||||
@extend %button-secondary;
|
||||
.show-options {
|
||||
--show-options-background-color: var(--color-background-tertiary);
|
||||
--show-options-border-color: var(--color-background-tertiary);
|
||||
--show-options-color: var(--color-foreground-secondary);
|
||||
|
||||
height: deprecated.$s-32;
|
||||
width: deprecated.$s-28;
|
||||
border-radius: deprecated.$br-8 0 0 deprecated.$br-8;
|
||||
box-sizing: border-box;
|
||||
border: deprecated.$s-1 solid var(--input-border-color);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
block-size: $sz-32;
|
||||
inline-size: $sz-28;
|
||||
border-radius: $br-8 0 0 $br-8;
|
||||
box-sizing: border-box;
|
||||
border: $b-1 solid var(--show-options-border-color);
|
||||
background-color: var(--show-options-background-color);
|
||||
color: var(--show-options-color);
|
||||
|
||||
svg {
|
||||
@extend %button-icon;
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
|
||||
&.selected {
|
||||
@extend %button-icon-selected;
|
||||
}
|
||||
--show-options-border-color: var(--color-accent-primary);
|
||||
}
|
||||
|
||||
.type-select-wrapper {
|
||||
flex-grow: 1;
|
||||
width: deprecated.$s-96;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
height: deprecated.$s-32;
|
||||
&:hover {
|
||||
--show-options-background-color: var(--color-background-quaternary);
|
||||
--show-options-border-color: var(--color-background-quaternary);
|
||||
--show-options-color: var(--color-accent-primary);
|
||||
|
||||
.grid-type-select {
|
||||
border-radius: 0;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
border: deprecated.$s-1 solid var(--input-border-color);
|
||||
|
||||
&:hover {
|
||||
border: deprecated.$s-1 solid var(--input-border-color-hover);
|
||||
}
|
||||
}
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.grid-size {
|
||||
@extend %asset-element;
|
||||
&:active {
|
||||
outline: none;
|
||||
|
||||
width: deprecated.$s-60;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
padding-left: deprecated.$s-8;
|
||||
border-radius: 0 deprecated.$br-8 deprecated.$br-8 0;
|
||||
|
||||
.numeric-input {
|
||||
@extend %input-base;
|
||||
@include deprecated.body-small-typography;
|
||||
}
|
||||
--show-options-background-color: var(--color-background-secondary);
|
||||
--show-options-border-color: var(--color-background-quaternary);
|
||||
--show-options-color: var(--color-accent-primary);
|
||||
}
|
||||
|
||||
.editable-select-wrapper {
|
||||
@extend %asset-element;
|
||||
&[disabled],
|
||||
&:disabled {
|
||||
--show-options-background-color: var(--color-background-quaternary);
|
||||
--show-options-border-color: var(--color-background-quaternary);
|
||||
--show-options-color: var(--color-foreground-disabled);
|
||||
|
||||
width: deprecated.$s-60;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
border-radius: 0 deprecated.$br-8 deprecated.$br-8 0;
|
||||
|
||||
.column-select {
|
||||
height: deprecated.$s-32;
|
||||
border-radius: 0 deprecated.$br-8 deprecated.$br-8 0;
|
||||
box-sizing: border-box;
|
||||
border: deprecated.$s-1 solid var(--input-border-color);
|
||||
|
||||
.numeric-input {
|
||||
@extend %input-base;
|
||||
@include deprecated.body-small-typography;
|
||||
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
span {
|
||||
@include deprecated.flex-center;
|
||||
|
||||
svg {
|
||||
@extend %button-icon;
|
||||
}
|
||||
}
|
||||
}
|
||||
cursor: unset;
|
||||
}
|
||||
|
||||
&.hidden {
|
||||
.show-options {
|
||||
@include deprecated.hidden-element;
|
||||
&.selected {
|
||||
outline: none;
|
||||
|
||||
border: deprecated.$s-1 solid var(--input-border-color-disabled);
|
||||
}
|
||||
|
||||
.type-select-wrapper,
|
||||
.editable-select-wrapper {
|
||||
@include deprecated.hidden-element;
|
||||
|
||||
.column-select,
|
||||
.grid-type-select {
|
||||
@include deprecated.hidden-element;
|
||||
|
||||
border: deprecated.$s-1 solid var(--input-border-color-disabled);
|
||||
}
|
||||
|
||||
.column-select {
|
||||
@include deprecated.hidden-element;
|
||||
|
||||
border-radius: 0 deprecated.$br-8 deprecated.$br-8 0;
|
||||
|
||||
.numeric-input {
|
||||
@include deprecated.hidden-element;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.grid-size {
|
||||
@include deprecated.hidden-element;
|
||||
|
||||
border: deprecated.$s-1 solid var(--input-border-color-disabled);
|
||||
|
||||
.icon {
|
||||
stroke: var(--input-foreground-color-disabled);
|
||||
}
|
||||
|
||||
.numeric-input {
|
||||
color: var(--input-foreground-color-disabled);
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
.hidden-btn,
|
||||
.lock-btn {
|
||||
background-color: transparent;
|
||||
|
||||
svg {
|
||||
stroke: var(--input-foreground-color-disabled);
|
||||
}
|
||||
}
|
||||
}
|
||||
--show-options-background-color: var(--color-background-quaternary);
|
||||
--show-options-border-color: var(--color-background-quaternary);
|
||||
--show-options-color: var(--color-accent-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
@include deprecated.flex-row;
|
||||
.type-select-wrapper {
|
||||
flex-grow: 1;
|
||||
inline-size: $sz-96;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
block-size: $sz-32;
|
||||
}
|
||||
|
||||
.type-select-wrapper .grid-type-select {
|
||||
--grid-type-select-border-color: var(--color-background-tertiary);
|
||||
|
||||
border-radius: 0;
|
||||
block-size: 100%;
|
||||
box-sizing: border-box;
|
||||
border: $b-1 solid var(--grid-type-select-border-color);
|
||||
|
||||
&:hover {
|
||||
--grid-type-select-border-color: var(--color-background-quaternary);
|
||||
}
|
||||
}
|
||||
|
||||
.grid-size {
|
||||
@include use-typography("body-small");
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
block-size: $sz-32;
|
||||
inline-size: px2rem(60);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
padding-inline-start: var(--sp-s);
|
||||
border-radius: 0 $br-8 $br-8 0;
|
||||
background-color: var(--color-background-tertiary);
|
||||
color: var(--color-foreground-primary);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-background-quaternary);
|
||||
color: var(--color-foreground-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.grid-size .numeric-input {
|
||||
--input-height: #{$sz-28};
|
||||
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.editable-select-wrapper {
|
||||
inline-size: px2rem(60);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.editable-select-wrapper .column-select {
|
||||
block-size: $sz-32;
|
||||
border-radius: 0 $br-8 $br-8 0;
|
||||
box-sizing: border-box;
|
||||
border: $b-1 solid var(--color-background-tertiary);
|
||||
}
|
||||
|
||||
.column-select .numeric-input {
|
||||
@include use-typography("body-small");
|
||||
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
outline: none;
|
||||
display: block;
|
||||
max-inline-size: 99%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
block-size: $sz-28;
|
||||
inline-size: 100%;
|
||||
flex-grow: 1;
|
||||
border-radius: $br-8;
|
||||
color: var(--color-foreground-primary);
|
||||
|
||||
&[disabled] {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.hidden .show-options {
|
||||
@include hidden-control;
|
||||
|
||||
border: $b-1 solid var(--color-background-quaternary);
|
||||
}
|
||||
|
||||
.hidden .type-select-wrapper,
|
||||
.hidden .editable-select-wrapper {
|
||||
@include hidden-control;
|
||||
}
|
||||
|
||||
.hidden .column-select,
|
||||
.hidden .grid-type-select {
|
||||
@include hidden-control;
|
||||
|
||||
border: $b-1 solid var(--color-background-quaternary);
|
||||
}
|
||||
|
||||
.hidden .column-select {
|
||||
border-radius: 0 $br-8 $br-8 0;
|
||||
}
|
||||
|
||||
.hidden .column-select .numeric-input {
|
||||
@include hidden-control;
|
||||
}
|
||||
|
||||
.hidden .grid-size {
|
||||
@include hidden-control;
|
||||
|
||||
border: $b-1 solid var(--color-background-quaternary);
|
||||
}
|
||||
|
||||
.hidden .grid-size .numeric-input {
|
||||
--input-fg-color: var(--color-foreground-secondary);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-xs);
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.grid-advanced-options {
|
||||
@include deprecated.flex-column;
|
||||
|
||||
margin-top: deprecated.$s-4;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-xs);
|
||||
margin-block-start: var(--sp-xs);
|
||||
}
|
||||
|
||||
.column-row,
|
||||
.square-row {
|
||||
@include deprecated.flex-column;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-xs);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.advanced-row {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: deprecated.$s-4;
|
||||
gap: var(--sp-xs);
|
||||
}
|
||||
|
||||
.orientation-select-wrapper {
|
||||
width: deprecated.$s-92;
|
||||
padding: 0;
|
||||
.orientation-select-wrapper {
|
||||
inline-size: px2rem(92);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.color-wrapper {
|
||||
inline-size: px2rem(156);
|
||||
}
|
||||
|
||||
.show-more-options {
|
||||
--show-more-options-background-color: transparent;
|
||||
--show-more-options-border-color: transparent;
|
||||
--show-more-options-color: var(--color-foreground-secondary);
|
||||
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
block-size: $sz-32;
|
||||
inline-size: $sz-32;
|
||||
border-radius: $br-8;
|
||||
background-color: var(--show-more-options-background-color);
|
||||
border: $b-2 solid var(--show-more-options-border-color);
|
||||
color: var(--show-more-options-color);
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
border: $b-1 solid var(--color-accent-primary);
|
||||
|
||||
--show-more-options-background-color: var(--color-background-tertiary);
|
||||
--show-more-options-color: var(--color-foreground-primary);
|
||||
}
|
||||
|
||||
.color-wrapper {
|
||||
width: deprecated.$s-156;
|
||||
&:hover {
|
||||
--show-more-options-background-color: var(--color-background-quaternary);
|
||||
--show-more-options-border-color: var(--color-background-quaternary);
|
||||
--show-more-options-color: var(--color-accent-primary);
|
||||
}
|
||||
|
||||
.show-more-options {
|
||||
@extend %button-tertiary;
|
||||
&:active {
|
||||
outline: none;
|
||||
|
||||
height: deprecated.$s-32;
|
||||
width: deprecated.$s-32;
|
||||
|
||||
svg {
|
||||
@extend %button-icon;
|
||||
}
|
||||
|
||||
&.selected {
|
||||
@extend %button-icon-selected;
|
||||
}
|
||||
--show-more-options-background-color: var(--color-background-secondary);
|
||||
--show-more-options-border-color: transparent;
|
||||
--show-more-options-color: var(--color-accent-primary);
|
||||
}
|
||||
|
||||
.height {
|
||||
@extend %input-element;
|
||||
@include deprecated.body-small-typography;
|
||||
&[disabled],
|
||||
&:disabled {
|
||||
--show-more-options-color: var(--color-foreground-disabled);
|
||||
|
||||
.icon-text {
|
||||
padding-top: deprecated.$s-1;
|
||||
}
|
||||
cursor: unset;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.gutter,
|
||||
.margin {
|
||||
@extend %input-element;
|
||||
@include deprecated.body-small-typography;
|
||||
&.selected {
|
||||
outline: none;
|
||||
|
||||
.icon {
|
||||
&.rotated svg {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
--show-more-options-background-color: var(--color-background-quaternary);
|
||||
--show-more-options-border-color: var(--color-background-quaternary);
|
||||
--show-more-options-color: var(--color-accent-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.show-more-options svg {
|
||||
stroke: var(--show-more-options-color);
|
||||
}
|
||||
|
||||
.height,
|
||||
.gutter,
|
||||
.margin {
|
||||
--grid-param-input-color: var(--color-foreground-secondary);
|
||||
--grid-param-input-background-color: var(--color-background-tertiary);
|
||||
--grid-param-input-border-color: var(--color-background-tertiary);
|
||||
|
||||
@include use-typography("body-small");
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
block-size: $sz-32;
|
||||
border-radius: $br-8;
|
||||
background-color: var(--grid-param-input-background-color);
|
||||
border: $b-1 solid var(--grid-param-input-border-color);
|
||||
color: var(--grid-param-input-color);
|
||||
|
||||
&:hover {
|
||||
--grid-param-input-color: var(--color-foreground-primary);
|
||||
--grid-param-input-background-color: var(--color-background-quaternary);
|
||||
--grid-param-input-border-color: var(--color-background-quaternary);
|
||||
}
|
||||
|
||||
.more-options {
|
||||
@include deprecated.menu-shadow;
|
||||
@include deprecated.flex-column;
|
||||
&:active,
|
||||
&:focus,
|
||||
&:focus-within {
|
||||
--grid-param-input-color: var(--color-foreground-primary);
|
||||
--grid-param-input-background-color: var(--color-background-primary);
|
||||
--grid-param-input-border-color: var(--color-accent-primary);
|
||||
}
|
||||
|
||||
position: absolute;
|
||||
top: calc(deprecated.$s-2 + deprecated.$s-28);
|
||||
right: 0;
|
||||
width: deprecated.$s-156;
|
||||
max-height: deprecated.$s-300;
|
||||
padding: deprecated.$s-2;
|
||||
margin: 0 0 deprecated.$s-40 0;
|
||||
margin-top: deprecated.$s-4;
|
||||
border-radius: deprecated.$br-8;
|
||||
z-index: deprecated.$z-index-4;
|
||||
overflow-y: auto;
|
||||
background-color: var(--menu-background-color);
|
||||
&:focus,
|
||||
&:focus-within {
|
||||
--grid-param-input-background-color: var(--color-background-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
.option-btn {
|
||||
@include deprecated.button-style;
|
||||
.height .numeric-input,
|
||||
.gutter .numeric-input,
|
||||
.margin .numeric-input {
|
||||
--input-height: #{$sz-28};
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: deprecated.$s-32;
|
||||
padding: 0 deprecated.$s-8;
|
||||
border-radius: deprecated.$br-6;
|
||||
color: var(--menu-foreground-color);
|
||||
flex-grow: 1;
|
||||
margin-block: var(--sp-xxs);
|
||||
margin-inline: 0;
|
||||
padding-inline-start: $sz-6;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--menu-background-color-hover);
|
||||
color: var(--menu-foreground-color-hover);
|
||||
}
|
||||
}
|
||||
.more-options {
|
||||
box-shadow: 0 0 $sz-12 0 var(--color-shadow-dark);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-xs);
|
||||
position: absolute;
|
||||
inset-block-start: calc(var(--sp-xxs) + $sz-28);
|
||||
inset-inline-end: 0;
|
||||
inline-size: px2rem(156);
|
||||
max-block-size: px2rem(300); // TODO: when this gets addressed in the DS, use a token
|
||||
padding: var(--sp-xxs);
|
||||
margin-block: var(--sp-xs) $sz-40;
|
||||
margin-inline: 0;
|
||||
border-radius: $br-8;
|
||||
z-index: var(--z-index-dropdown);
|
||||
overflow-y: auto;
|
||||
background-color: var(--color-background-tertiary);
|
||||
}
|
||||
|
||||
.option-btn {
|
||||
@include use-typography("body-small");
|
||||
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
block-size: $sz-32;
|
||||
padding-inline: var(--sp-s);
|
||||
border-radius: $br-6;
|
||||
color: var(--color-foreground-primary);
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background-color: var(--color-background-quaternary);
|
||||
color: var(--color-foreground-primary);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.second-row {
|
||||
@extend %dropdown-wrapper;
|
||||
|
||||
left: unset;
|
||||
right: 0;
|
||||
width: deprecated.$s-108;
|
||||
|
||||
.btn-options {
|
||||
@include deprecated.button-style;
|
||||
@extend %dropdown-element-base;
|
||||
|
||||
width: 100%;
|
||||
}
|
||||
box-shadow: 0 0 $sz-12 0 var(--color-shadow-dark);
|
||||
position: absolute;
|
||||
inset-block-start: $sz-32;
|
||||
inset-inline-start: unset;
|
||||
inset-inline-end: 0;
|
||||
inline-size: px2rem(108);
|
||||
max-block-size: var(--menu-max-height, px2rem(300));
|
||||
padding: var(--sp-xxs);
|
||||
margin: 0;
|
||||
margin-block-start: px2rem(1);
|
||||
border-radius: $br-8;
|
||||
z-index: var(--z-index-dropdown);
|
||||
overflow: hidden auto;
|
||||
background-color: var(--color-background-tertiary);
|
||||
color: var(--color-foreground-primary);
|
||||
}
|
||||
@@ -880,6 +880,25 @@
|
||||
(h/call wasm/internal-module "_store_image")
|
||||
true)))))
|
||||
|
||||
(defn- store-image-url!
|
||||
"Registers the public URL an image was loaded from so SVG export can emit a
|
||||
linked `<image href>` instead of a Skia base64 embed."
|
||||
[image-id url]
|
||||
(when (and (wasm/live?) (some? url) (not (str/blank? url)))
|
||||
(let [buffer (uuid/get-u32 image-id)
|
||||
encoder (js/TextEncoder.)
|
||||
encoded (.encode encoder url)
|
||||
size (.-byteLength encoded)
|
||||
offset (mem/alloc size)
|
||||
heap (mem/get-heap-u8)]
|
||||
(.set heap encoded offset)
|
||||
(h/call wasm/internal-module "_store_image_url"
|
||||
(aget buffer 0)
|
||||
(aget buffer 1)
|
||||
(aget buffer 2)
|
||||
(aget buffer 3))
|
||||
true)))
|
||||
|
||||
(defn- store-image-texture
|
||||
"Creates a WebGL texture from a decoded image and passes the texture ID to
|
||||
WASM. This avoids decoding the image twice (once in browser, once in WASM)."
|
||||
@@ -922,6 +941,7 @@
|
||||
so Skia rasterizes them."
|
||||
[shape-id image-id thumbnail?]
|
||||
(let [url (cf/resolve-file-media {:id image-id} thumbnail?)]
|
||||
(store-image-url! image-id url)
|
||||
{:key url
|
||||
:thumbnail? thumbnail?
|
||||
:callback
|
||||
@@ -959,6 +979,8 @@
|
||||
(aget buffer 2)
|
||||
(aget buffer 3)
|
||||
thumbnail?)]
|
||||
;; Always register the URL (SVG export needs it even when bytes are cached).
|
||||
(store-image-url! id (cf/resolve-file-media {:id id} thumbnail?))
|
||||
(when (zero? cached-image?)
|
||||
(fetch-image shape-id id thumbnail?)))))
|
||||
|
||||
@@ -993,6 +1015,7 @@
|
||||
(aget buffer 2)
|
||||
(aget buffer 3)
|
||||
thumbnail?)]
|
||||
(store-image-url! id (cf/resolve-file-media {:id id} thumbnail?))
|
||||
(when (zero? cached-image?)
|
||||
(fetch-image shape-id id thumbnail?))))
|
||||
(types.fills/get-image-ids fills))))))
|
||||
@@ -1021,6 +1044,7 @@
|
||||
(aget buffer 2)
|
||||
(aget buffer 3)
|
||||
thumbnail?)]
|
||||
(store-image-url! image-id (cf/resolve-file-media {:id image-id} thumbnail?))
|
||||
(when (zero? cached-image?)
|
||||
(fetch-image shape-id image-id thumbnail?))))
|
||||
image-ids))))
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
- **plugins-runtime**: `Library.createComponent()` now rejects invalid input (an empty shape list, or a shape inside a component copy) with a validation error instead of returning a component proxy pointing at nothing.
|
||||
- **plugins-runtime**: Setting an individual padding/margin side (`leftPadding`, `topMargin`, …) now re-derives the padding/margin type, switching to `multiple` when the four sides stop being symmetric (so the value is actually painted) and back to `simple` once top/bottom and left/right are mirrored again.
|
||||
- **plugins-runtime**: Removed the premature deep-hardening of the host plugin context, which froze shared host functions (including `Function.prototype`) before SES override taming, causing `TypeError: Cannot assign to read only property 'toString'` on later host-side function extension. Related to #11001.
|
||||
|
||||
## 1.5.0 (2026-07-08)
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, it, vi, expect, beforeEach } from 'vitest';
|
||||
import { loadPlugin, setContextBuilder, getPlugins } from './load-plugin';
|
||||
import { createPlugin } from './create-plugin';
|
||||
import { ses } from './ses.js';
|
||||
import type { Context } from '@penpot/plugin-types';
|
||||
import type { Manifest } from './models/manifest.model.js';
|
||||
|
||||
vi.mock('./create-plugin', () => ({
|
||||
createPlugin: vi.fn(),
|
||||
}));
|
||||
|
||||
// NOTE: `./ses.js` is intentionally NOT mocked here: the test spies on the
|
||||
// real `ses.harden` to assert that `loadPlugin` never hardens the host
|
||||
// context.
|
||||
|
||||
describe('loadPlugin host context boundary (regression for #11001)', () => {
|
||||
let manifest: Manifest;
|
||||
|
||||
beforeEach(() => {
|
||||
manifest = {
|
||||
pluginId: 'test-plugin',
|
||||
name: 'Test Plugin',
|
||||
host: '',
|
||||
code: '',
|
||||
permissions: ['content:read'],
|
||||
};
|
||||
|
||||
vi.mocked(createPlugin).mockResolvedValue({
|
||||
plugin: {
|
||||
close: vi.fn(),
|
||||
sendMessage: vi.fn(),
|
||||
},
|
||||
} as unknown as Awaited<ReturnType<typeof createPlugin>>);
|
||||
});
|
||||
|
||||
it('does not freeze host-owned functions reachable through the context', async () => {
|
||||
const hostListener = function hostListener() {
|
||||
return 'host-value';
|
||||
};
|
||||
const nestedHostObject = {
|
||||
nestedFn() {
|
||||
return 'nested';
|
||||
},
|
||||
};
|
||||
const hostContext = {
|
||||
addListener: hostListener,
|
||||
nested: nestedHostObject,
|
||||
} as unknown as Context;
|
||||
|
||||
setContextBuilder(() => hostContext);
|
||||
|
||||
const hardenSpy = vi.spyOn(ses, 'harden');
|
||||
|
||||
await loadPlugin(manifest);
|
||||
|
||||
// The host context itself must be passed through untouched so the host
|
||||
// can keep modifying its own runtime objects (e.g. on page navigation).
|
||||
expect(createPlugin).toHaveBeenCalledWith(
|
||||
hostContext,
|
||||
manifest,
|
||||
expect.any(Function),
|
||||
undefined,
|
||||
);
|
||||
|
||||
// Host-owned functions must remain extensible: page navigation and
|
||||
// runtime code may patch/augment them (e.g. assigning `toString` on a
|
||||
// wrapped listener). A deep `ses.harden(context)` here would freeze
|
||||
// them and turn such later assignments into
|
||||
// `TypeError: Cannot assign to read only property 'toString'`.
|
||||
expect(Object.isFrozen(hostListener)).toBe(false);
|
||||
expect(Object.isExtensible(hostListener)).toBe(true);
|
||||
expect(Object.isFrozen(nestedHostObject)).toBe(false);
|
||||
expect(() => {
|
||||
hostListener.toString = () => 'patched-by-host';
|
||||
}).not.toThrow();
|
||||
|
||||
expect(hardenSpy).not.toHaveBeenCalled();
|
||||
expect(getPlugins()).toHaveLength(1);
|
||||
|
||||
hardenSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
import { describe, it, vi, expect, beforeAll } from 'vitest';
|
||||
import 'ses';
|
||||
import { loadPlugin, setContextBuilder, getPlugins } from './load-plugin';
|
||||
import type { Context } from '@penpot/plugin-types';
|
||||
import type { Manifest } from './models/manifest.model.js';
|
||||
|
||||
// Real initialization-path regression tests for #11001.
|
||||
//
|
||||
// NOTE: `./create-plugin`, `./plugin-manager` and
|
||||
// `./create-sandbox` are intentionally NOT mocked here. This spec exercises
|
||||
// the real `loadPlugin → createPlugin → createPluginManager → createSandbox`
|
||||
// path with the real SES implementation, mirroring the production
|
||||
// initialization order from `plugins-runtime/src/index.ts`:
|
||||
// repairIntrinsics (module load) → loadPlugin → createSandbox/hardenIntrinsics
|
||||
//
|
||||
// `hardenIntrinsics()` is deliberately NOT called up front: the first test
|
||||
// must run in the production window where only `repairIntrinsics` has run.
|
||||
// Tests run in declaration order; the later tests build on the locked-down
|
||||
// state the first real `loadPlugin` leaves behind (via `createSandbox`).
|
||||
//
|
||||
// Note on SES isolation: this suite depends on Vitest's default
|
||||
// file-level isolation. Each test file runs in a separate worker
|
||||
// process, so SES intrinsics frozen here do not leak into other
|
||||
// spec files.
|
||||
|
||||
const REPAIR_OPTIONS = {
|
||||
evalTaming: 'unsafeEval',
|
||||
stackFiltering: 'verbose',
|
||||
errorTaming: 'unsafe',
|
||||
consoleTaming: 'unsafe',
|
||||
errorTrapping: 'none',
|
||||
unhandledRejectionTrapping: 'none',
|
||||
};
|
||||
|
||||
function makeManifest(
|
||||
code: string,
|
||||
permissions: Manifest['permissions'],
|
||||
): Manifest {
|
||||
return {
|
||||
pluginId: 'test-plugin',
|
||||
name: 'Test Plugin',
|
||||
host: '',
|
||||
code,
|
||||
permissions,
|
||||
};
|
||||
}
|
||||
|
||||
function makeHostFixture() {
|
||||
const listenerTypes: string[] = [];
|
||||
const listeners = new Map<symbol, string>();
|
||||
// Inline code (empty host + non-URL code) resolves without network, so no
|
||||
// fetch mock is needed. UI/modal APIs are never touched by the probe code.
|
||||
const createRectangle = vi.fn(() => ({ type: 'rectangle-marker' }));
|
||||
const selection: object[] = [{ id: 'shape-1' }];
|
||||
const context = {
|
||||
addListener: (type: string, _callback: (...args: unknown[]) => unknown) => {
|
||||
const id = Symbol(type);
|
||||
listeners.set(id, type);
|
||||
listenerTypes.push(type);
|
||||
return id;
|
||||
},
|
||||
removeListener: (id: symbol) => {
|
||||
listeners.delete(id);
|
||||
},
|
||||
theme: 'dark',
|
||||
createRectangle,
|
||||
selection,
|
||||
// Host-only member: present on the raw context but NOT part of the
|
||||
// public penpot API. Plugin code must never see it (see B-2 below).
|
||||
__internalSecret: 'host-internal',
|
||||
} as unknown as Context;
|
||||
return { context, listenerTypes, createRectangle, selection };
|
||||
}
|
||||
|
||||
function lastCompartmentGlobalThis(): Record<string, unknown> {
|
||||
const plugins = getPlugins();
|
||||
const last = plugins[plugins.length - 1] as unknown as {
|
||||
compartment: { compartment: { globalThis: Record<string, unknown> } };
|
||||
};
|
||||
return last.compartment.compartment.globalThis;
|
||||
}
|
||||
|
||||
describe('loadPlugin real initialization path (regression for #11001)', () => {
|
||||
beforeAll(() => {
|
||||
// Production module-load step only: repairs intrinsics WITHOUT
|
||||
// installing override taming, exactly like `index.ts` at import time.
|
||||
(
|
||||
globalThis as unknown as { repairIntrinsics(opts: object): void }
|
||||
).repairIntrinsics({ ...REPAIR_OPTIONS });
|
||||
});
|
||||
|
||||
it('loads through the real path and keeps host function augmentation working', async () => {
|
||||
const fixture = makeHostFixture();
|
||||
setContextBuilder(() => fixture.context);
|
||||
|
||||
await loadPlugin(
|
||||
makeManifest('penpot.on("finish", function () {});', ['content:read']),
|
||||
);
|
||||
|
||||
// The plugin code really ran inside the sandbox: the manager registers
|
||||
// `themechange` + `finish`, and the plugin code adds its own `finish`
|
||||
// listener through the public API.
|
||||
expect(fixture.listenerTypes).toEqual(['themechange', 'finish', 'finish']);
|
||||
expect(getPlugins()).toHaveLength(1);
|
||||
|
||||
// The user-facing behavior from #11001: host-side augmentation of a
|
||||
// fresh function (e.g. assigning `toString` during page navigation)
|
||||
// succeeds after a real plugin load.
|
||||
const freshWrapper = function freshWrapper() {
|
||||
return 'navigation-wrapper';
|
||||
};
|
||||
expect(() => {
|
||||
freshWrapper.toString = () => 'patched-by-runtime';
|
||||
}).not.toThrow();
|
||||
expect(fixture.listenerTypes.length).toBe(3);
|
||||
});
|
||||
|
||||
it('denies the write API without permission and leaves the host untouched', async () => {
|
||||
const fixture = makeHostFixture();
|
||||
setContextBuilder(() => fixture.context);
|
||||
|
||||
await expect(
|
||||
loadPlugin(makeManifest('penpot.createRectangle();', ['content:read'])),
|
||||
).rejects.toThrow(/content:write/);
|
||||
expect(fixture.createRectangle).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows the same write API with permission', async () => {
|
||||
const fixture = makeHostFixture();
|
||||
setContextBuilder(() => fixture.context);
|
||||
|
||||
await loadPlugin(
|
||||
makeManifest('penpot.createRectangle();', [
|
||||
'content:read',
|
||||
'content:write',
|
||||
]),
|
||||
);
|
||||
expect(fixture.createRectangle).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not expose raw host-only context members to plugin code', async () => {
|
||||
const fixture = makeHostFixture();
|
||||
setContextBuilder(() => fixture.context);
|
||||
|
||||
await loadPlugin(
|
||||
makeManifest('globalThis.__probe = typeof penpot.__internalSecret;', [
|
||||
'content:read',
|
||||
]),
|
||||
);
|
||||
// The public `penpot` object is a boundary proxy over a curated API, not
|
||||
// the raw host context, so host-only members are invisible inside.
|
||||
expect(lastCompartmentGlobalThis()['__probe']).toBe('undefined');
|
||||
});
|
||||
|
||||
it('keeps safeReturn protection on returned values without blocking allowed edits', async () => {
|
||||
const fixture = makeHostFixture();
|
||||
setContextBuilder(() => fixture.context);
|
||||
|
||||
await loadPlugin(
|
||||
makeManifest(
|
||||
'penpot.createRectangle(); ' +
|
||||
'globalThis.__selectionFrozen = Object.isFrozen(penpot.selection);',
|
||||
['content:read', 'content:write'],
|
||||
),
|
||||
);
|
||||
expect(fixture.createRectangle).toHaveBeenCalledTimes(1);
|
||||
expect(lastCompartmentGlobalThis()['__selectionFrozen']).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,6 @@ import type { Context } from '@penpot/plugin-types';
|
||||
import { loadManifest } from './parse-manifest.js';
|
||||
import { Manifest } from './models/manifest.model.js';
|
||||
import { createPlugin } from './create-plugin.js';
|
||||
import { ses } from './ses.js';
|
||||
|
||||
let plugins: Awaited<ReturnType<typeof createPlugin>>[] = [];
|
||||
|
||||
@@ -54,8 +53,23 @@ export const loadPlugin = async function (
|
||||
|
||||
closeAllPlugins();
|
||||
|
||||
// The host context is not deeply frozen at this load stage.
|
||||
//
|
||||
// The context still contains host-internal function objects and shared
|
||||
// prototypes that the host may legitimately extend after plugin load
|
||||
// (for example, by assigning custom properties). Deep-freezing here
|
||||
// would freeze those prototypes before SES override taming completes,
|
||||
// preventing later host-side mutations with a "Cannot assign to read
|
||||
// only property" TypeError.
|
||||
//
|
||||
// Responsibility boundary: this function forwards the context to the
|
||||
// sandbox layer without deep-freezing it. The public API that plugins
|
||||
// consume is constructed by the API module (`api/index.ts`), and
|
||||
// `createSandbox`'s proxy handler applies `ses.safeReturn` to values
|
||||
// crossing into the sandbox. Compartment isolation and intrinsics
|
||||
// hardening are performed by createSandbox, not here.
|
||||
const plugin = await createPlugin(
|
||||
ses.harden(context) as Context,
|
||||
context,
|
||||
manifest,
|
||||
() => {
|
||||
plugins = plugins.filter((api) => api !== plugin);
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
#
|
||||
# Text snapshots reference `fonts/sourcesanspro-regular.ttf`; this script copies
|
||||
# the bundled font into `target/svg-preview/fonts/` so the gallery renders text.
|
||||
# Image-fill snapshots reference `images/test-fill.svg`; same idea for fills.
|
||||
#
|
||||
# When a test produced a pending change there will be a `*.snap.new` next to the
|
||||
# accepted `*.snap`; the gallery then shows "accepted" vs "new" side by side.
|
||||
@@ -27,10 +28,14 @@ OUT_DIR="$SCRIPT_DIR/target/svg-preview"
|
||||
OUT="$OUT_DIR/index.html"
|
||||
FONT_SRC="$SCRIPT_DIR/src/fonts/sourcesanspro-regular.ttf"
|
||||
FONT_DIR="$OUT_DIR/fonts"
|
||||
IMAGE_SRC="$SCRIPT_DIR/src/render/svg/fixtures/test-fill.svg"
|
||||
IMAGE_DIR="$OUT_DIR/images"
|
||||
|
||||
mkdir -p "$OUT_DIR"
|
||||
mkdir -p "$FONT_DIR"
|
||||
mkdir -p "$IMAGE_DIR"
|
||||
cp "$FONT_SRC" "$FONT_DIR/"
|
||||
cp "$IMAGE_SRC" "$IMAGE_DIR/"
|
||||
|
||||
# Prints the SVG body of a snapshot file: everything after the second `---`
|
||||
# line (the YAML front matter insta writes).
|
||||
|
||||
@@ -82,6 +82,9 @@ pub struct ImageStore {
|
||||
tick: Cell<u64>,
|
||||
/// gpu-only
|
||||
context: Option<Box<DirectContext>>,
|
||||
/// Source URL registered when the image was fetched (SVG export references
|
||||
/// this in linked `<image>` elements).
|
||||
source_urls: HashMap<Uuid, String>,
|
||||
}
|
||||
|
||||
/// Creates a Skia image from an existing WebGL texture.
|
||||
@@ -227,6 +230,7 @@ impl ImageStore {
|
||||
total_bytes: 0,
|
||||
tick: Cell::new(0),
|
||||
context: Some(Box::new(context.clone())),
|
||||
source_urls: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,6 +243,7 @@ impl ImageStore {
|
||||
total_bytes: 0,
|
||||
tick: Cell::new(0),
|
||||
context: None,
|
||||
source_urls: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -476,4 +481,14 @@ impl ImageStore {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn set_source_url(&mut self, id: Uuid, url: String) {
|
||||
if !url.is_empty() {
|
||||
self.source_urls.insert(id, url);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn source_url(&self, id: &Uuid) -> Option<&str> {
|
||||
self.source_urls.get(id).map(String::as_str)
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ use crate::render::vector::draw_shape_geometry;
|
||||
|
||||
/// Accumulates the SVG document body while drawing.
|
||||
pub(crate) struct SvgLayerCanvas {
|
||||
pub(super) scale: f32,
|
||||
scale: f32,
|
||||
page_rect: skia::Rect,
|
||||
tx: f32,
|
||||
ty: f32,
|
||||
@@ -97,6 +97,28 @@ impl SvgLayerCanvas {
|
||||
self.out.push_str("</g>");
|
||||
}
|
||||
|
||||
/// Appends raw SVG markup to the body (flushes any pending Skia fragment first).
|
||||
pub(super) fn push_raw(&mut self, markup: &str) {
|
||||
self.flush();
|
||||
self.out.push_str(markup);
|
||||
}
|
||||
|
||||
/// CTM for leaf content placed in page space: Scale * Translate * Centered.
|
||||
pub(super) fn page_shape_matrix_attr(&self, shape: &Shape) -> String {
|
||||
let mut ctm = skia::Matrix::scale((self.scale, self.scale));
|
||||
ctm = ctm * skia::Matrix::translate((self.tx, self.ty));
|
||||
ctm = ctm * shape.centered_transform();
|
||||
format!(
|
||||
"matrix({} {} {} {} {} {})",
|
||||
ctm.scale_x(),
|
||||
ctm.skew_y(),
|
||||
ctm.skew_x(),
|
||||
ctm.scale_y(),
|
||||
ctm.translate_x(),
|
||||
ctm.translate_y()
|
||||
)
|
||||
}
|
||||
|
||||
/// Emits a `<clipPath>` from a shape's geometry (in device/page space).
|
||||
///
|
||||
/// A mask can be a group too. Since a group has no geometry of its own, we
|
||||
|
||||
@@ -5,8 +5,9 @@ use skia_safe as skia;
|
||||
use crate::globals::TestRenderResourcesGuard;
|
||||
use crate::render::{FontStore, RenderResources};
|
||||
use crate::shapes::{
|
||||
Fill, FontFamily, FontStyle, Frame, Group, GrowType, Paragraph, Rect, SolidColor, TextAlign,
|
||||
TextContent, TextDirection, TextSpan, Type,
|
||||
Fill, FontFamily, FontStyle, Frame, Group, GrowType, ImageFill, Paragraph, Path, Rect, Segment,
|
||||
SolidColor, Stroke, StrokeKind, StrokeStyle, TextAlign, TextContent, TextDirection, TextSpan,
|
||||
Type,
|
||||
};
|
||||
use crate::state::ShapesPool;
|
||||
use crate::utils::uuid_from_u32_quartet;
|
||||
@@ -17,6 +18,10 @@ use super::render_tree_to_svg;
|
||||
/// Font URL referenced in exported SVG `@font-face` rules.
|
||||
pub(super) const TEST_FONT_URL: &str = "fonts/sourcesanspro-regular.ttf";
|
||||
|
||||
/// Media URL referenced by linked `<image href>` fills in SVG export tests.
|
||||
/// Relative path so `./preview-snapshots` can resolve it under `target/svg-preview/`.
|
||||
pub(super) const TEST_IMAGE_URL: &str = "images/test-fill.svg";
|
||||
|
||||
fn register_test_font_urls(fonts: &mut FontStore) {
|
||||
let family = FontFamily::new(Uuid::nil(), 400, FontStyle::Normal);
|
||||
fonts.set_source_url(&family.alias(), TEST_FONT_URL.to_string());
|
||||
@@ -27,6 +32,31 @@ pub(super) fn uid(n: u32) -> Uuid {
|
||||
uuid_from_u32_quartet(0, 0, 0, n)
|
||||
}
|
||||
|
||||
/// Adds a rectangle filled with a linked image (must call `render_with` / register URL).
|
||||
pub(super) fn add_image_rect(
|
||||
pool: &mut ShapesPool,
|
||||
id: Uuid,
|
||||
parent: Uuid,
|
||||
(l, t, r, b): (f32, f32, f32, f32),
|
||||
image_id: Uuid,
|
||||
keep_aspect_ratio: bool,
|
||||
opacity: u8,
|
||||
) {
|
||||
add_rect_with_fills(
|
||||
pool,
|
||||
id,
|
||||
parent,
|
||||
(l, t, r, b),
|
||||
vec![Fill::Image(ImageFill::new(
|
||||
image_id,
|
||||
opacity,
|
||||
200,
|
||||
100,
|
||||
keep_aspect_ratio,
|
||||
))],
|
||||
);
|
||||
}
|
||||
|
||||
/// Adds a solid-filled rectangle to the pool.
|
||||
pub(super) fn add_solid_rect(
|
||||
pool: &mut ShapesPool,
|
||||
@@ -67,15 +97,101 @@ pub(super) fn add_frame(
|
||||
(l, t, r, b): (f32, f32, f32, f32),
|
||||
color: skia::Color,
|
||||
clip: bool,
|
||||
) {
|
||||
add_frame_with_fills(
|
||||
pool,
|
||||
id,
|
||||
parent,
|
||||
(l, t, r, b),
|
||||
vec![Fill::Solid(SolidColor(color))],
|
||||
clip,
|
||||
);
|
||||
}
|
||||
|
||||
fn add_frame_with_fills(
|
||||
pool: &mut ShapesPool,
|
||||
id: Uuid,
|
||||
parent: Uuid,
|
||||
(l, t, r, b): (f32, f32, f32, f32),
|
||||
fills: Vec<Fill>,
|
||||
clip: bool,
|
||||
) {
|
||||
let shape = pool.add_shape(id);
|
||||
shape.set_parent(parent);
|
||||
shape.set_shape_type(Type::Frame(Frame::default()));
|
||||
shape.set_selrect(l, t, r, b);
|
||||
shape.set_fills(vec![Fill::Solid(SolidColor(color))]);
|
||||
shape.set_fills(fills);
|
||||
shape.set_clip(clip);
|
||||
}
|
||||
|
||||
/// Frame whose background is a linked image fill.
|
||||
pub(super) fn add_image_frame(
|
||||
pool: &mut ShapesPool,
|
||||
id: Uuid,
|
||||
parent: Uuid,
|
||||
(l, t, r, b): (f32, f32, f32, f32),
|
||||
image_id: Uuid,
|
||||
clip: bool,
|
||||
) {
|
||||
add_frame_with_fills(
|
||||
pool,
|
||||
id,
|
||||
parent,
|
||||
(l, t, r, b),
|
||||
vec![test_image_fill(image_id)],
|
||||
clip,
|
||||
);
|
||||
}
|
||||
|
||||
fn triangle_segments(closed: bool) -> Vec<Segment> {
|
||||
let mut segments = vec![
|
||||
Segment::MoveTo((10.0, 90.0)),
|
||||
Segment::LineTo((50.0, 10.0)),
|
||||
Segment::LineTo((90.0, 90.0)),
|
||||
];
|
||||
if closed {
|
||||
segments.push(Segment::Close);
|
||||
}
|
||||
segments
|
||||
}
|
||||
|
||||
fn add_path_with_fills(
|
||||
pool: &mut ShapesPool,
|
||||
id: Uuid,
|
||||
parent: Uuid,
|
||||
(l, t, r, b): (f32, f32, f32, f32),
|
||||
segments: Vec<Segment>,
|
||||
fills: Vec<Fill>,
|
||||
) {
|
||||
let shape = pool.add_shape(id);
|
||||
shape.set_parent(parent);
|
||||
shape.set_shape_type(Type::Path(Path::new(segments)));
|
||||
shape.set_selrect(l, t, r, b);
|
||||
shape.set_fills(fills);
|
||||
}
|
||||
|
||||
fn test_image_fill(image_id: Uuid) -> Fill {
|
||||
Fill::Image(ImageFill::new(image_id, 255, 200, 100, true))
|
||||
}
|
||||
|
||||
/// Triangle path (open or closed) with a linked image fill.
|
||||
pub(super) fn add_image_path(
|
||||
pool: &mut ShapesPool,
|
||||
id: Uuid,
|
||||
parent: Uuid,
|
||||
closed: bool,
|
||||
image_id: Uuid,
|
||||
) {
|
||||
add_path_with_fills(
|
||||
pool,
|
||||
id,
|
||||
parent,
|
||||
(0.0, 0.0, 100.0, 100.0),
|
||||
triangle_segments(closed),
|
||||
vec![test_image_fill(image_id)],
|
||||
);
|
||||
}
|
||||
|
||||
/// Adds an empty (unmasked) group.
|
||||
pub(super) fn add_group(
|
||||
pool: &mut ShapesPool,
|
||||
@@ -153,9 +269,134 @@ pub(super) fn add_text_with_fills(
|
||||
shape.set_shape_type(Type::Text(content));
|
||||
}
|
||||
|
||||
/// Adds a rectangle with a single solid stroke (no fill).
|
||||
pub(super) fn add_stroked_rect(
|
||||
pool: &mut ShapesPool,
|
||||
id: Uuid,
|
||||
parent: Uuid,
|
||||
(l, t, r, b): (f32, f32, f32, f32),
|
||||
stroke: Stroke,
|
||||
) {
|
||||
let shape = pool.add_shape(id);
|
||||
shape.set_parent(parent);
|
||||
shape.set_shape_type(Type::Rect(Rect::default()));
|
||||
shape.set_selrect(l, t, r, b);
|
||||
shape.set_fills(vec![]);
|
||||
shape.add_stroke(stroke);
|
||||
}
|
||||
|
||||
/// Adds a closed rectangular path with a single solid stroke (no fill).
|
||||
pub(super) fn add_stroked_closed_path(
|
||||
pool: &mut ShapesPool,
|
||||
id: Uuid,
|
||||
parent: Uuid,
|
||||
bounds: (f32, f32, f32, f32),
|
||||
stroke: Stroke,
|
||||
) {
|
||||
add_stroked_path(pool, id, parent, bounds, stroke, true);
|
||||
}
|
||||
|
||||
/// Adds an open polyline path with a single solid stroke (no fill).
|
||||
pub(super) fn add_stroked_open_path(
|
||||
pool: &mut ShapesPool,
|
||||
id: Uuid,
|
||||
parent: Uuid,
|
||||
bounds: (f32, f32, f32, f32),
|
||||
stroke: Stroke,
|
||||
) {
|
||||
add_stroked_path(pool, id, parent, bounds, stroke, false);
|
||||
}
|
||||
|
||||
fn add_stroked_path(
|
||||
pool: &mut ShapesPool,
|
||||
id: Uuid,
|
||||
parent: Uuid,
|
||||
(l, t, r, b): (f32, f32, f32, f32),
|
||||
stroke: Stroke,
|
||||
closed: bool,
|
||||
) {
|
||||
let mut segments = vec![
|
||||
Segment::MoveTo((l, t)),
|
||||
Segment::LineTo((r, t)),
|
||||
Segment::LineTo((r, b)),
|
||||
Segment::LineTo((l, b)),
|
||||
];
|
||||
if closed {
|
||||
segments.push(Segment::Close);
|
||||
}
|
||||
let path = Path::new(segments);
|
||||
let shape = pool.add_shape(id);
|
||||
shape.set_parent(parent);
|
||||
shape.set_shape_type(Type::Path(path));
|
||||
shape.set_selrect(l, t, r, b);
|
||||
shape.set_fills(vec![]);
|
||||
shape.add_stroke(stroke);
|
||||
}
|
||||
|
||||
pub(super) fn solid_stroke(kind: StrokeKind, width: f32, color: skia::Color) -> Stroke {
|
||||
stroke_with_style(kind, StrokeStyle::Solid, width, color)
|
||||
}
|
||||
|
||||
pub(super) fn dotted_stroke(kind: StrokeKind, width: f32, color: skia::Color) -> Stroke {
|
||||
stroke_with_style(kind, StrokeStyle::Dotted, width, color)
|
||||
}
|
||||
|
||||
pub(super) fn dashed_stroke(kind: StrokeKind, width: f32, color: skia::Color) -> Stroke {
|
||||
stroke_with_style(kind, StrokeStyle::Dashed, width, color)
|
||||
}
|
||||
|
||||
pub(super) fn mixed_stroke(kind: StrokeKind, width: f32, color: skia::Color) -> Stroke {
|
||||
stroke_with_style(kind, StrokeStyle::Mixed, width, color)
|
||||
}
|
||||
|
||||
fn stroke_with_style(
|
||||
kind: StrokeKind,
|
||||
style: StrokeStyle,
|
||||
width: f32,
|
||||
color: skia::Color,
|
||||
) -> Stroke {
|
||||
let mut stroke = match kind {
|
||||
StrokeKind::Inner => Stroke::new_inner_stroke(width, style, None, None, None, None),
|
||||
StrokeKind::Outer => Stroke::new_outer_stroke(width, style, None, None, None, None),
|
||||
StrokeKind::Center => Stroke::new_center_stroke(width, style, None, None, None, None),
|
||||
};
|
||||
stroke.fill = Fill::Solid(SolidColor(color));
|
||||
stroke
|
||||
}
|
||||
|
||||
/// Text with a linked image fill (register URL via `render_with`).
|
||||
pub(super) fn add_image_text(
|
||||
pool: &mut ShapesPool,
|
||||
id: Uuid,
|
||||
bounds: (f32, f32, f32, f32),
|
||||
text: &str,
|
||||
font_size: f32,
|
||||
image_id: Uuid,
|
||||
) {
|
||||
add_text_with_fills(
|
||||
pool,
|
||||
id,
|
||||
bounds,
|
||||
text,
|
||||
font_size,
|
||||
vec![test_image_fill(image_id)],
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn render(pool: &ShapesPool, root: Uuid) -> String {
|
||||
render_with(pool, root, |_resources| {})
|
||||
}
|
||||
|
||||
/// Like [`render`], but lets the test register extra resources (e.g. image URLs)
|
||||
/// before export.
|
||||
pub(super) fn render_with(
|
||||
pool: &ShapesPool,
|
||||
root: Uuid,
|
||||
setup: impl FnOnce(&mut RenderResources),
|
||||
) -> String {
|
||||
let mut resources = RenderResources::try_new_headless().expect("headless resources");
|
||||
register_test_font_urls(&mut resources.fonts);
|
||||
setup(&mut resources);
|
||||
let _guard = TestRenderResourcesGuard::install(&mut resources);
|
||||
let bytes = render_tree_to_svg(&mut resources, &root, pool, 1.0).expect("svg export");
|
||||
String::from_utf8(bytes).expect("utf8 svg")
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100" viewBox="0 0 200 100">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#2563eb"/>
|
||||
<stop offset="100%" stop-color="#7c3aed"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="200" height="100" fill="url(#g)"/>
|
||||
<circle cx="60" cy="50" r="28" fill="#fbbf24"/>
|
||||
<rect x="110" y="22" width="70" height="56" rx="8" fill="#f8fafc" opacity="0.9"/>
|
||||
<text x="145" y="58" text-anchor="middle" font-family="system-ui,sans-serif" font-size="22" font-weight="700" fill="#1e293b">IMG</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 610 B |
@@ -5,6 +5,7 @@ use crate::shapes::{Shape, Stroke};
|
||||
use crate::state::ShapesPoolRef;
|
||||
|
||||
use super::document::{effect_attrs, SvgLayerCanvas};
|
||||
use super::images::emit_fills;
|
||||
use super::render_tree;
|
||||
use crate::render::RenderResources;
|
||||
|
||||
@@ -29,14 +30,9 @@ pub(super) fn render_frame(
|
||||
builder.open_group(&format!("clip-path=\"url(#{clip_id})\""));
|
||||
}
|
||||
|
||||
// Frame background (frame space).
|
||||
// Frame background (frame space), with linked `<image>` for image fills.
|
||||
if !element.fills.is_empty() {
|
||||
let canvas = builder.canvas();
|
||||
canvas.save();
|
||||
canvas.concat(&matrix);
|
||||
let mut renderer = VectorRenderer::new(canvas, shared, scale, false);
|
||||
renderer.draw_fills(element, &element.fills)?;
|
||||
canvas.restore();
|
||||
emit_fills(builder, shared, element, &element.fills, tree, scale)?;
|
||||
}
|
||||
|
||||
// Children (absolute coords).
|
||||
@@ -45,7 +41,14 @@ pub(super) fn render_frame(
|
||||
render_tree(builder, shared, child_id, tree, scale)?;
|
||||
}
|
||||
|
||||
// Strokes over children (frame space).
|
||||
// Close content clip before strokes. Outer (and half of center) strokes
|
||||
// extend past the frame bounds; keeping them under clip-path hides them.
|
||||
// Matches GPU: clipped-frame strokes render in exit without the frame clip.
|
||||
if clipped {
|
||||
builder.close_group();
|
||||
}
|
||||
|
||||
// Strokes over children (frame space), outside the content clip.
|
||||
let visible_strokes: Vec<&Stroke> = element.visible_strokes().collect();
|
||||
if !visible_strokes.is_empty() {
|
||||
let canvas = builder.canvas();
|
||||
@@ -56,9 +59,6 @@ pub(super) fn render_frame(
|
||||
canvas.restore();
|
||||
}
|
||||
|
||||
if clipped {
|
||||
builder.close_group();
|
||||
}
|
||||
if effects.is_some() {
|
||||
builder.close_group();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
use crate::error::Result;
|
||||
use crate::render::shape_renderer::ShapeRenderer;
|
||||
use crate::render::vector::VectorRenderer;
|
||||
use crate::shapes::{Fill, ImageFill, Shape};
|
||||
use crate::state::ShapesPoolRef;
|
||||
|
||||
use super::document::SvgLayerCanvas;
|
||||
use crate::render::RenderResources;
|
||||
|
||||
/// Emits fills bottom -> top for SVG export.
|
||||
///
|
||||
/// Non-image fills go through Skia's SVG canvas. Image fills with a registered
|
||||
/// source URL become native linked `<image>` elements (see `store_image_url`);
|
||||
/// without a URL they fall back to Skia (base64-embed) when a CPU image exists.
|
||||
pub(super) fn emit_fills(
|
||||
builder: &mut SvgLayerCanvas,
|
||||
shared: &mut RenderResources,
|
||||
shape: &Shape,
|
||||
fills: &[Fill],
|
||||
tree: ShapesPoolRef,
|
||||
scale: f32,
|
||||
) -> Result<()> {
|
||||
if fills.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// fills[0] is the topmost layer; draw bottom → top.
|
||||
for fill in fills.iter().rev() {
|
||||
match fill {
|
||||
Fill::Image(image_fill) if shared.images.source_url(&image_fill.id()).is_some() => {
|
||||
emit_image_fill(builder, shared, shape, image_fill, tree)?;
|
||||
}
|
||||
fill => {
|
||||
let matrix = shape.centered_transform();
|
||||
let canvas = builder.canvas();
|
||||
canvas.save();
|
||||
canvas.concat(&matrix);
|
||||
let mut renderer = VectorRenderer::new(canvas, shared, scale, false);
|
||||
renderer.draw_fills(shape, std::slice::from_ref(fill))?;
|
||||
canvas.restore();
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Emits a linked SVG `<image>` clipped to the shape geometry.
|
||||
///
|
||||
/// Skia's SVG backend would base64-embed a PNG from `draw_image_rect`; we emit
|
||||
/// a native `<image href="...">` instead so the export stays linked to the
|
||||
/// registered media URL (see `store_image_url`).
|
||||
fn emit_image_fill(
|
||||
builder: &mut SvgLayerCanvas,
|
||||
shared: &RenderResources,
|
||||
shape: &Shape,
|
||||
image_fill: &ImageFill,
|
||||
tree: ShapesPoolRef,
|
||||
) -> Result<()> {
|
||||
let Some(url) = shared.images.source_url(&image_fill.id()) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let clip_id = builder.unique("imgclip");
|
||||
builder.push_clip_path(&clip_id, shape, tree);
|
||||
let href = xml_escape_attr(url);
|
||||
emit_linked_image_element(builder, shape, image_fill, &href, &clip_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Emits `<g clip-path>` + `<image href>` using the shape selrect and page CTM.
|
||||
pub(super) fn emit_linked_image_element(
|
||||
builder: &mut SvgLayerCanvas,
|
||||
shape: &Shape,
|
||||
image_fill: &ImageFill,
|
||||
href: &str,
|
||||
clip_id: &str,
|
||||
) {
|
||||
let selrect = shape.selrect();
|
||||
let opacity = image_fill.opacity() as f32 / 255.0;
|
||||
let preserve = if image_fill.keep_aspect_ratio() {
|
||||
"xMidYMid slice"
|
||||
} else {
|
||||
"none"
|
||||
};
|
||||
let transform = builder.page_shape_matrix_attr(shape);
|
||||
|
||||
let opacity_attr = if (opacity - 1.0).abs() < f32::EPSILON {
|
||||
String::new()
|
||||
} else {
|
||||
format!(r#" opacity="{opacity}""#)
|
||||
};
|
||||
|
||||
builder.open_group(&format!("clip-path=\"url(#{clip_id})\""));
|
||||
builder.push_raw(&format!(
|
||||
r#"<image href="{href}" x="{}" y="{}" width="{}" height="{}" preserveAspectRatio="{preserve}"{opacity_attr} transform="{transform}"/>"#,
|
||||
selrect.left(),
|
||||
selrect.top(),
|
||||
selrect.width(),
|
||||
selrect.height(),
|
||||
));
|
||||
builder.close_group();
|
||||
}
|
||||
|
||||
pub(super) fn xml_escape_attr(s: &str) -> String {
|
||||
s.replace('&', "&")
|
||||
.replace('"', """)
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
}
|
||||
@@ -8,7 +8,8 @@ use crate::shapes::{Shape, Type};
|
||||
use crate::state::ShapesPoolRef;
|
||||
use crate::uuid::Uuid;
|
||||
|
||||
use super::vector::{render_leaf_content, VectorRenderer};
|
||||
use super::shape_renderer::ShapeRenderer;
|
||||
use super::vector::VectorRenderer;
|
||||
use super::RenderResources;
|
||||
|
||||
/// Collects the registered font aliases used by every text span in the subtree
|
||||
@@ -60,7 +61,8 @@ fn svg_page_bounds(shape: &Shape, tree: ShapesPoolRef, scale: f32) -> skia::Rect
|
||||
/// `<clipPath>`.
|
||||
///
|
||||
/// Special-case re-emission for shadows, layer blur, masks, text strokes, and
|
||||
/// deferred strokes is intentionally out of scope for this cut.
|
||||
/// image strokes is intentionally out of scope for this cut. Solid Inner/Outer
|
||||
/// and dotted/dashed strokes are emitted as filled outlines.
|
||||
pub fn render_to_svg(
|
||||
shared: &mut RenderResources,
|
||||
id: &Uuid,
|
||||
@@ -124,6 +126,7 @@ pub(crate) fn render_tree_to_svg(
|
||||
mod document;
|
||||
mod frames;
|
||||
mod groups;
|
||||
mod images;
|
||||
mod text;
|
||||
|
||||
use document::SvgLayerCanvas;
|
||||
@@ -132,6 +135,7 @@ use groups::render_group;
|
||||
use text::render_text_fill;
|
||||
|
||||
use document::effect_attrs;
|
||||
use images::emit_fills;
|
||||
|
||||
/// Renders `id`'s subtree to an SVG body, returning `(defs, body)`.
|
||||
fn render_body(
|
||||
@@ -171,7 +175,7 @@ fn render_tree(
|
||||
| Type::Path(_)
|
||||
| Type::Bool(_)
|
||||
| Type::Text(_)
|
||||
| Type::SVGRaw(_) => render_leaf(builder, shared, element, scale),
|
||||
| Type::SVGRaw(_) => render_leaf(builder, shared, element, tree, scale),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,6 +183,7 @@ fn render_leaf(
|
||||
builder: &mut SvgLayerCanvas,
|
||||
shared: &mut RenderResources,
|
||||
element: &Shape,
|
||||
tree: ShapesPoolRef,
|
||||
scale: f32,
|
||||
) -> Result<()> {
|
||||
let effects = effect_attrs(element);
|
||||
@@ -188,14 +193,26 @@ fn render_leaf(
|
||||
|
||||
{
|
||||
if matches!(element.shape_type, Type::Text(_)) {
|
||||
render_text_fill(builder, element)?;
|
||||
render_text_fill(builder, shared, element)?;
|
||||
} else {
|
||||
emit_fills(builder, shared, element, &element.fills, tree, scale)?;
|
||||
|
||||
let matrix = element.centered_transform();
|
||||
let canvas = builder.canvas();
|
||||
canvas.save();
|
||||
canvas.concat(&matrix);
|
||||
let mut renderer = VectorRenderer::new(canvas, shared, scale, false);
|
||||
render_leaf_content(&mut renderer, element)?;
|
||||
renderer.draw_fill_inner_shadows(element)?;
|
||||
|
||||
let visible_strokes: Vec<_> = element.visible_strokes().collect();
|
||||
if !visible_strokes.is_empty() {
|
||||
renderer.draw_strokes(element, &visible_strokes)?;
|
||||
if !element.has_fills() {
|
||||
for stroke in &visible_strokes {
|
||||
renderer.draw_stroke_inner_shadows(element, stroke)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
canvas.restore();
|
||||
}
|
||||
}
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
---
|
||||
source: src/render/svg/tests.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="164" height="124" viewBox="0 0 164 124"><defs><clipPath id="clip0" clipPathUnits="userSpaceOnUse">
|
||||
<rect transform="translate(12 12)" width="140" height="100"/>
|
||||
</clipPath></defs><g clip-path="url(#clip0)">
|
||||
<rect fill="#EEE" transform="translate(12 12)" width="140" height="100"/>
|
||||
</g>
|
||||
<path fill="#1040FF" transform="translate(12 12)" d="M-12 -12L152 -12L152 112L-12 112L-12 -12ZM140 0L0 0L0 100L140 100L140 0Z" fill-rule="evenodd"/>
|
||||
</svg>
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
---
|
||||
source: src/render/svg/tests.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100" height="80" viewBox="0 0 100 80">
|
||||
<path fill="green" d="M18 0L0 0L0 8L18 8L18 0ZM0 36L0 18L8 18L8 36L0 36ZM0 72L0 54L8 54L8 72L0 72ZM28 80L10 80L10 72L28 72L28 80ZM64 80L45.999996 80L45.999996 72L64 72L64 80ZM100 80L100 72L82 72L82 80L100 80ZM100 44L100 62L92 62L92 44L100 44ZM100 8L100 26L92 26L92 8L100 8ZM72 0L90 0L90 8L72 8L72 0ZM36 0L54.000004 0L54.000004 8L36 8L36 0Z" fill-rule="evenodd"/>
|
||||
</svg>
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
---
|
||||
source: src/render/svg/tests.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="116" height="96" viewBox="0 0 116 96">
|
||||
<path fill="green" transform="translate(8 8)" d="M0 4C2.6666667 4 4 2.6666667 4 0C4 -2.6666667 2.6666667 -4 0 -4C-2.6666667 -4 -4 -2.6666667 -4 0C-4 2.6666667 -2.6666667 4 0 4ZM13 4C15.666667 4 17 2.6666667 17 0C17 -2.6666667 15.666667 -4 13 -4C10.333333 -4 9 -2.6666667 9 0C9 2.6666667 10.333333 4 13 4ZM26 4C28.666666 4 30 2.6666667 30 0C30 -2.6666667 28.666666 -4 26 -4C23.333334 -4 22 -2.6666667 22 0C22 2.6666667 23.333334 4 26 4ZM39 4C41.666668 4 43 2.6666667 43 0C43 -2.6666667 41.666668 -4 39 -4C36.333332 -4 35 -2.6666667 35 0C35 2.6666667 36.333332 4 39 4ZM52 4C54.666668 4 56 2.6666667 56 0C56 -2.6666667 54.666668 -4 52 -4C49.333332 -4 48 -2.6666667 48 0C48 2.6666667 49.333332 4 52 4ZM65 4C67.666664 4 69 2.6666667 69 0C69 -2.6666667 67.666664 -4 65 -4C62.333332 -4 61 -2.6666667 61 0C61 2.6666667 62.333332 4 65 4ZM78 4C80.666664 4 82 2.6666667 82 0C82 -2.6666667 80.666664 -4 78 -4C75.333336 -4 74 -2.6666667 74 0C74 2.6666667 75.333336 4 78 4ZM91 4C93.666664 4 95 2.6666667 95 0C95 -2.6666667 93.666664 -4 91 -4C88.333336 -4 87 -2.6666667 87 0C87 2.6666667 88.333336 4 91 4ZM100 8C102.66666 8 104 6.666667 104 4C104 1.3333333 102.66666 0 100 0C97.333336 0 96 1.3333333 96 4C96 6.666667 97.333336 8 100 8ZM0 13C2.6666667 13 4 11.666667 4 9C4 6.333333 2.6666667 5 0 5C-2.6666667 5 -4 6.333333 -4 9C-4 11.666667 -2.6666667 13 0 13ZM100 21C102.66666 21 104 19.666666 104 17C104 14.333333 102.66666 13 100 13C97.333336 13 96 14.333333 96 17C96 19.666666 97.333336 21 100 21ZM0 26C2.6666667 26 4 24.666666 4 22C4 19.333334 2.6666667 18 0 18C-2.6666667 18 -4 19.333334 -4 22C-4 24.666666 -2.6666667 26 0 26ZM100 34C102.66666 34 104 32.666668 104 30C104 27.333334 102.66666 26 100 26C97.333336 26 96 27.333334 96 30C96 32.666668 97.333336 34 100 34ZM0 39C2.6666667 39 4 37.666668 4 35C4 32.333332 2.6666667 31 0 31C-2.6666667 31 -4 32.333332 -4 35C-4 37.666668 -2.6666667 39 0 39ZM100 47C102.66666 47 104 45.666668 104 43C104 40.333332 102.66666 39 100 39C97.333336 39 96 40.333332 96 43C96 45.666668 97.333336 47 100 47ZM0 52C2.6666667 52 4 50.666668 4 48C4 45.333332 2.6666667 44 0 44C-2.6666667 44 -4 45.333332 -4 48C-4 50.666668 -2.6666667 52 0 52ZM100 60C102.66666 60 104 58.666668 104 56C104 53.333332 102.66666 52 100 52C97.333336 52 96 53.333332 96 56C96 58.666668 97.333336 60 100 60ZM0 65C2.6666667 65 4 63.666668 4 61C4 58.333332 2.6666667 57 0 57C-2.6666667 57 -4 58.333332 -4 61C-4 63.666668 -2.6666667 65 0 65ZM100 73C102.66666 73 104 71.666664 104 69C104 66.333336 102.66666 65 100 65C97.333336 65 96 66.333336 96 69C96 71.666664 97.333336 73 100 73ZM0 78C2.6666667 78 4 76.666664 4 74C4 71.333336 2.6666667 70 0 70C-2.6666667 70 -4 71.333336 -4 74C-4 76.666664 -2.6666667 78 0 78ZM7 84C9.666667 84 11 82.666664 11 80C11 77.333336 9.666667 76 7 76C4.333333 76 3 77.333336 3 80C3 82.666664 4.333333 84 7 84ZM20 84C22.666666 84 24 82.666664 24 80C24 77.333336 22.666666 76 20 76C17.333334 76 16 77.333336 16 80C16 82.666664 17.333334 84 20 84ZM33 84C35.666668 84 37 82.666664 37 80C37 77.333336 35.666668 76 33 76C30.333334 76 29 77.333336 29 80C29 82.666664 30.333334 84 33 84ZM45.999996 84C48.666664 84 49.999996 82.666664 49.999996 80C49.999996 77.333336 48.666664 76 45.999996 76C43.333328 76 41.999996 77.333336 41.999996 80C41.999996 82.666664 43.333328 84 45.999996 84ZM59 84C61.666668 84 63 82.666664 63 80C63 77.333336 61.666668 76 59 76C56.333332 76 55 77.333336 55 80C55 82.666664 56.333332 84 59 84ZM72 84C74.666664 84 76 82.666664 76 80C76 77.333336 74.666664 76 72 76C69.333336 76 68 77.333336 68 80C68 82.666664 69.333336 84 72 84ZM85 84C87.666664 84 89 82.666664 89 80C89 77.333336 87.666664 76 85 76C82.333336 76 81 77.333336 81 80C81 82.666664 82.333336 84 85 84ZM98 84C100.66666 84 102 82.666664 102 80C102 77.333336 100.66666 76 98 76C95.333336 76 94 77.333336 94 80C94 82.666664 95.333336 84 98 84Z" fill-rule="evenodd"/>
|
||||
</svg>
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
---
|
||||
source: src/render/svg/tests.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100" height="80" viewBox="0 0 100 80">
|
||||
<path fill="blue" d="M100 0L0 0L0 80L100 80L100 0ZM8 9C8 7.7741446 7.7323785 6.6098022 7.197135 5.5069714C8.7744055 7.1689906 10.708694 8 13 8C15.737486 8 17.904152 6.8878965 19.5 4.6636891C21.095848 6.8878965 23.262514 8 26 8C28.737486 8 30.904152 6.8878965 32.5 4.6636891C34.095848 6.8878965 36.262516 8 39 8C41.737484 8 43.904152 6.8878965 45.5 4.6636891C47.095848 6.8878965 49.262516 8 52 8C54.737484 8 56.904152 6.8878965 58.5 4.6636891C60.095848 6.8878965 62.262516 8 65 8C67.737488 8 69.904152 6.8878965 71.5 4.6636891C73.095848 6.8878965 75.262512 8 78 8C80.737488 8 82.904152 6.8878965 84.5 4.6636891C86.095848 6.8878965 88.262512 8 91 8C91.656258 8 92.302719 7.9204535 92.939392 7.7613606C93.525131 8.8608704 94.324104 9.7737494 95.336311 10.5C93.112106 12.095847 92 14.262514 92 17C92 19.737486 93.112106 21.904152 95.336311 23.5C93.112106 25.095848 92 27.262514 92 30C92 32.737484 93.112106 34.904152 95.336311 36.5C93.112106 38.095848 92 40.262516 92 43C92 45.737484 93.112106 47.904152 95.336311 49.5C93.112106 51.095848 92 53.262516 92 56C92 58.737484 93.112106 60.904152 95.336311 62.5C93.112106 64.095848 92 66.262512 92 69C92 70.631767 92.456512 72.123863 93.369545 73.47628C92.642754 73.992149 92.019569 74.61216 91.5 75.336311C89.904152 73.112106 87.737488 72 85 72C82.262512 72 80.095848 73.112106 78.5 75.336311C76.904152 73.112106 74.737488 72 72 72C69.262512 72 67.095848 73.112106 65.5 75.336311C63.904152 73.112106 61.737484 72 59 72C56.262512 72 54.095844 73.112106 52.5 75.336311C50.904152 73.112106 48.737484 72 45.999996 72C43.262512 72 41.095844 73.112106 39.5 75.336311C37.904152 73.112106 35.737484 72 33 72C30.262514 72 28.095848 73.112106 26.5 75.336311C24.904152 73.112106 22.737486 72 20 72C17.262514 72 15.095847 73.112106 13.5 75.336311C12.085551 73.364929 10.170585 72.264732 7.7551012 72.035721C7.276824 70.147453 6.2463531 68.635551 4.6636891 67.5C6.8878965 65.904152 8 63.737488 8 61C8 58.262516 6.8878965 56.095848 4.6636891 54.5C6.8878965 52.904152 8 50.737484 8 48C8 45.262516 6.8878965 43.095848 4.6636891 41.5C6.8878965 39.904152 8 37.737484 8 35C8 32.262516 6.8878965 30.095848 4.6636891 28.5C6.8878965 26.904152 8 24.737486 8 22C8 19.262514 6.8878965 17.095848 4.6636891 15.5C6.8878965 13.904153 8 11.737486 8 9Z" fill-rule="evenodd"/>
|
||||
</svg>
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
---
|
||||
source: src/render/svg/tests.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="132" height="112" viewBox="0 0 132 112">
|
||||
<path fill="green" transform="translate(16 16)" d="M-8 0C-8 1.6422184 -7.538126 3.1422181 -6.6143785 4.5C-7.538126 5.8577819 -8 7.3577814 -8 9C-8 11.737486 -6.8878965 13.904153 -4.6636891 15.5C-6.8878965 17.095848 -8 19.262514 -8 22C-8 24.737486 -6.8878965 26.904152 -4.6636891 28.5C-6.8878965 30.095848 -8 32.262516 -8 35C-8 37.737484 -6.8878965 39.904152 -4.6636891 41.5C-6.8878965 43.095848 -8 45.262516 -8 48C-8 50.737484 -6.8878965 52.904152 -4.6636891 54.5C-6.8878965 56.095848 -8 58.262516 -8 61C-8 63.737488 -6.8878965 65.904152 -4.6636891 67.5C-6.8878965 69.095848 -8 71.262512 -8 74C-8 78.851593 -5.5850339 81.506355 -0.75510108 81.964279C0.26408672 85.988091 2.8491201 88 7 88C9.7374859 88 11.904153 86.887894 13.5 84.663689C15.095847 86.887894 17.262514 88 20 88C22.737486 88 24.904152 86.887894 26.5 84.663689C28.095848 86.887894 30.262514 88 33 88C35.737484 88 37.904152 86.887894 39.499996 84.663689C41.095844 86.887894 43.262512 88 45.999996 88C48.737484 88 50.904152 86.887894 52.499996 84.663689C54.095844 86.887894 56.262512 88 59 88C61.737484 88 63.904152 86.887894 65.5 84.663689C67.095848 86.887894 69.262512 88 72 88C74.737488 88 76.904152 86.887894 78.5 84.663689C80.095848 86.887894 82.262512 88 85 88C87.737488 88 89.904152 86.887894 91.5 84.663689C93.095848 86.887894 95.262512 88 98 88C103.33334 88 106 85.333336 106 80C106 78.368233 105.54349 76.876137 104.63046 75.52372C106.87682 73.929276 108 71.754707 108 69C108 66.262512 106.88789 64.095848 104.66369 62.5C106.88789 60.904152 108 58.737484 108 56C108 53.262516 106.88789 51.095848 104.66369 49.5C106.88789 47.904152 108 45.737484 108 43C108 40.262516 106.88789 38.095848 104.66369 36.5C106.88789 34.904152 108 32.737484 108 30C108 27.262514 106.88789 25.095848 104.66369 23.5C106.88789 21.904152 108 19.737486 108 17C108 14.262514 106.88789 12.095847 104.66369 10.5C106.88789 8.9041529 108 6.7374859 108 4C108 -1.3333335 105.33334 -4 100 -4C99.343742 -4 98.697281 -3.9204535 98.060608 -3.7613606C96.55526 -6.5871201 94.201721 -8 91 -8C88.262512 -8 86.095848 -6.8878965 84.5 -4.6636891C82.904152 -6.8878965 80.737488 -8 78 -8C75.262512 -8 73.095848 -6.8878965 71.5 -4.6636891C69.904152 -6.8878965 67.737488 -8 65 -8C62.262516 -8 60.095848 -6.8878965 58.5 -4.6636891C56.904152 -6.8878965 54.737484 -8 52 -8C49.262516 -8 47.095848 -6.8878965 45.5 -4.6636891C43.904152 -6.8878965 41.737484 -8 39 -8C36.262516 -8 34.095848 -6.8878965 32.5 -4.6636891C30.904152 -6.8878965 28.737486 -8 26 -8C23.262514 -8 21.095848 -6.8878965 19.5 -4.6636891C17.904152 -6.8878965 15.737486 -8 13 -8C10.262514 -8 8.0958471 -6.8878965 6.5 -4.6636891C4.9041519 -6.8878965 2.7374856 -8 0 -8C-5.3333335 -8 -8 -5.3333335 -8 0ZM100 0L0 0L0 80L100 80L100 0Z" fill-rule="evenodd"/>
|
||||
</svg>
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
---
|
||||
source: src/render/svg/tests.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="132" height="112" viewBox="0 0 132 112">
|
||||
<path fill="red" transform="translate(16 16)" d="M0 -8L13 -8L13 0L0 0L0 -8ZM35 0L26 0L26 -8L35 -8L35 0ZM61 0L48 0L48 -8L61 -8L61 0ZM83 0L74 0L74 -8L83 -8L83 0ZM100 31L100 22L108 22L108 31L100 31ZM100 57L100 44L108 44L108 57L100 57ZM100 79L100 70L108 70L108 79L100 79ZM75 80L88 80L88 88L75 88L75 80ZM53 80L62 80L62 88L53 88L53 80ZM27 80L39.999996 80L39.999996 88L27 88L27 80ZM0 37L0 46L-8 46L-8 37L0 37ZM0 11L0 24L-8 24L-8 11L0 11ZM0 59L0 72L-8 72L-8 59L0 59ZM5 80L14 80L14 88L5 88L5 80ZM100 9L100 0L96 0L96 -8L108 -8L108 9L100 9Z" fill-rule="evenodd"/>
|
||||
</svg>
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
---
|
||||
source: src/render/svg/tests.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="116" height="96" viewBox="0 0 116 96">
|
||||
<path fill="none" stroke="green" stroke-width="8" stroke-miterlimit="4" transform="translate(8 8)" d="M0 0L100 0L100 80L0 80L0 0Z"/>
|
||||
</svg>
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
---
|
||||
source: src/render/svg/tests.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100" height="80" viewBox="0 0 100 80">
|
||||
<path fill="blue" d="M100 0L0 0L0 80L100 80L100 0ZM8 8L8 72L92 72L92 8L8 8Z" fill-rule="evenodd"/>
|
||||
</svg>
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
---
|
||||
source: src/render/svg/tests.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="132" height="112" viewBox="0 0 132 112">
|
||||
<path fill="green" transform="translate(16 16)" d="M-8 -8L108 -8L108 88L-8 88L-8 -8ZM100 0L0 0L0 80L100 80L100 0Z" fill-rule="evenodd"/>
|
||||
</svg>
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
---
|
||||
source: src/render/svg/tests.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100" height="80" viewBox="0 0 100 80"><defs><clipPath id="imgclip0" clipPathUnits="userSpaceOnUse">
|
||||
<rect width="100" height="80"/>
|
||||
</clipPath></defs><g clip-path="url(#imgclip0)"><image href="images/test-fill.svg" x="0" y="0" width="100" height="80" preserveAspectRatio="xMidYMid slice" transform="matrix(1 0 0 1 0 0)"/></g></svg>
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
---
|
||||
source: src/render/svg/tests.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="80" height="80" viewBox="0 0 80 80"><defs><clipPath id="imgclip0" clipPathUnits="userSpaceOnUse">
|
||||
<path transform="translate(-10 -10)" d="M10 90L50 10L90 90L10 90Z"/>
|
||||
</clipPath></defs><g clip-path="url(#imgclip0)"><image href="images/test-fill.svg" x="0" y="0" width="100" height="100" preserveAspectRatio="xMidYMid slice" transform="matrix(1 0 0 1 -10 -10)"/></g></svg>
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
---
|
||||
source: src/render/svg/tests.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="120" viewBox="0 0 200 120"><defs><clipPath id="imgclip0" clipPathUnits="userSpaceOnUse">
|
||||
<rect width="200" height="120"/>
|
||||
</clipPath></defs><g clip-path="url(#imgclip0)"><image href="images/test-fill.svg" x="0" y="0" width="200" height="120" preserveAspectRatio="xMidYMid slice" transform="matrix(1 0 0 1 0 0)"/></g></svg>
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
---
|
||||
source: src/render/svg/tests.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="80" height="80" viewBox="0 0 80 80"><defs><clipPath id="imgclip0" clipPathUnits="userSpaceOnUse">
|
||||
<path transform="translate(-10 -10)" d="M10 90L50 10L90 90"/>
|
||||
</clipPath></defs><g clip-path="url(#imgclip0)"><image href="images/test-fill.svg" x="0" y="0" width="100" height="100" preserveAspectRatio="xMidYMid slice" transform="matrix(1 0 0 1 -10 -10)"/></g></svg>
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
---
|
||||
source: src/render/svg/tests.rs
|
||||
assertion_line: 306
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="560" height="240" viewBox="0 0 560 240"><defs><style type="text/css"><![CDATA[@font-face{font-family:"Source Sans Pro";font-style:normal;font-weight:400;src:url("fonts/sourcesanspro-regular.ttf") format("truetype");}]]></style><clipPath id="txtimgclip0" clipPathUnits="userSpaceOnUse">
|
||||
<text font-size="200" font-family="Source Sans Pro" x="0, 130.37109, 263.08594, 360.83984" y="181">
|
||||
HOLA
|
||||
</text>
|
||||
</clipPath></defs><g clip-path="url(#txtimgclip0)"><image href="images/test-fill.svg" x="0" y="0" width="560" height="240" preserveAspectRatio="xMidYMid slice" transform="matrix(1 0 0 1 0 0)"/></g></svg>
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
---
|
||||
source: src/render/svg/tests.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="408" height="128" viewBox="0 0 408 128"><defs><clipPath id="imgclip0" clipPathUnits="userSpaceOnUse">
|
||||
<rect transform="translate(-100 -50)" x="100" y="50" width="408" height="128"/>
|
||||
</clipPath></defs><g clip-path="url(#imgclip0)"><image href="images/test-fill.svg" x="100" y="50" width="408" height="128" preserveAspectRatio="none" opacity="0.5019608" transform="matrix(1 0 0 1 -100 -50)"/></g>
|
||||
<rect fill="#003FFF" fill-opacity="0.50196081" transform="translate(-100 -50)" x="100" y="50" width="408" height="128"/>
|
||||
</svg>
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
---
|
||||
source: src/render/svg/tests.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="116" height="96" viewBox="0 0 116 96">
|
||||
<path fill="red" transform="translate(8 8)" d="M0 4C2.6666667 4 4 2.6666667 4 0C4 -2.6666667 2.6666667 -4 0 -4C-2.6666667 -4 -4 -2.6666667 -4 0C-4 2.6666667 -2.6666667 4 0 4ZM13 4C15.666667 4 17 2.6666667 17 0C17 -2.6666667 15.666667 -4 13 -4C10.333333 -4 9 -2.6666667 9 0C9 2.6666667 10.333333 4 13 4ZM26 4C28.666666 4 30 2.6666667 30 0C30 -2.6666667 28.666666 -4 26 -4C23.333334 -4 22 -2.6666667 22 0C22 2.6666667 23.333334 4 26 4ZM39 4C41.666668 4 43 2.6666667 43 0C43 -2.6666667 41.666668 -4 39 -4C36.333332 -4 35 -2.6666667 35 0C35 2.6666667 36.333332 4 39 4ZM52 4C54.666668 4 56 2.6666667 56 0C56 -2.6666667 54.666668 -4 52 -4C49.333332 -4 48 -2.6666667 48 0C48 2.6666667 49.333332 4 52 4ZM65 4C67.666664 4 69 2.6666667 69 0C69 -2.6666667 67.666664 -4 65 -4C62.333332 -4 61 -2.6666667 61 0C61 2.6666667 62.333332 4 65 4ZM78 4C80.666664 4 82 2.6666667 82 0C82 -2.6666667 80.666664 -4 78 -4C75.333336 -4 74 -2.6666667 74 0C74 2.6666667 75.333336 4 78 4ZM91 4C93.666664 4 95 2.6666667 95 0C95 -2.6666667 93.666664 -4 91 -4C88.333336 -4 87 -2.6666667 87 0C87 2.6666667 88.333336 4 91 4ZM100 8C102.66666 8 104 6.666667 104 4C104 1.3333333 102.66666 0 100 0C97.333336 0 96 1.3333333 96 4C96 6.666667 97.333336 8 100 8ZM100 21C102.66666 21 104 19.666666 104 17C104 14.333333 102.66666 13 100 13C97.333336 13 96 14.333333 96 17C96 19.666666 97.333336 21 100 21ZM100 34C102.66666 34 104 32.666668 104 30C104 27.333334 102.66666 26 100 26C97.333336 26 96 27.333334 96 30C96 32.666668 97.333336 34 100 34ZM100 47C102.66666 47 104 45.666668 104 43C104 40.333332 102.66666 39 100 39C97.333336 39 96 40.333332 96 43C96 45.666668 97.333336 47 100 47ZM100 60C102.66666 60 104 58.666668 104 56C104 53.333332 102.66666 52 100 52C97.333336 52 96 53.333332 96 56C96 58.666668 97.333336 60 100 60ZM100 73C102.66666 73 104 71.666664 104 69C104 66.333336 102.66666 65 100 65C97.333336 65 96 66.333336 96 69C96 71.666664 97.333336 73 100 73ZM7 84C9.666667 84 11 82.666664 11 80C11 77.333336 9.666667 76 7 76C4.333333 76 3 77.333336 3 80C3 82.666664 4.333333 84 7 84ZM20 84C22.666666 84 24 82.666664 24 80C24 77.333336 22.666666 76 20 76C17.333334 76 16 77.333336 16 80C16 82.666664 17.333334 84 20 84ZM33 84C35.666668 84 37 82.666664 37 80C37 77.333336 35.666668 76 33 76C30.333334 76 29 77.333336 29 80C29 82.666664 30.333334 84 33 84ZM45.999996 84C48.666664 84 49.999996 82.666664 49.999996 80C49.999996 77.333336 48.666664 76 45.999996 76C43.333328 76 41.999996 77.333336 41.999996 80C41.999996 82.666664 43.333328 84 45.999996 84ZM59 84C61.666668 84 63 82.666664 63 80C63 77.333336 61.666668 76 59 76C56.333332 76 55 77.333336 55 80C55 82.666664 56.333332 84 59 84ZM72 84C74.666664 84 76 82.666664 76 80C76 77.333336 74.666664 76 72 76C69.333336 76 68 77.333336 68 80C68 82.666664 69.333336 84 72 84ZM85 84C87.666664 84 89 82.666664 89 80C89 77.333336 87.666664 76 85 76C82.333336 76 81 77.333336 81 80C81 82.666664 82.333336 84 85 84ZM98 84C100.66666 84 102 82.666664 102 80C102 77.333336 100.66666 76 98 76C95.333336 76 94 77.333336 94 80C94 82.666664 95.333336 84 98 84Z" fill-rule="evenodd"/>
|
||||
</svg>
|
||||
+11
File diff suppressed because one or more lines are too long.
+8
@@ -0,0 +1,8 @@
|
||||
---
|
||||
source: src/render/svg/tests.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="116" height="96" viewBox="0 0 116 96">
|
||||
<path fill="none" stroke="red" stroke-width="8" stroke-miterlimit="4" transform="translate(8 8)" d="M0 0L100 0L100 80L0 80"/>
|
||||
</svg>
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
---
|
||||
source: src/render/svg/tests.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="116" height="96" viewBox="0 0 116 96">
|
||||
<path fill="none" stroke="blue" stroke-width="8" stroke-miterlimit="4" transform="translate(8 8)" d="M0 0L100 0L100 80L0 80"/>
|
||||
</svg>
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
---
|
||||
source: src/render/svg/tests.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="116" height="96" viewBox="0 0 116 96">
|
||||
<path fill="none" stroke="green" stroke-width="8" stroke-miterlimit="4" transform="translate(8 8)" d="M0 0L100 0L100 80L0 80"/>
|
||||
</svg>
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
---
|
||||
source: src/render/svg/tests.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="108" height="88" viewBox="0 0 108 88">
|
||||
<path fill="blue" transform="translate(-6 -6)" d="M28 6L10 6L10 14L28 14L28 6ZM64 6L46 6L46 14L64 14L64 6ZM100 6L82 6L82 14L100 14L100 6ZM114 36L114 18L106 18L106 36L114 36ZM6 28L6 46L14 46L14 28L6 28ZM114 72L114 54L106 54L106 72L114 72ZM6 64L6 82L14 82L14 64L6 64ZM20 94L38 94L38 86L20 86L20 94ZM55.999996 94L74 94L74 86L55.999996 86L55.999996 94ZM92 94L110 94L110 86L92 86L92 94Z" fill-rule="evenodd"/>
|
||||
</svg>
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
---
|
||||
source: src/render/svg/tests.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="120" height="100" viewBox="0 0 120 100">
|
||||
<path fill="red" transform="translate(-10 -10)" d="M20 10L40 10L40 20L20 20L20 10ZM80 20L60 20L60 10L80 10L80 20ZM100 20L100 10L120 10L120 20L100 20ZM120 60L120 40L130 40L130 60L120 60ZM80 100L100 100L100 110L80 110L80 100ZM40 100L59.999996 100L59.999996 110L40 110L40 100ZM20 40L20 60L10 60L10 40L20 40ZM20 80L20 100L10 100L10 80L20 80ZM120 100L120 80L130 80L130 100L120 100Z" fill-rule="evenodd"/>
|
||||
</svg>
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
---
|
||||
source: src/render/svg/tests.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="108" height="88" viewBox="0 0 108 88">
|
||||
<path fill="blue" transform="translate(-6 -6)" d="M10 14C12.666667 14 14 12.666667 14 10C14 7.333333 12.666667 6 10 6C7.333333 6 6 7.333333 6 10C6 12.666667 7.333333 14 10 14ZM23 14C25.666666 14 27 12.666667 27 10C27 7.333333 25.666666 6 23 6C20.333334 6 19 7.333333 19 10C19 12.666667 20.333334 14 23 14ZM36 14C38.666668 14 40 12.666667 40 10C40 7.333333 38.666668 6 36 6C33.333332 6 32 7.333333 32 10C32 12.666667 33.333332 14 36 14ZM49 14C51.666668 14 53 12.666667 53 10C53 7.333333 51.666668 6 49 6C46.333332 6 45 7.333333 45 10C45 12.666667 46.333332 14 49 14ZM62 14C64.666664 14 66 12.666667 66 10C66 7.333333 64.666664 6 62 6C59.333332 6 58 7.333333 58 10C58 12.666667 59.333332 14 62 14ZM75 14C77.666664 14 79 12.666667 79 10C79 7.333333 77.666664 6 75 6C72.333336 6 71 7.333333 71 10C71 12.666667 72.333336 14 75 14ZM88 14C90.666664 14 92 12.666667 92 10C92 7.333333 90.666664 6 88 6C85.333336 6 84 7.333333 84 10C84 12.666667 85.333336 14 88 14ZM101 14C103.66666 14 105 12.666667 105 10C105 7.333333 103.66666 6 101 6C98.333336 6 97 7.333333 97 10C97 12.666667 98.333336 14 101 14ZM110 18C112.66666 18 114 16.666666 114 14C114 11.333333 112.66666 10 110 10C107.33334 10 106 11.333333 106 14C106 16.666666 107.33334 18 110 18ZM10 23C12.666667 23 14 21.666666 14 19C14 16.333334 12.666667 15 10 15C7.333333 15 6 16.333334 6 19C6 21.666666 7.333333 23 10 23ZM110 31C112.66666 31 114 29.666666 114 27C114 24.333334 112.66666 23 110 23C107.33334 23 106 24.333334 106 27C106 29.666666 107.33334 31 110 31ZM10 36C12.666667 36 14 34.666668 14 32C14 29.333334 12.666667 28 10 28C7.333333 28 6 29.333334 6 32C6 34.666668 7.333333 36 10 36ZM110 44C112.66666 44 114 42.666668 114 40C114 37.333332 112.66666 36 110 36C107.33334 36 106 37.333332 106 40C106 42.666668 107.33334 44 110 44ZM10 49C12.666667 49 14 47.666668 14 45C14 42.333332 12.666667 41 10 41C7.333333 41 6 42.333332 6 45C6 47.666668 7.333333 49 10 49ZM110 57C112.66666 57 114 55.666668 114 53C114 50.333332 112.66666 49 110 49C107.33334 49 106 50.333332 106 53C106 55.666668 107.33334 57 110 57ZM10 62C12.666667 62 14 60.666668 14 58C14 55.333332 12.666667 54 10 54C7.333333 54 6 55.333332 6 58C6 60.666668 7.333333 62 10 62ZM110 70C112.66666 70 114 68.666664 114 66C114 63.333332 112.66666 62 110 62C107.33334 62 106 63.333332 106 66C106 68.666664 107.33334 70 110 70ZM10 75C12.666667 75 14 73.666664 14 71C14 68.333336 12.666667 67 10 67C7.333333 67 6 68.333336 6 71C6 73.666664 7.333333 75 10 75ZM110 83C112.66666 83 114 81.666664 114 79C114 76.333336 112.66666 75 110 75C107.33334 75 106 76.333336 106 79C106 81.666664 107.33334 83 110 83ZM10 88C12.666667 88 14 86.666664 14 84C14 81.333336 12.666667 80 10 80C7.333333 80 6 81.333336 6 84C6 86.666664 7.333333 88 10 88ZM17 94C19.666666 94 21 92.666664 21 90C21 87.333336 19.666666 86 17 86C14.333333 86 13 87.333336 13 90C13 92.666664 14.333333 94 17 94ZM30 94C32.666668 94 34 92.666664 34 90C34 87.333336 32.666668 86 30 86C27.333334 86 26 87.333336 26 90C26 92.666664 27.333334 94 30 94ZM43 94C45.666668 94 47 92.666664 47 90C47 87.333336 45.666668 86 43 86C40.333332 86 39 87.333336 39 90C39 92.666664 40.333332 94 43 94ZM55.999996 94C58.666664 94 59.999996 92.666664 59.999996 90C59.999996 87.333336 58.666664 86 55.999996 86C53.333328 86 51.999996 87.333336 51.999996 90C51.999996 92.666664 53.333328 94 55.999996 94ZM69 94C71.666664 94 73 92.666664 73 90C73 87.333336 71.666664 86 69 86C66.333336 86 65 87.333336 65 90C65 92.666664 66.333336 94 69 94ZM82 94C84.666664 94 86 92.666664 86 90C86 87.333336 84.666664 86 82 86C79.333336 86 78 87.333336 78 90C78 92.666664 79.333336 94 82 94ZM95 94C97.666664 94 99 92.666664 99 90C99 87.333336 97.666664 86 95 86C92.333336 86 91 87.333336 91 90C91 92.666664 92.333336 94 95 94ZM108 94C110.66666 94 112 92.666664 112 90C112 87.333336 110.66666 86 108 86C105.33334 86 104 87.333336 104 90C104 92.666664 105.33334 94 108 94Z" fill-rule="evenodd"/>
|
||||
</svg>
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
---
|
||||
source: src/render/svg/tests.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100" height="80" viewBox="0 0 100 80">
|
||||
<path fill="blue" transform="translate(-10 -10)" d="M110 10L10 10L10 90L110 90L110 10ZM16.614376 17.500002C16.928108 17.223316 17.223316 16.928108 17.500002 16.614376C19.490559 18.871458 21.990557 20 25 20C28.009443 20 30.509443 18.871458 32.5 16.614376C34.490559 18.871458 36.990559 20 40 20C43.009441 20 45.509441 18.871458 47.5 16.614376C49.490559 18.871458 51.990559 20 55 20C58.009441 20 60.509441 18.871458 62.5 16.614376C64.490562 18.871458 66.990562 20 70 20C73.009438 20 75.509438 18.871458 77.5 16.614376C79.490562 18.871458 81.990562 20 85 20C88.009438 20 90.509438 18.871458 92.5 16.614376C94.490562 18.871458 96.990562 20 100 20C100.43244 20 100.86308 19.972065 101.2919 19.916197C101.84457 20.895153 102.54248 21.75642 103.38562 22.499998C101.12854 24.490557 100 26.990557 100 30C100 33.009441 101.12854 35.509441 103.38562 37.5C101.12854 39.490559 100 41.990559 100 45C100 48.009441 101.12854 50.509441 103.38562 52.5C101.12854 54.490559 100 56.990559 100 60C100 63.009441 101.12854 65.509438 103.38562 67.5C101.12854 69.490562 100 71.990562 100 75C100 78.009438 101.12854 80.509438 103.38562 82.5C103.07189 82.77668 102.77668 83.071892 102.5 83.38562C100.50944 81.12854 98.009438 80 95 80C91.990562 80 89.490562 81.12854 87.5 83.38562C85.509438 81.12854 83.009438 80 80 80C76.990562 80 74.490562 81.12854 72.5 83.38562C70.509438 81.12854 68.009438 80 65 80C61.990559 80 59.490559 81.12854 57.5 83.385628C55.509441 81.12854 53.009438 80 49.999996 80C46.990555 80 44.490559 81.12854 42.5 83.38562C40.509441 81.12854 38.009441 80 35 80C31.990557 80 29.490559 81.12854 27.500002 83.38562C25.509443 81.12854 23.009443 80 20 80C19.567553 80 19.136921 80.027931 18.708101 80.083801C18.155426 79.104851 17.457518 78.243584 16.614376 77.5C18.871458 75.509438 20 73.009438 20 70C20 66.990562 18.871458 64.490562 16.614376 62.5C18.871458 60.509441 20 58.009441 20 55C20 51.990559 18.871458 49.490559 16.614376 47.5C18.871458 45.509441 20 43.009441 20 40C20 36.990559 18.871458 34.490559 16.614376 32.5C18.871458 30.509443 20 28.009443 20 25C20 21.990557 18.871458 19.490559 16.614376 17.500002Z" fill-rule="evenodd"/>
|
||||
</svg>
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
---
|
||||
source: src/render/svg/tests.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="120" height="100" viewBox="0 0 120 100">
|
||||
<path fill="red" transform="translate(-10 -10)" d="M50 10C46.990559 10 44.490559 11.128541 42.5 13.385624C40.509441 11.128541 38.009441 10 35 10C31.990557 10 29.490559 11.128541 27.500002 13.385624C25.509443 11.128541 23.009443 10 20 10C13.333333 10 10 13.333333 10 20C10 23.009443 11.128541 25.509441 13.385624 27.499998C11.128541 29.490557 10 31.990557 10 35C10 38.009441 11.128541 40.509441 13.385624 42.5C11.128541 44.490559 10 46.990559 10 50C10 53.009441 11.128541 55.509441 13.385624 57.5C11.128541 59.490559 10 61.990559 10 65C10 68.009438 11.128541 70.509438 13.385624 72.5C11.128541 74.490562 10 76.990562 10 80C10 83.009438 11.128541 85.509438 13.385624 87.5C11.128541 89.490562 10 91.990562 10 95C10 101.66666 13.333333 105 20 105C20.432447 105 20.863079 104.97207 21.291899 104.9162C23.205288 108.3054 26.107988 110 30 110C33.009441 110 35.509441 108.87146 37.5 106.61438C39.490559 108.87146 41.990559 110 45 110C48.009441 110 50.509438 108.87146 52.499996 106.61438C54.490555 108.87146 56.990555 110 59.999996 110C63.009438 110 65.509438 108.87146 67.5 106.61437C69.490555 108.87146 71.990555 110 75 110C78.009438 110 80.509438 108.87146 82.5 106.61438C84.490562 108.87146 86.990562 110 90 110C93.009438 110 95.509438 108.87146 97.5 106.61438C99.490562 108.87146 101.99056 110 105 110C108.00944 110 110.50944 108.87146 112.5 106.61438C114.49056 108.87146 116.99056 110 120 110C126.66666 110 130 106.66666 130 100C130 96.990562 128.87146 94.490562 126.61438 92.5C128.87146 90.509438 130 88.009438 130 85C130 81.990562 128.87146 79.490562 126.61438 77.5C128.87146 75.509438 130 73.009438 130 70C130 66.990562 128.87146 64.490562 126.61438 62.5C128.87146 60.509441 130 58.009441 130 55C130 51.990559 128.87146 49.490559 126.61438 47.5C128.87146 45.509441 130 43.009441 130 40C130 36.990559 128.87146 34.490559 126.61438 32.5C128.87146 30.509443 130 28.009443 130 25C130 18.333332 126.66666 15 120 15C119.56756 15 119.13692 15.027935 118.7081 15.083804C116.79472 11.694601 113.89201 10 110 10C106.99056 10 104.49056 11.128541 102.5 13.385624C100.50944 11.128541 98.009438 10 95 10C91.990562 10 89.490562 11.128541 87.5 13.385624C85.509438 11.128541 83.009438 10 80 10C76.990562 10 74.490562 11.128541 72.5 13.385624C70.509438 11.128541 68.009438 10 65 10C61.990559 10 59.490559 11.128541 57.5 13.385624C55.509441 11.128541 53.009441 10 50 10ZM120 20L20 20L20 100L120 100L120 20Z" fill-rule="evenodd"/>
|
||||
</svg>
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
---
|
||||
source: src/render/svg/tests.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="108" height="88" viewBox="0 0 108 88">
|
||||
<path fill="blue" transform="translate(-6 -6)" d="M23 6L10 6L10 14L23 14L23 6ZM45 6L36 6L36 14L45 14L45 6ZM71 6L58 6L58 14L71 14L71 6ZM93 6L84 6L84 14L93 14L93 6ZM114 6L106 6L106 19L114 19L114 6ZM6 21L6 34L14 34L14 21L6 21ZM114 41L114 32L106 32L106 41L114 41ZM6 47L6 56L14 56L14 47L6 47ZM114 67L114 54L106 54L106 67L114 67ZM6 69L6 82L14 82L14 69L6 69ZM114 89L114 80L106 80L106 89L114 89ZM15 94L24 94L24 86L15 86L15 94ZM37 94L49.999996 94L49.999996 86L37 86L37 94ZM63 94L72 94L72 86L63 86L63 94ZM85 94L98 94L98 86L85 86L85 94Z" fill-rule="evenodd"/>
|
||||
</svg>
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
---
|
||||
source: src/render/svg/tests.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="140" height="100" viewBox="0 0 140 100">
|
||||
<rect fill="#FFD400" width="140" height="100"/>
|
||||
<path fill="#1040FF" d="M0 0L140 0L140 100L0 100L0 0ZM40 4L128 4L128 76L40 76L40 4Z" fill-rule="evenodd"/>
|
||||
</svg>
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
---
|
||||
source: src/render/svg/tests.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="108" height="88" viewBox="0 0 108 88">
|
||||
<rect fill="none" stroke="blue" stroke-width="8" stroke-miterlimit="4" transform="translate(-6 -6)" x="10" y="10" width="100" height="80"/>
|
||||
</svg>
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
---
|
||||
source: src/render/svg/tests.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100" height="80" viewBox="0 0 100 80">
|
||||
<path fill="blue" transform="translate(-10 -10)" d="M110 10L10 10L10 90L110 90L110 10ZM20 20L20 80L100 80L100 20L20 20Z" fill-rule="evenodd"/>
|
||||
</svg>
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
---
|
||||
source: src/render/svg/tests.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="120" height="100" viewBox="0 0 120 100">
|
||||
<path fill="red" transform="translate(-10 -10)" d="M10 10L130 10L130 110L10 110L10 10ZM120 20L20 20L20 100L120 100L120 20Z" fill-rule="evenodd"/>
|
||||
</svg>
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
---
|
||||
source: src/render/svg/tests.rs
|
||||
assertion_line: 454
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="171.24356" height="156.60254" viewBox="0 0 171.24356 156.60254">
|
||||
<path fill="#FFD400" transform="matrix(0.866025 0.5 -0.5 0.866025 50 0)" d="M0 0L140 0L140 100L-9.5367432e-07 100L0 0Z"/>
|
||||
<path fill="#1040FF" transform="matrix(0.866025 0.5 -0.5 0.866025 50 0)" d="M140 0L0 0L0 100L140 100L140 0ZM11.999999 88L12 12L128 12L128 88L11.999999 88Z" fill-rule="evenodd"/>
|
||||
</svg>
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
---
|
||||
source: src/render/svg/tests.rs
|
||||
assertion_line: 367
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="204.02817" height="189.38715" viewBox="0 0 204.02817 189.38715">
|
||||
<rect fill="#FFD400" transform="matrix(0.866025 0.5 -0.5 0.866025 66.3923 16.3923)" width="140" height="100"/>
|
||||
<path fill="#1040FF" transform="matrix(0.866025 0.5 -0.5 0.866025 66.3923 16.3923)" d="M-12 -12L152 -12L152 112L-12 112L-12 -12ZM140 0L0 0L0 100L140 100L140 0Z" fill-rule="evenodd"/>
|
||||
</svg>
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::fixtures::*;
|
||||
|
||||
use crate::shapes::{BlendMode, Fill, SolidColor};
|
||||
use crate::shapes::{BlendMode, Fill, ImageFill, SolidColor, StrokeCap, StrokeKind};
|
||||
use crate::state::ShapesPool;
|
||||
use crate::uuid::Uuid;
|
||||
|
||||
@@ -208,6 +208,47 @@ fn exports_an_unclipped_frame_with_overflowing_child() {
|
||||
insta::assert_snapshot!(svg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exports_clipped_frame_with_solid_outer_stroke() {
|
||||
// Regression: frame content clip must not wrap strokes — outer strokes
|
||||
// sit outside the selrect and would be fully clipped away.
|
||||
let mut pool = ShapesPool::new();
|
||||
let id = uid(1);
|
||||
add_frame(
|
||||
&mut pool,
|
||||
id,
|
||||
Uuid::nil(),
|
||||
(0.0, 0.0, 140.0, 100.0),
|
||||
skia::Color::from_rgb(0xee, 0xee, 0xee),
|
||||
true,
|
||||
);
|
||||
{
|
||||
let shape = pool.get_mut(&id).unwrap();
|
||||
shape.add_stroke(solid_stroke(
|
||||
StrokeKind::Outer,
|
||||
12.0,
|
||||
skia::Color::from_rgb(0x10, 0x40, 0xff),
|
||||
));
|
||||
}
|
||||
|
||||
let svg = render(&pool, id);
|
||||
assert!(
|
||||
svg.contains("clip-path=\"url(#"),
|
||||
"clipped frame must keep content clip: {svg}"
|
||||
);
|
||||
assert!(
|
||||
svg.contains("fill-rule=\"evenodd\""),
|
||||
"outer stroke must emit an evenodd outline: {svg}"
|
||||
);
|
||||
let stroke_pos = svg.find("fill-rule=\"evenodd\"").expect("stroke outline");
|
||||
let clip_close = svg.find("</g>").expect("clip group close");
|
||||
assert!(
|
||||
stroke_pos > clip_close,
|
||||
"outer stroke must be outside the content clip group: {svg}"
|
||||
);
|
||||
insta::assert_snapshot!(svg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exports_text_with_multiple_solid_fills() {
|
||||
let mut pool = ShapesPool::new();
|
||||
@@ -240,6 +281,661 @@ fn exports_text_with_multiple_solid_fills() {
|
||||
insta::assert_snapshot!(svg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exports_rect_with_solid_inner_stroke() {
|
||||
let mut pool = ShapesPool::new();
|
||||
let id = uid(1);
|
||||
add_stroked_rect(
|
||||
&mut pool,
|
||||
id,
|
||||
Uuid::nil(),
|
||||
(10.0, 10.0, 110.0, 90.0),
|
||||
solid_stroke(StrokeKind::Inner, 10.0, skia::Color::from_rgb(0, 0, 255)),
|
||||
);
|
||||
|
||||
let svg = render(&pool, id);
|
||||
assert!(
|
||||
svg.contains("fill=\"blue\"") || svg.to_ascii_lowercase().contains("fill=\"#0000ff\""),
|
||||
"inner stroke must emit a filled outline: {svg}"
|
||||
);
|
||||
assert!(
|
||||
svg.contains("fill-rule=\"evenodd\""),
|
||||
"aligned stroke outline should use evenodd: {svg}"
|
||||
);
|
||||
insta::assert_snapshot!(svg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exports_rect_with_per_side_solid_inner_stroke() {
|
||||
// Regression: solid Inner/Outer SVG expansion used stroke_to_path with a
|
||||
// uniform width and ignored stroke.widths. Per-side must use the GPU
|
||||
// evenodd band (top/right/bottom/left).
|
||||
let mut pool = ShapesPool::new();
|
||||
let id = uid(1);
|
||||
let mut stroke = solid_stroke(
|
||||
StrokeKind::Inner,
|
||||
20.0,
|
||||
skia::Color::from_rgb(0x10, 0x40, 0xff),
|
||||
);
|
||||
stroke.widths = Some([4.0, 12.0, 24.0, 40.0]); // top, right, bottom, left
|
||||
add_stroked_rect(&mut pool, id, Uuid::nil(), (0.0, 0.0, 140.0, 100.0), stroke);
|
||||
{
|
||||
let shape = pool.get_mut(&id).unwrap();
|
||||
shape.set_fills(vec![Fill::Solid(SolidColor(skia::Color::from_rgb(
|
||||
0xff, 0xd4, 0x00,
|
||||
)))]);
|
||||
}
|
||||
|
||||
let svg = render(&pool, id);
|
||||
assert!(
|
||||
svg.contains("fill-rule=\"evenodd\""),
|
||||
"per-side inner stroke must emit an evenodd band: {svg}"
|
||||
);
|
||||
// Inner hole for (0,0)-(140,100) with [4,12,24,40]: (40,4)-(128,76).
|
||||
// Uniform width=20 would incorrectly hole at (20,20)-(120,80).
|
||||
assert!(
|
||||
svg.contains("40") && svg.contains("128") && svg.contains("76"),
|
||||
"per-side hole must reflect left=40 / right=12 / bottom=24, got: {svg}"
|
||||
);
|
||||
assert!(
|
||||
!svg.contains("M20 20") && !svg.contains("L20 20"),
|
||||
"must not use uniform width=20 inset: {svg}"
|
||||
);
|
||||
insta::assert_snapshot!(svg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exports_rect_with_solid_center_stroke() {
|
||||
let mut pool = ShapesPool::new();
|
||||
let id = uid(1);
|
||||
add_stroked_rect(
|
||||
&mut pool,
|
||||
id,
|
||||
Uuid::nil(),
|
||||
(10.0, 10.0, 110.0, 90.0),
|
||||
solid_stroke(StrokeKind::Center, 8.0, skia::Color::from_rgb(0, 0, 255)),
|
||||
);
|
||||
|
||||
let svg = render(&pool, id);
|
||||
assert!(
|
||||
svg.contains("stroke=\"blue\"") || svg.to_ascii_lowercase().contains("stroke=\"#0000ff\""),
|
||||
"center stroke must keep a stroke attribute: {svg}"
|
||||
);
|
||||
assert!(
|
||||
!svg.contains("fill-rule=\"evenodd\""),
|
||||
"center stroke must not expand to an evenodd outline: {svg}"
|
||||
);
|
||||
insta::assert_snapshot!(svg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exports_rect_with_solid_outer_stroke() {
|
||||
let mut pool = ShapesPool::new();
|
||||
let id = uid(1);
|
||||
add_stroked_rect(
|
||||
&mut pool,
|
||||
id,
|
||||
Uuid::nil(),
|
||||
(20.0, 20.0, 120.0, 100.0),
|
||||
solid_stroke(StrokeKind::Outer, 10.0, skia::Color::from_rgb(255, 0, 0)),
|
||||
);
|
||||
|
||||
let svg = render(&pool, id);
|
||||
assert!(
|
||||
svg.contains("fill=\"red\"") || svg.to_ascii_lowercase().contains("fill=\"#ff0000\""),
|
||||
"outer stroke must emit a filled outline: {svg}"
|
||||
);
|
||||
assert!(
|
||||
svg.contains("fill-rule=\"evenodd\""),
|
||||
"aligned stroke outline should use evenodd: {svg}"
|
||||
);
|
||||
insta::assert_snapshot!(svg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exports_rotated_rect_with_solid_outer_stroke() {
|
||||
// Regression: outline strokes must use local selrect geometry. Baking
|
||||
// `centered_transform` into the path (via rect_segments) while the leaf
|
||||
// canvas also concatenates it double-rotates the stroke.
|
||||
let mut pool = ShapesPool::new();
|
||||
let id = uid(1);
|
||||
add_stroked_rect(
|
||||
&mut pool,
|
||||
id,
|
||||
Uuid::nil(),
|
||||
(0.0, 0.0, 140.0, 100.0),
|
||||
solid_stroke(
|
||||
StrokeKind::Outer,
|
||||
12.0,
|
||||
skia::Color::from_rgb(0x10, 0x40, 0xff),
|
||||
),
|
||||
);
|
||||
{
|
||||
let shape = pool.get_mut(&id).unwrap();
|
||||
// 30° rotation (cos≈0.866, sin=0.5), matching a workspace export case.
|
||||
let c = 0.866_025_4_f32;
|
||||
let s = 0.5_f32;
|
||||
shape.set_transform(c, s, -s, c, 0.0, 0.0);
|
||||
shape.set_rotation(30.0);
|
||||
shape.set_fills(vec![Fill::Solid(SolidColor(skia::Color::from_rgb(
|
||||
0xff, 0xd4, 0x00,
|
||||
)))]);
|
||||
}
|
||||
|
||||
let svg = render(&pool, id);
|
||||
assert!(
|
||||
svg.contains("fill-rule=\"evenodd\""),
|
||||
"rotated outer stroke must emit an evenodd outline: {svg}"
|
||||
);
|
||||
// Fill rect + stroke path should both carry the same leaf CTM (one rotation).
|
||||
assert!(
|
||||
svg.matches("matrix(0.866025").count() >= 2,
|
||||
"fill and stroke must each use the rotation matrix once: {svg}"
|
||||
);
|
||||
// Outline path data must stay in local selrect space (roughly [-stroke, w+stroke]).
|
||||
// Double rotation bakes world-space points into `d` before the CTM is applied.
|
||||
let d_attr = svg
|
||||
.split("d=\"")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split('"').next())
|
||||
.unwrap_or("");
|
||||
let first_num = d_attr
|
||||
.trim_start_matches(|c: char| !c.is_ascii_digit() && c != '-' && c != '.')
|
||||
.split(|c: char| !c.is_ascii_digit() && c != '-' && c != '.')
|
||||
.find(|s| !s.is_empty())
|
||||
.and_then(|s| s.parse::<f32>().ok());
|
||||
assert!(
|
||||
matches!(first_num, Some(n) if (-40.0..180.0).contains(&n)),
|
||||
"stroke path d= should start in local coords, got {first_num:?} from {d_attr}: {svg}"
|
||||
);
|
||||
insta::assert_snapshot!(svg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exports_closed_path_with_solid_inner_stroke() {
|
||||
let mut pool = ShapesPool::new();
|
||||
let id = uid(1);
|
||||
add_stroked_closed_path(
|
||||
&mut pool,
|
||||
id,
|
||||
Uuid::nil(),
|
||||
(0.0, 0.0, 100.0, 80.0),
|
||||
solid_stroke(StrokeKind::Inner, 8.0, skia::Color::from_rgb(0, 0, 255)),
|
||||
);
|
||||
|
||||
let svg = render(&pool, id);
|
||||
assert!(
|
||||
svg.contains("fill=\"blue\"") || svg.to_ascii_lowercase().contains("fill=\"#0000ff\""),
|
||||
"closed path inner stroke must emit a filled outline: {svg}"
|
||||
);
|
||||
assert!(
|
||||
svg.contains("fill-rule=\"evenodd\""),
|
||||
"aligned stroke outline should use evenodd: {svg}"
|
||||
);
|
||||
insta::assert_snapshot!(svg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exports_rotated_closed_path_with_solid_inner_stroke() {
|
||||
// Regression: path content is stored in parent space; stroke outlines must
|
||||
// apply `to_path_transform` (like fills via get_skia_path) before drawing
|
||||
// under the leaf `centered_transform`, or the stroke double-rotates.
|
||||
use crate::shapes::Type;
|
||||
|
||||
let mut pool = ShapesPool::new();
|
||||
let id = uid(1);
|
||||
add_stroked_closed_path(
|
||||
&mut pool,
|
||||
id,
|
||||
Uuid::nil(),
|
||||
(0.0, 0.0, 140.0, 100.0),
|
||||
solid_stroke(
|
||||
StrokeKind::Inner,
|
||||
12.0,
|
||||
skia::Color::from_rgb(0x10, 0x40, 0xff),
|
||||
),
|
||||
);
|
||||
{
|
||||
let shape = pool.get_mut(&id).unwrap();
|
||||
let c = 0.866_025_4_f32;
|
||||
let s = 0.5_f32;
|
||||
shape.set_transform(c, s, -s, c, 0.0, 0.0);
|
||||
shape.set_rotation(30.0);
|
||||
shape.set_fills(vec![Fill::Solid(SolidColor(skia::Color::from_rgb(
|
||||
0xff, 0xd4, 0x00,
|
||||
)))]);
|
||||
|
||||
// Bake rotation into path points (Penpot path storage model).
|
||||
let bake = shape.centered_transform();
|
||||
if let Type::Path(ref mut path) = shape.shape_type {
|
||||
path.transform(&bake);
|
||||
let b = path.bounds();
|
||||
shape.set_selrect(b.min_x(), b.min_y(), b.max_x(), b.max_y());
|
||||
}
|
||||
}
|
||||
|
||||
let svg = render(&pool, id);
|
||||
assert!(
|
||||
svg.contains("fill-rule=\"evenodd\""),
|
||||
"rotated path inner stroke must emit an evenodd outline: {svg}"
|
||||
);
|
||||
assert!(
|
||||
svg.matches("matrix(0.866025").count() >= 2,
|
||||
"fill and stroke must each use the rotation matrix once: {svg}"
|
||||
);
|
||||
// Stroke outline `d` must stay in local (unrotated) space like the fill.
|
||||
let stroke_d = svg
|
||||
.split("fill-rule=\"evenodd\"")
|
||||
.next()
|
||||
.and_then(|before| before.rsplit("d=\"").next())
|
||||
.and_then(|s| s.split('"').next())
|
||||
.unwrap_or("");
|
||||
let first_num = stroke_d
|
||||
.trim_start_matches(|c: char| !c.is_ascii_digit() && c != '-' && c != '.')
|
||||
.split(|c: char| !c.is_ascii_digit() && c != '-' && c != '.')
|
||||
.find(|s| !s.is_empty())
|
||||
.and_then(|s| s.parse::<f32>().ok());
|
||||
assert!(
|
||||
matches!(first_num, Some(n) if (-40.0..180.0).contains(&n)),
|
||||
"stroke path d= should start in local coords, got {first_num:?} from {stroke_d}: {svg}"
|
||||
);
|
||||
insta::assert_snapshot!(svg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exports_closed_path_with_solid_center_stroke() {
|
||||
let mut pool = ShapesPool::new();
|
||||
let id = uid(1);
|
||||
add_stroked_closed_path(
|
||||
&mut pool,
|
||||
id,
|
||||
Uuid::nil(),
|
||||
(0.0, 0.0, 100.0, 80.0),
|
||||
solid_stroke(StrokeKind::Center, 8.0, skia::Color::from_rgb(0, 128, 0)),
|
||||
);
|
||||
|
||||
let svg = render(&pool, id);
|
||||
assert!(
|
||||
svg.contains("stroke=\"green\"") || svg.to_ascii_lowercase().contains("stroke=\"#008000\""),
|
||||
"closed path center stroke must keep a stroke attribute: {svg}"
|
||||
);
|
||||
assert!(
|
||||
!svg.contains("fill-rule=\"evenodd\""),
|
||||
"center stroke must not expand to an evenodd outline: {svg}"
|
||||
);
|
||||
insta::assert_snapshot!(svg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exports_closed_path_with_solid_outer_stroke() {
|
||||
let mut pool = ShapesPool::new();
|
||||
let id = uid(1);
|
||||
add_stroked_closed_path(
|
||||
&mut pool,
|
||||
id,
|
||||
Uuid::nil(),
|
||||
(0.0, 0.0, 100.0, 80.0),
|
||||
solid_stroke(StrokeKind::Outer, 8.0, skia::Color::from_rgb(0, 128, 0)),
|
||||
);
|
||||
|
||||
let svg = render(&pool, id);
|
||||
// Path outer previously used save_layer+Clear (dropped by SkSVG). Must not
|
||||
// be a bare stroked path with no visible paint.
|
||||
assert!(
|
||||
svg.contains("fill=\"green\"") || svg.to_ascii_lowercase().contains("fill=\"#008000\""),
|
||||
"closed path outer stroke must emit a filled outline: {svg}"
|
||||
);
|
||||
assert!(
|
||||
svg.contains("fill-rule=\"evenodd\""),
|
||||
"aligned stroke outline should use evenodd: {svg}"
|
||||
);
|
||||
insta::assert_snapshot!(svg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exports_open_path_with_solid_inner_stroke() {
|
||||
// Open paths force Center alignment regardless of the requested kind.
|
||||
let mut pool = ShapesPool::new();
|
||||
let id = uid(1);
|
||||
add_stroked_open_path(
|
||||
&mut pool,
|
||||
id,
|
||||
Uuid::nil(),
|
||||
(0.0, 0.0, 100.0, 80.0),
|
||||
solid_stroke(StrokeKind::Inner, 8.0, skia::Color::from_rgb(0, 0, 255)),
|
||||
);
|
||||
|
||||
let svg = render(&pool, id);
|
||||
assert!(
|
||||
svg.contains("stroke=\"blue\"") || svg.to_ascii_lowercase().contains("stroke=\"#0000ff\""),
|
||||
"open path inner stroke must render as center stroke: {svg}"
|
||||
);
|
||||
assert!(
|
||||
!svg.contains("fill-rule=\"evenodd\""),
|
||||
"open path must not expand to an evenodd outline: {svg}"
|
||||
);
|
||||
insta::assert_snapshot!(svg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exports_open_path_with_solid_center_stroke() {
|
||||
let mut pool = ShapesPool::new();
|
||||
let id = uid(1);
|
||||
add_stroked_open_path(
|
||||
&mut pool,
|
||||
id,
|
||||
Uuid::nil(),
|
||||
(0.0, 0.0, 100.0, 80.0),
|
||||
solid_stroke(StrokeKind::Center, 8.0, skia::Color::from_rgb(255, 0, 0)),
|
||||
);
|
||||
|
||||
let svg = render(&pool, id);
|
||||
assert!(
|
||||
svg.contains("stroke=\"red\"") || svg.to_ascii_lowercase().contains("stroke=\"#ff0000\""),
|
||||
"open path center stroke must keep a stroke attribute: {svg}"
|
||||
);
|
||||
assert!(
|
||||
!svg.contains("fill-rule=\"evenodd\""),
|
||||
"open path must not expand to an evenodd outline: {svg}"
|
||||
);
|
||||
insta::assert_snapshot!(svg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exports_open_path_with_solid_outer_stroke() {
|
||||
// Open paths force Center alignment regardless of the requested kind.
|
||||
let mut pool = ShapesPool::new();
|
||||
let id = uid(1);
|
||||
add_stroked_open_path(
|
||||
&mut pool,
|
||||
id,
|
||||
Uuid::nil(),
|
||||
(0.0, 0.0, 100.0, 80.0),
|
||||
solid_stroke(StrokeKind::Outer, 8.0, skia::Color::from_rgb(0, 128, 0)),
|
||||
);
|
||||
|
||||
let svg = render(&pool, id);
|
||||
assert!(
|
||||
svg.contains("stroke=\"green\"") || svg.to_ascii_lowercase().contains("stroke=\"#008000\""),
|
||||
"open path outer stroke must render as center stroke: {svg}"
|
||||
);
|
||||
assert!(
|
||||
!svg.contains("fill-rule=\"evenodd\""),
|
||||
"open path must not expand to an evenodd outline: {svg}"
|
||||
);
|
||||
insta::assert_snapshot!(svg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exports_rect_with_dotted_inner_stroke() {
|
||||
let mut pool = ShapesPool::new();
|
||||
let id = uid(1);
|
||||
add_stroked_rect(
|
||||
&mut pool,
|
||||
id,
|
||||
Uuid::nil(),
|
||||
(10.0, 10.0, 110.0, 90.0),
|
||||
dotted_stroke(StrokeKind::Inner, 10.0, skia::Color::from_rgb(0, 0, 255)),
|
||||
);
|
||||
|
||||
let svg = render(&pool, id);
|
||||
assert!(
|
||||
svg.contains("fill=\"blue\"") || svg.to_ascii_lowercase().contains("fill=\"#0000ff\""),
|
||||
"dotted inner stroke must emit filled geometry: {svg}"
|
||||
);
|
||||
insta::assert_snapshot!(svg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exports_rect_with_dotted_center_stroke() {
|
||||
let mut pool = ShapesPool::new();
|
||||
let id = uid(1);
|
||||
add_stroked_rect(
|
||||
&mut pool,
|
||||
id,
|
||||
Uuid::nil(),
|
||||
(10.0, 10.0, 110.0, 90.0),
|
||||
dotted_stroke(StrokeKind::Center, 8.0, skia::Color::from_rgb(0, 0, 255)),
|
||||
);
|
||||
|
||||
let svg = render(&pool, id);
|
||||
// PathEffect does not serialize; dots expand to filled outline geometry.
|
||||
assert!(
|
||||
svg.contains("fill=\"blue\"") || svg.to_ascii_lowercase().contains("fill=\"#0000ff\""),
|
||||
"dotted center stroke must emit filled geometry: {svg}"
|
||||
);
|
||||
insta::assert_snapshot!(svg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exports_rect_with_dotted_outer_stroke() {
|
||||
let mut pool = ShapesPool::new();
|
||||
let id = uid(1);
|
||||
add_stroked_rect(
|
||||
&mut pool,
|
||||
id,
|
||||
Uuid::nil(),
|
||||
(20.0, 20.0, 120.0, 100.0),
|
||||
dotted_stroke(StrokeKind::Outer, 10.0, skia::Color::from_rgb(255, 0, 0)),
|
||||
);
|
||||
|
||||
let svg = render(&pool, id);
|
||||
assert!(
|
||||
svg.contains("fill=\"red\"") || svg.to_ascii_lowercase().contains("fill=\"#ff0000\""),
|
||||
"dotted outer stroke must emit filled geometry: {svg}"
|
||||
);
|
||||
insta::assert_snapshot!(svg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exports_closed_path_with_dotted_inner_stroke() {
|
||||
let mut pool = ShapesPool::new();
|
||||
let id = uid(1);
|
||||
add_stroked_closed_path(
|
||||
&mut pool,
|
||||
id,
|
||||
Uuid::nil(),
|
||||
(0.0, 0.0, 100.0, 80.0),
|
||||
dotted_stroke(StrokeKind::Inner, 8.0, skia::Color::from_rgb(0, 0, 255)),
|
||||
);
|
||||
|
||||
let svg = render(&pool, id);
|
||||
assert!(
|
||||
svg.contains("fill=\"blue\"") || svg.to_ascii_lowercase().contains("fill=\"#0000ff\""),
|
||||
"closed path dotted inner stroke must emit filled geometry: {svg}"
|
||||
);
|
||||
insta::assert_snapshot!(svg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exports_closed_path_with_dotted_center_stroke() {
|
||||
let mut pool = ShapesPool::new();
|
||||
let id = uid(1);
|
||||
add_stroked_closed_path(
|
||||
&mut pool,
|
||||
id,
|
||||
Uuid::nil(),
|
||||
(0.0, 0.0, 100.0, 80.0),
|
||||
dotted_stroke(StrokeKind::Center, 8.0, skia::Color::from_rgb(0, 128, 0)),
|
||||
);
|
||||
|
||||
let svg = render(&pool, id);
|
||||
assert!(
|
||||
svg.contains("fill=\"green\"") || svg.to_ascii_lowercase().contains("fill=\"#008000\""),
|
||||
"closed path dotted center stroke must emit filled geometry: {svg}"
|
||||
);
|
||||
insta::assert_snapshot!(svg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exports_closed_path_with_dotted_outer_stroke() {
|
||||
let mut pool = ShapesPool::new();
|
||||
let id = uid(1);
|
||||
add_stroked_closed_path(
|
||||
&mut pool,
|
||||
id,
|
||||
Uuid::nil(),
|
||||
(0.0, 0.0, 100.0, 80.0),
|
||||
dotted_stroke(StrokeKind::Outer, 8.0, skia::Color::from_rgb(0, 128, 0)),
|
||||
);
|
||||
|
||||
let svg = render(&pool, id);
|
||||
assert!(
|
||||
svg.contains("fill=\"green\"") || svg.to_ascii_lowercase().contains("fill=\"#008000\""),
|
||||
"closed path dotted outer stroke must emit filled geometry: {svg}"
|
||||
);
|
||||
insta::assert_snapshot!(svg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exports_open_path_with_dotted_center_stroke() {
|
||||
let mut pool = ShapesPool::new();
|
||||
let id = uid(1);
|
||||
add_stroked_open_path(
|
||||
&mut pool,
|
||||
id,
|
||||
Uuid::nil(),
|
||||
(0.0, 0.0, 100.0, 80.0),
|
||||
dotted_stroke(StrokeKind::Center, 8.0, skia::Color::from_rgb(255, 0, 0)),
|
||||
);
|
||||
|
||||
let svg = render(&pool, id);
|
||||
assert!(
|
||||
svg.contains("fill=\"red\"") || svg.to_ascii_lowercase().contains("fill=\"#ff0000\""),
|
||||
"open path dotted stroke must emit filled geometry: {svg}"
|
||||
);
|
||||
insta::assert_snapshot!(svg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exports_open_path_with_dotted_stroke_and_caps() {
|
||||
// Regression: dotted/dashed SVG expansion used stroke_to_path and returned
|
||||
// before draw_stroke_geometry, so open-path caps (triangle/circle/…) were
|
||||
// dropped. Caps must be overlaid after the expanded outline.
|
||||
let mut pool = ShapesPool::new();
|
||||
let id = uid(1);
|
||||
let mut stroke = dotted_stroke(
|
||||
StrokeKind::Center,
|
||||
12.0,
|
||||
skia::Color::from_rgb(0x10, 0x40, 0xff),
|
||||
);
|
||||
stroke.cap_start = Some(StrokeCap::TriangleArrow);
|
||||
stroke.cap_end = Some(StrokeCap::CircleMarker);
|
||||
add_stroked_open_path(&mut pool, id, Uuid::nil(), (0.0, 0.0, 140.0, 90.0), stroke);
|
||||
|
||||
let svg = render(&pool, id);
|
||||
assert!(
|
||||
svg.contains("fill=\"#1040FF\"") || svg.to_ascii_lowercase().contains("fill=\"#1040ff\""),
|
||||
"dotted stroke with caps must emit filled geometry: {svg}"
|
||||
);
|
||||
// Caps are separate filled draws (triangle + circle), not only the dotted outline.
|
||||
assert!(
|
||||
svg.matches("<path ").count() >= 2 || svg.contains("<circle"),
|
||||
"expected separate cap geometry besides the dotted outline: {svg}"
|
||||
);
|
||||
insta::assert_snapshot!(svg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exports_rect_with_dashed_center_stroke() {
|
||||
let mut pool = ShapesPool::new();
|
||||
let id = uid(1);
|
||||
add_stroked_rect(
|
||||
&mut pool,
|
||||
id,
|
||||
Uuid::nil(),
|
||||
(10.0, 10.0, 110.0, 90.0),
|
||||
dashed_stroke(StrokeKind::Center, 8.0, skia::Color::from_rgb(0, 0, 255)),
|
||||
);
|
||||
|
||||
let svg = render(&pool, id);
|
||||
assert!(
|
||||
svg.contains("fill=\"blue\"") || svg.to_ascii_lowercase().contains("fill=\"#0000ff\""),
|
||||
"dashed center stroke must emit filled geometry: {svg}"
|
||||
);
|
||||
insta::assert_snapshot!(svg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exports_rect_with_dashed_outer_stroke() {
|
||||
let mut pool = ShapesPool::new();
|
||||
let id = uid(1);
|
||||
add_stroked_rect(
|
||||
&mut pool,
|
||||
id,
|
||||
Uuid::nil(),
|
||||
(20.0, 20.0, 120.0, 100.0),
|
||||
dashed_stroke(StrokeKind::Outer, 10.0, skia::Color::from_rgb(255, 0, 0)),
|
||||
);
|
||||
|
||||
let svg = render(&pool, id);
|
||||
assert!(
|
||||
svg.contains("fill=\"red\"") || svg.to_ascii_lowercase().contains("fill=\"#ff0000\""),
|
||||
"dashed outer stroke must emit filled geometry: {svg}"
|
||||
);
|
||||
insta::assert_snapshot!(svg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exports_closed_path_with_dashed_inner_stroke() {
|
||||
let mut pool = ShapesPool::new();
|
||||
let id = uid(1);
|
||||
add_stroked_closed_path(
|
||||
&mut pool,
|
||||
id,
|
||||
Uuid::nil(),
|
||||
(0.0, 0.0, 100.0, 80.0),
|
||||
dashed_stroke(StrokeKind::Inner, 8.0, skia::Color::from_rgb(0, 128, 0)),
|
||||
);
|
||||
|
||||
let svg = render(&pool, id);
|
||||
assert!(
|
||||
svg.contains("fill=\"green\"") || svg.to_ascii_lowercase().contains("fill=\"#008000\""),
|
||||
"closed path dashed inner stroke must emit filled geometry: {svg}"
|
||||
);
|
||||
insta::assert_snapshot!(svg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exports_rect_with_mixed_center_stroke() {
|
||||
let mut pool = ShapesPool::new();
|
||||
let id = uid(1);
|
||||
add_stroked_rect(
|
||||
&mut pool,
|
||||
id,
|
||||
Uuid::nil(),
|
||||
(10.0, 10.0, 110.0, 90.0),
|
||||
mixed_stroke(StrokeKind::Center, 8.0, skia::Color::from_rgb(0, 0, 255)),
|
||||
);
|
||||
|
||||
let svg = render(&pool, id);
|
||||
assert!(
|
||||
svg.contains("fill=\"blue\"") || svg.to_ascii_lowercase().contains("fill=\"#0000ff\""),
|
||||
"mixed center stroke must emit filled geometry: {svg}"
|
||||
);
|
||||
insta::assert_snapshot!(svg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exports_closed_path_with_mixed_outer_stroke() {
|
||||
let mut pool = ShapesPool::new();
|
||||
let id = uid(1);
|
||||
add_stroked_closed_path(
|
||||
&mut pool,
|
||||
id,
|
||||
Uuid::nil(),
|
||||
(0.0, 0.0, 100.0, 80.0),
|
||||
mixed_stroke(StrokeKind::Outer, 8.0, skia::Color::from_rgb(255, 0, 0)),
|
||||
);
|
||||
|
||||
let svg = render(&pool, id);
|
||||
assert!(
|
||||
svg.contains("fill=\"red\"") || svg.to_ascii_lowercase().contains("fill=\"#ff0000\""),
|
||||
"closed path mixed outer stroke must emit filled geometry: {svg}"
|
||||
);
|
||||
insta::assert_snapshot!(svg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exports_solid_text_with_font_face() {
|
||||
let mut pool = ShapesPool::new();
|
||||
@@ -266,3 +962,229 @@ fn exports_solid_text_with_font_face() {
|
||||
);
|
||||
insta::assert_snapshot!(svg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exports_image_fill_on_text() {
|
||||
let mut pool = ShapesPool::new();
|
||||
let id = uid(1);
|
||||
let image_id = uid(42);
|
||||
add_image_text(
|
||||
&mut pool,
|
||||
id,
|
||||
(0.0, 0.0, 560.0, 240.0),
|
||||
"HOLA",
|
||||
200.0,
|
||||
image_id,
|
||||
);
|
||||
|
||||
let svg = render_with(&pool, id, |resources| {
|
||||
resources
|
||||
.images
|
||||
.set_source_url(image_id, TEST_IMAGE_URL.to_string());
|
||||
});
|
||||
|
||||
assert!(
|
||||
svg.contains("<image") && svg.contains(TEST_IMAGE_URL),
|
||||
"text image fill must emit a linked <image>: {svg}"
|
||||
);
|
||||
assert!(
|
||||
svg.contains("clip-path=\"url(#"),
|
||||
"text image fill must be clipped to glyph silhouette: {svg}"
|
||||
);
|
||||
assert!(
|
||||
svg.contains("<clipPath ") && svg.contains("<text"),
|
||||
"clipPath must contain text glyphs: {svg}"
|
||||
);
|
||||
assert!(
|
||||
!svg.contains("data:image"),
|
||||
"must not base64-embed the image: {svg}"
|
||||
);
|
||||
insta::assert_snapshot!(svg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exports_image_fill_as_linked_image() {
|
||||
let mut pool = ShapesPool::new();
|
||||
let id = uid(1);
|
||||
let image_id = uid(42);
|
||||
add_image_rect(
|
||||
&mut pool,
|
||||
id,
|
||||
Uuid::nil(),
|
||||
(0.0, 0.0, 100.0, 80.0),
|
||||
image_id,
|
||||
true,
|
||||
255,
|
||||
);
|
||||
|
||||
let svg = render_with(&pool, id, |resources| {
|
||||
resources
|
||||
.images
|
||||
.set_source_url(image_id, TEST_IMAGE_URL.to_string());
|
||||
});
|
||||
|
||||
assert!(
|
||||
svg.contains("<image"),
|
||||
"image fill must emit an <image> element: {svg}"
|
||||
);
|
||||
assert!(
|
||||
svg.contains(TEST_IMAGE_URL),
|
||||
"image href must use the registered URL: {svg}"
|
||||
);
|
||||
assert!(
|
||||
svg.contains("preserveAspectRatio=\"xMidYMid slice\""),
|
||||
"keep-aspect image fill must slice: {svg}"
|
||||
);
|
||||
assert!(
|
||||
svg.contains("clip-path=\"url(#"),
|
||||
"image fill must be clipped to shape geometry: {svg}"
|
||||
);
|
||||
assert!(
|
||||
!svg.contains("data:image"),
|
||||
"must not base64-embed the image: {svg}"
|
||||
);
|
||||
insta::assert_snapshot!(svg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exports_mixed_solid_and_image_fills_in_order() {
|
||||
// Image under a translucent solid; stretch (keep-aspect off); partial image
|
||||
// opacity; shape not at the page origin (page translate in CTM).
|
||||
let mut pool = ShapesPool::new();
|
||||
let id = uid(1);
|
||||
let image_id = uid(42);
|
||||
add_rect_with_fills(
|
||||
&mut pool,
|
||||
id,
|
||||
Uuid::nil(),
|
||||
(100.0, 50.0, 508.0, 178.0),
|
||||
vec![
|
||||
// fills[0] topmost — solid blue @ 50%
|
||||
Fill::Solid(SolidColor(skia::Color::from_argb(128, 0, 63, 255))),
|
||||
// fills[1] underneath — linked image, stretch, ~50% opacity
|
||||
Fill::Image(ImageFill::new(image_id, 128, 400, 300, false)),
|
||||
],
|
||||
);
|
||||
|
||||
let svg = render_with(&pool, id, |resources| {
|
||||
resources
|
||||
.images
|
||||
.set_source_url(image_id, TEST_IMAGE_URL.to_string());
|
||||
});
|
||||
|
||||
let image_pos = svg.find("<image");
|
||||
let blue_pos = svg.to_ascii_lowercase().find("fill=\"#003fff\"");
|
||||
assert!(image_pos.is_some(), "missing image fill: {svg}");
|
||||
assert!(blue_pos.is_some(), "missing top solid fill: {svg}");
|
||||
assert!(
|
||||
image_pos.unwrap() < blue_pos.unwrap(),
|
||||
"image (bottom) must appear before solid (top): {svg}"
|
||||
);
|
||||
assert!(
|
||||
svg.contains("preserveAspectRatio=\"none\""),
|
||||
"mixed image fill should stretch when keep-aspect is off: {svg}"
|
||||
);
|
||||
// 128/255 → ~0.50196 as f32 (not a rounded "0.5").
|
||||
assert!(
|
||||
svg.contains("opacity=\"0.5019608\""),
|
||||
"image fill opacity must be emitted: {svg}"
|
||||
);
|
||||
insta::assert_snapshot!(svg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exports_image_fill_on_closed_path() {
|
||||
let mut pool = ShapesPool::new();
|
||||
let id = uid(1);
|
||||
let image_id = uid(42);
|
||||
add_image_path(&mut pool, id, Uuid::nil(), true, image_id);
|
||||
|
||||
let svg = render_with(&pool, id, |resources| {
|
||||
resources
|
||||
.images
|
||||
.set_source_url(image_id, TEST_IMAGE_URL.to_string());
|
||||
});
|
||||
|
||||
assert!(
|
||||
svg.contains("<image") && svg.contains(TEST_IMAGE_URL),
|
||||
"closed path must emit a linked image fill: {svg}"
|
||||
);
|
||||
assert!(
|
||||
svg.contains("clip-path=\"url(#"),
|
||||
"image fill must be clipped to the path: {svg}"
|
||||
);
|
||||
// Clip geometry should be a path (triangle), not a plain rect.
|
||||
assert!(
|
||||
svg.contains("<path") || svg.contains(" d=\""),
|
||||
"closed-path clip must use path geometry: {svg}"
|
||||
);
|
||||
assert!(
|
||||
svg.contains("preserveAspectRatio=\"xMidYMid slice\""),
|
||||
"keep-aspect image fill on path: {svg}"
|
||||
);
|
||||
insta::assert_snapshot!(svg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exports_image_fill_on_open_path() {
|
||||
let mut pool = ShapesPool::new();
|
||||
let id = uid(1);
|
||||
let image_id = uid(42);
|
||||
add_image_path(&mut pool, id, Uuid::nil(), false, image_id);
|
||||
|
||||
let svg = render_with(&pool, id, |resources| {
|
||||
resources
|
||||
.images
|
||||
.set_source_url(image_id, TEST_IMAGE_URL.to_string());
|
||||
});
|
||||
|
||||
assert!(
|
||||
svg.contains("<image") && svg.contains(TEST_IMAGE_URL),
|
||||
"open path must still emit a linked image fill: {svg}"
|
||||
);
|
||||
assert!(
|
||||
svg.contains("clip-path=\"url(#"),
|
||||
"image fill must be clipped to the open path geometry: {svg}"
|
||||
);
|
||||
insta::assert_snapshot!(svg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exports_image_fill_on_frame() {
|
||||
let mut pool = ShapesPool::new();
|
||||
let id = uid(1);
|
||||
let image_id = uid(42);
|
||||
add_image_frame(
|
||||
&mut pool,
|
||||
id,
|
||||
Uuid::nil(),
|
||||
(0.0, 0.0, 200.0, 120.0),
|
||||
image_id,
|
||||
false,
|
||||
);
|
||||
|
||||
let svg = render_with(&pool, id, |resources| {
|
||||
resources
|
||||
.images
|
||||
.set_source_url(image_id, TEST_IMAGE_URL.to_string());
|
||||
});
|
||||
|
||||
assert!(
|
||||
svg.contains("<image") && svg.contains(TEST_IMAGE_URL),
|
||||
"frame background must emit a linked image fill: {svg}"
|
||||
);
|
||||
assert!(
|
||||
svg.contains("clip-path=\"url(#"),
|
||||
"frame image fill must be clipped to the frame: {svg}"
|
||||
);
|
||||
assert!(
|
||||
svg.contains("preserveAspectRatio=\"xMidYMid slice\""),
|
||||
"frame image fill should keep aspect: {svg}"
|
||||
);
|
||||
// No nested board clip when clip_content is off.
|
||||
assert!(
|
||||
!svg.contains("id=\"clip0\""),
|
||||
"unclipped frame should not wrap children in a board clip: {svg}"
|
||||
);
|
||||
insta::assert_snapshot!(svg);
|
||||
}
|
||||
@@ -1,19 +1,122 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::shapes::Shape;
|
||||
use crate::render::text;
|
||||
use crate::shapes::{Fill, ImageFill, Shape};
|
||||
use crate::uuid::Uuid;
|
||||
|
||||
use super::document::SvgLayerCanvas;
|
||||
use crate::render::text;
|
||||
use super::images::{emit_linked_image_element, xml_escape_attr};
|
||||
use crate::render::RenderResources;
|
||||
|
||||
/// Emits a text shape's fill as native `<text>` elements.
|
||||
/// Emits a text shape's fills for SVG export.
|
||||
///
|
||||
/// The shared GPU/PDF renderer wraps text in `save_layer`, which `SkSVGDevice`
|
||||
/// silently drops. Text strokes are handled separately in a later PR.
|
||||
pub(super) fn render_text_fill(builder: &mut SvgLayerCanvas, element: &Shape) -> Result<()> {
|
||||
/// Linked image fills become `<image href>` clipped to the glyph silhouette;
|
||||
/// other fills go through Skia as native `<text>`. Strokes are a later PR.
|
||||
pub(super) fn render_text_fill(
|
||||
builder: &mut SvgLayerCanvas,
|
||||
shared: &RenderResources,
|
||||
element: &Shape,
|
||||
) -> Result<()> {
|
||||
let text_content = element.get_text_content();
|
||||
let text_content = text_content.new_bounds(element.selrect());
|
||||
let max_layers = text_content.max_fill_layers();
|
||||
if max_layers == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let matrix = element.centered_transform();
|
||||
let canvas = builder.canvas();
|
||||
canvas.save();
|
||||
canvas.concat(&matrix);
|
||||
text::paint_text_fill(canvas, element);
|
||||
canvas.restore();
|
||||
|
||||
for layer in 0..max_layers {
|
||||
let linked = linked_image_fills_at_layer(&text_content, layer, shared);
|
||||
let skip_ids: HashSet<Uuid> = linked.iter().map(|img| img.id()).collect();
|
||||
|
||||
for image_fill in &linked {
|
||||
emit_text_image_fill(builder, shared, element, image_fill, layer)?;
|
||||
}
|
||||
|
||||
if layer_has_skia_fills(&text_content, layer, &skip_ids) {
|
||||
let mut paragraph_builders = if skip_ids.is_empty() {
|
||||
text_content.paragraph_builder_group_for_fill_layer(layer)
|
||||
} else {
|
||||
text_content
|
||||
.paragraph_builder_group_for_fill_layer_skipping_images(layer, &skip_ids)
|
||||
};
|
||||
let canvas = builder.canvas();
|
||||
canvas.save();
|
||||
canvas.concat(&matrix);
|
||||
text::paint_text_paragraphs(canvas, element, &mut paragraph_builders);
|
||||
canvas.restore();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn linked_image_fills_at_layer<'a>(
|
||||
text_content: &'a crate::shapes::TextContent,
|
||||
layer: usize,
|
||||
shared: &RenderResources,
|
||||
) -> Vec<&'a ImageFill> {
|
||||
let mut out = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
for paragraph in text_content.paragraphs() {
|
||||
for span in paragraph.children() {
|
||||
if let Some(Fill::Image(img)) = span.fills_from_bottom(layer) {
|
||||
if shared.images.source_url(&img.id()).is_some() && seen.insert(img.id()) {
|
||||
out.push(img);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn layer_has_skia_fills(
|
||||
text_content: &crate::shapes::TextContent,
|
||||
layer: usize,
|
||||
skip_ids: &HashSet<Uuid>,
|
||||
) -> bool {
|
||||
text_content.paragraphs().iter().any(|paragraph| {
|
||||
paragraph
|
||||
.children()
|
||||
.iter()
|
||||
.any(|span| match span.fills_from_bottom(layer) {
|
||||
Some(Fill::Image(img)) if skip_ids.contains(&img.id()) => false,
|
||||
Some(_) => true,
|
||||
None => false,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Linked `<image>` clipped to the opaque glyph silhouette for this image layer.
|
||||
fn emit_text_image_fill(
|
||||
builder: &mut SvgLayerCanvas,
|
||||
shared: &RenderResources,
|
||||
shape: &Shape,
|
||||
image_fill: &ImageFill,
|
||||
layer: usize,
|
||||
) -> Result<()> {
|
||||
let Some(url) = shared.images.source_url(&image_fill.id()) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let clip_id = builder.unique("txtimgclip");
|
||||
let text_content = shape.get_text_content().new_bounds(shape.selrect());
|
||||
let mut paragraph_builders =
|
||||
text_content.paragraph_builder_group_opaque_for_image_layer(layer, image_fill.id());
|
||||
|
||||
let canvas = builder.new_fragment();
|
||||
{
|
||||
let cv: &skia_safe::Canvas = &canvas;
|
||||
cv.save();
|
||||
cv.concat(&shape.centered_transform());
|
||||
text::paint_text_paragraphs(cv, shape, &mut paragraph_builders);
|
||||
cv.restore();
|
||||
}
|
||||
builder.finish_clip_path_fragment(&clip_id, canvas);
|
||||
|
||||
let href = xml_escape_attr(url);
|
||||
emit_linked_image_element(builder, shape, image_fill, &href, &clip_id);
|
||||
Ok(())
|
||||
}
|
||||
@@ -530,22 +530,13 @@ fn render_text_on_canvas(
|
||||
}
|
||||
}
|
||||
|
||||
/// Paints text fill for vector SVG export. Skips `save_layer` wrappers that
|
||||
/// `SkSVGDevice` would drop.
|
||||
pub fn paint_text_fill(canvas: &Canvas, shape: &Shape) {
|
||||
let text_content = shape.get_text_content();
|
||||
let text_content = text_content.new_bounds(shape.selrect());
|
||||
let max_layers = text_content.max_fill_layers();
|
||||
if max_layers == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Each fill layer is painted separately so SkSVGDevice can emit `fill`
|
||||
// attributes (merged shaders are dropped). Bottom layer first.
|
||||
for layer in 0..max_layers {
|
||||
let mut paragraph_builders = text_content.paragraph_builder_group_for_fill_layer(layer);
|
||||
paint_text_with_emoji_overlay(canvas, shape, &mut paragraph_builders, false);
|
||||
}
|
||||
/// Paints pre-built paragraph groups (SVG export path for selective fill layers).
|
||||
pub fn paint_text_paragraphs(
|
||||
canvas: &Canvas,
|
||||
shape: &Shape,
|
||||
paragraph_builder_groups: &mut [Vec<ParagraphBuilder>],
|
||||
) {
|
||||
paint_text_with_emoji_overlay(canvas, shape, paragraph_builder_groups, false);
|
||||
}
|
||||
|
||||
/// Lays out and paints paragraph builders without any layer management.
|
||||
|
||||
@@ -2,7 +2,8 @@ use skia_safe::{self as skia, Canvas, Paint, RRect};
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::shapes::{
|
||||
merge_fills, radius_to_sigma, BlurType, Fill, Frame, Rect, Shape, Stroke, StrokeKind, Type,
|
||||
circle_segments_local, merge_fills, radius_to_sigma, rect_segments_local, stroke_to_path,
|
||||
BlurType, Fill, Frame, Path, Rect, Shape, Stroke, StrokeKind, StrokeStyle, Type,
|
||||
};
|
||||
use crate::state::ShapesPoolRef;
|
||||
use crate::uuid::Uuid;
|
||||
@@ -22,9 +23,10 @@ pub(super) struct VectorRenderer<'a> {
|
||||
canvas: &'a Canvas,
|
||||
shared: &'a mut RenderResources,
|
||||
scale: f32,
|
||||
/// When `true`, multiple fills are composited into a single shader (PDF).
|
||||
/// When `false`, each fill is drawn separately so SkSVGDevice can emit
|
||||
/// `fill` attributes (SVG export).
|
||||
/// When `true`, use PDF/GPU-friendly compositing (`merge_fills`,
|
||||
/// `save_layer` for outer strokes). When `false` (SVG export), avoid
|
||||
/// techniques that `SkSVGDevice` drops: draw fills individually and emit
|
||||
/// solid Inner/Outer strokes as filled outlines.
|
||||
compose_fills: bool,
|
||||
}
|
||||
|
||||
@@ -82,8 +84,16 @@ impl ShapeRenderer for VectorRenderer<'_> {
|
||||
}
|
||||
|
||||
fn draw_strokes(&mut self, shape: &Shape, strokes: &[&Stroke]) -> Result<()> {
|
||||
let svg_export = !self.compose_fills;
|
||||
for stroke in strokes.iter().rev() {
|
||||
draw_single_stroke(self.canvas, self.shared, self.scale, shape, stroke)?;
|
||||
draw_single_stroke(
|
||||
self.canvas,
|
||||
self.shared,
|
||||
self.scale,
|
||||
shape,
|
||||
stroke,
|
||||
svg_export,
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -840,9 +850,9 @@ fn render_frame(
|
||||
canvas.save_layer(&layer_rec);
|
||||
}
|
||||
|
||||
// Clip to frame bounds in the frame's own space, then undo the transform so
|
||||
// children draw at their absolute coords while staying clipped (mirrors the
|
||||
// GPU clip). Outset ~0.5px like the GPU clip to avoid an AA seam.
|
||||
// Clip fills + children only. Strokes render outside the content clip so
|
||||
// outer/center strokes are not trimmed (same as GPU render_shape_exit).
|
||||
canvas.save();
|
||||
if element.clip_content {
|
||||
canvas.concat(&matrix);
|
||||
clip_to_frame_content(canvas, element, scale);
|
||||
@@ -866,8 +876,9 @@ fn render_frame(
|
||||
for child_id in &children {
|
||||
render_tree_inner(shared, canvas, child_id, tree, scale, opts)?;
|
||||
}
|
||||
canvas.restore(); // content clip
|
||||
|
||||
// Strokes over children (clipped frames), in the frame's space.
|
||||
// Strokes over children, outside the frame content clip.
|
||||
let visible_strokes: Vec<&Stroke> = element.visible_strokes().collect();
|
||||
if !visible_strokes.is_empty() {
|
||||
canvas.save();
|
||||
@@ -1064,16 +1075,109 @@ fn draw_single_stroke(
|
||||
scale: f32,
|
||||
shape: &Shape,
|
||||
stroke: &Stroke,
|
||||
svg_export: bool,
|
||||
) -> Result<()> {
|
||||
// Image-fill strokes: the stroke masks the visible area of the image.
|
||||
if let Fill::Image(image_fill) = &stroke.fill {
|
||||
return draw_image_stroke(canvas, shared, scale, shape, stroke, image_fill);
|
||||
}
|
||||
|
||||
// Techniques SkSVGDevice cannot keep (save_layer+Clear/clip for Outer,
|
||||
// PathEffect stamps for dots/dashes): expand to a filled outline instead.
|
||||
// Solid Center stays on the shared stroke path.
|
||||
if svg_export && draw_svg_stroke_as_fill(canvas, shape, stroke) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
draw_stroke_geometry(canvas, scale, shape, stroke, false);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Draws a stroke as a filled path outline for SVG export.
|
||||
///
|
||||
/// Handles solid Inner/Outer and all dotted/dashed/mixed alignments (including
|
||||
/// Center and open paths, which force Center). Returns `true` when handled.
|
||||
fn draw_svg_stroke_as_fill(canvas: &Canvas, shape: &Shape, stroke: &Stroke) -> bool {
|
||||
let is_open = shape.is_open();
|
||||
let kind = stroke.render_kind(is_open);
|
||||
|
||||
// Per-side rect/frame strokes already expand to an evenodd band in
|
||||
// `draw_stroke_on_rect`. `stroke_to_path` only knows a uniform width.
|
||||
if stroke.per_side_widths().is_some()
|
||||
&& matches!(shape.shape_type, Type::Rect(_) | Type::Frame(_))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
let solid_outline = match stroke.style {
|
||||
StrokeStyle::Solid => match kind {
|
||||
// Solid Center already serializes as a native SVG stroke.
|
||||
StrokeKind::Center => return false,
|
||||
StrokeKind::Inner | StrokeKind::Outer => {
|
||||
if is_open {
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
},
|
||||
// PathEffects (path_1d / dash) do not survive SkSVGDevice; expand them.
|
||||
StrokeStyle::Dotted | StrokeStyle::Dashed | StrokeStyle::Mixed => false,
|
||||
};
|
||||
|
||||
// Local (untransformed) geometry: the SVG leaf canvas already has
|
||||
// `centered_transform`. Using `rect_segments` / `circle_segments` here
|
||||
// would bake the same transform into the path and double-rotate.
|
||||
// Path/Bool content is stored in parent space; `to_path_transform`
|
||||
// undoes that so the outline matches `get_skia_path` (fills) under CTM.
|
||||
let shape_path = match &shape.shape_type {
|
||||
Type::Rect(r) => Path::new(rect_segments_local(shape, r.corners)),
|
||||
Type::Frame(f) => Path::new(rect_segments_local(shape, f.corners)),
|
||||
Type::Circle => Path::new(circle_segments_local(shape)),
|
||||
Type::Path(_) | Type::Bool(_) => match shape.shape_type.path() {
|
||||
Some(path) => {
|
||||
let mut local = path.clone();
|
||||
if let Some(t) = shape.to_path_transform() {
|
||||
local.transform(&t);
|
||||
}
|
||||
local
|
||||
}
|
||||
None => return false,
|
||||
},
|
||||
Type::Text(_) | Type::SVGRaw(_) | Type::Group(_) => return false,
|
||||
};
|
||||
|
||||
let Some(outline) = stroke_to_path(
|
||||
stroke,
|
||||
&shape_path,
|
||||
None,
|
||||
&shape.selrect,
|
||||
shape.svg_attrs.as_ref(),
|
||||
solid_outline,
|
||||
) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let mut paint = stroke.fill.to_paint(&shape.selrect, true);
|
||||
paint.set_style(skia::PaintStyle::Fill);
|
||||
paint.set_anti_alias(true);
|
||||
canvas.draw_path(&outline.to_skia_path(shape.svg_attrs.as_ref()), &paint);
|
||||
|
||||
// Expanded dotted/dashed strokes skip `draw_stroke_geometry`, which is
|
||||
// where open-path caps are drawn. Overlay them here in local path space
|
||||
// (same as fills / the outline above under the leaf CTM).
|
||||
if is_open {
|
||||
if let Some(cap_path) = transformed_skia_path(shape) {
|
||||
let cap_paint =
|
||||
stroke.to_stroked_paint(true, &shape.selrect, shape.svg_attrs.as_ref(), true);
|
||||
super::strokes::handle_stroke_caps(
|
||||
&cap_path, stroke, canvas, true, &cap_paint, None, true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
/// Draws a stroke's geometry by shape type, kind and dash style. Rect/Circle
|
||||
/// reuse the GPU stroke fns (dash/alignment parity); Path/Bool use double-width
|
||||
/// + clip/clear + caps. `opaque` forces black for an image-stroke silhouette.
|
||||
|
||||
@@ -102,9 +102,18 @@ fn fix_radius(
|
||||
}
|
||||
|
||||
pub fn rect_segments(shape: &Shape, corners: Option<Corners>) -> Vec<Segment> {
|
||||
transform_segments(rect_segments_local(shape, corners), shape)
|
||||
}
|
||||
|
||||
/// Axis-aligned rect path in selrect space (no `shape.transform`).
|
||||
///
|
||||
/// Use when the caller already applies [`Shape::centered_transform`] on the
|
||||
/// canvas (e.g. SVG leaf export); [`rect_segments`] would bake the transform
|
||||
/// into the path and double-rotate.
|
||||
pub fn rect_segments_local(shape: &Shape, corners: Option<Corners>) -> Vec<Segment> {
|
||||
let sr = shape.selrect;
|
||||
|
||||
let segments = if let Some([r1, r2, r3, r4]) = corners {
|
||||
if let Some([r1, r2, r3, r4]) = corners {
|
||||
let (r1, r2, r3, r4) = fix_radius(r1, r2, r3, r4, sr.width(), sr.height());
|
||||
|
||||
let p1 = (sr.x(), sr.y() + r1.y);
|
||||
@@ -139,9 +148,7 @@ pub fn rect_segments(shape: &Shape, corners: Option<Corners>) -> Vec<Segment> {
|
||||
Segment::LineTo(p4),
|
||||
Segment::Close,
|
||||
]
|
||||
};
|
||||
|
||||
transform_segments(segments, shape)
|
||||
}
|
||||
}
|
||||
|
||||
fn transform_point(p: (f32, f32), matrix: &skia_safe::Matrix) -> (f32, f32) {
|
||||
@@ -151,6 +158,11 @@ fn transform_point(p: (f32, f32), matrix: &skia_safe::Matrix) -> (f32, f32) {
|
||||
}
|
||||
|
||||
pub fn circle_segments(shape: &Shape) -> Vec<Segment> {
|
||||
transform_segments(circle_segments_local(shape), shape)
|
||||
}
|
||||
|
||||
/// Circle path in selrect space (no `shape.transform`). See [`rect_segments_local`].
|
||||
pub fn circle_segments_local(shape: &Shape) -> Vec<Segment> {
|
||||
let sr = shape.selrect;
|
||||
let c = BEZIER_CIRCLE_C;
|
||||
let c1x = sr.x() + (sr.width() / 2.0 * (1.0 - c));
|
||||
@@ -168,15 +180,13 @@ pub fn circle_segments(shape: &Shape) -> Vec<Segment> {
|
||||
let p3 = (mx, ey);
|
||||
let p4 = (sr.x(), my);
|
||||
|
||||
let segments = vec![
|
||||
vec![
|
||||
Segment::MoveTo(p1),
|
||||
Segment::CurveTo(((c2x, p1.1), (p2.0, c1y), p2)),
|
||||
Segment::CurveTo(((p2.0, c2y), (c2x, p3.1), p3)),
|
||||
Segment::CurveTo(((c1x, p3.1), (p4.0, c2y), p4)),
|
||||
Segment::CurveTo(((p4.0, c1y), (c1x, p1.1), p1)),
|
||||
];
|
||||
|
||||
transform_segments(segments, shape)
|
||||
]
|
||||
}
|
||||
|
||||
fn join_paths(path: Path, other: Path) -> Path {
|
||||
|
||||
+114
-14
@@ -796,13 +796,13 @@ impl TextContent {
|
||||
&self,
|
||||
use_shadow: Option<bool>,
|
||||
) -> Vec<ParagraphBuilderGroup> {
|
||||
self.paragraph_builders(use_shadow, false, None, None)
|
||||
self.paragraph_builders(use_shadow, false, None, None, None, None)
|
||||
}
|
||||
|
||||
/// Creates paragraph builders with always-opaque paint (BLACK @ alpha 255).
|
||||
/// Used as a clip mask for inner stroke rendering.
|
||||
pub fn paragraph_builder_group_opaque(&self) -> Vec<ParagraphBuilderGroup> {
|
||||
self.paragraph_builders(None, true, None, None)
|
||||
self.paragraph_builders(None, true, None, None, None, None)
|
||||
}
|
||||
|
||||
/// Maximum number of stacked fills across every span in this text block.
|
||||
@@ -821,7 +821,42 @@ impl TextContent {
|
||||
&self,
|
||||
layer_from_bottom: usize,
|
||||
) -> Vec<ParagraphBuilderGroup> {
|
||||
self.paragraph_builders(None, false, None, Some(layer_from_bottom))
|
||||
self.paragraph_builders(None, false, None, Some(layer_from_bottom), None, None)
|
||||
}
|
||||
|
||||
/// Like [`paragraph_builder_group_for_fill_layer`], but spans whose fill at
|
||||
/// this layer is an image in `skip_image_ids` get transparent paint (those
|
||||
/// fills are re-emitted as linked SVG `<image>` elements).
|
||||
pub fn paragraph_builder_group_for_fill_layer_skipping_images(
|
||||
&self,
|
||||
layer_from_bottom: usize,
|
||||
skip_image_ids: &HashSet<Uuid>,
|
||||
) -> Vec<ParagraphBuilderGroup> {
|
||||
self.paragraph_builders(
|
||||
None,
|
||||
false,
|
||||
None,
|
||||
Some(layer_from_bottom),
|
||||
None,
|
||||
Some(skip_image_ids),
|
||||
)
|
||||
}
|
||||
|
||||
/// Opaque black glyphs only for spans whose fill at `layer_from_bottom` is
|
||||
/// the given image — used as an SVG `<clipPath>` for linked image fills.
|
||||
pub fn paragraph_builder_group_opaque_for_image_layer(
|
||||
&self,
|
||||
layer_from_bottom: usize,
|
||||
image_id: Uuid,
|
||||
) -> Vec<ParagraphBuilderGroup> {
|
||||
self.paragraph_builders(
|
||||
None,
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
Some((layer_from_bottom, image_id)),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
fn paragraph_builders(
|
||||
@@ -830,6 +865,8 @@ impl TextContent {
|
||||
opaque: bool,
|
||||
align_override: Option<skia::textlayout::TextAlign>,
|
||||
fill_layer: Option<usize>,
|
||||
opaque_image_layer: Option<(usize, Uuid)>,
|
||||
skip_image_ids: Option<&HashSet<Uuid>>,
|
||||
) -> Vec<ParagraphBuilderGroup> {
|
||||
let fonts = get_font_collection();
|
||||
let fallback_fonts = get_fallback_fonts();
|
||||
@@ -843,15 +880,63 @@ impl TextContent {
|
||||
let mut builder = ParagraphBuilder::new(¶graph_style, fonts);
|
||||
let mut has_text = false;
|
||||
for span in paragraph.children() {
|
||||
let remove_alpha =
|
||||
opaque || (use_shadow.unwrap_or(false) && !span.is_transparent());
|
||||
let text_style = span.to_style_with_paint(
|
||||
&self.bounds(),
|
||||
fallback_fonts,
|
||||
remove_alpha,
|
||||
paragraph.line_height(),
|
||||
fill_layer,
|
||||
);
|
||||
let text_style = if let Some((layer, image_id)) = opaque_image_layer {
|
||||
let mut style = span.to_style(
|
||||
&self.bounds(),
|
||||
fallback_fonts,
|
||||
false,
|
||||
paragraph.line_height(),
|
||||
);
|
||||
let mut paint = paint::Paint::default();
|
||||
match span.fills_from_bottom(layer) {
|
||||
Some(shapes::Fill::Image(img)) if img.id() == image_id => {
|
||||
paint.set_color(skia::Color::BLACK);
|
||||
paint.set_alpha(255);
|
||||
}
|
||||
_ => {
|
||||
paint.set_color(skia::Color::TRANSPARENT);
|
||||
}
|
||||
}
|
||||
style.set_foreground_paint(&paint);
|
||||
style
|
||||
} else if let (Some(layer), Some(skip)) = (fill_layer, skip_image_ids) {
|
||||
let skip_span = matches!(
|
||||
span.fills_from_bottom(layer),
|
||||
Some(shapes::Fill::Image(img)) if skip.contains(&img.id())
|
||||
);
|
||||
if skip_span {
|
||||
let mut style = span.to_style(
|
||||
&self.bounds(),
|
||||
fallback_fonts,
|
||||
false,
|
||||
paragraph.line_height(),
|
||||
);
|
||||
let mut paint = paint::Paint::default();
|
||||
paint.set_color(skia::Color::TRANSPARENT);
|
||||
style.set_foreground_paint(&paint);
|
||||
style
|
||||
} else {
|
||||
let remove_alpha =
|
||||
opaque || (use_shadow.unwrap_or(false) && !span.is_transparent());
|
||||
span.to_style_with_paint(
|
||||
&self.bounds(),
|
||||
fallback_fonts,
|
||||
remove_alpha,
|
||||
paragraph.line_height(),
|
||||
fill_layer,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
let remove_alpha =
|
||||
opaque || (use_shadow.unwrap_or(false) && !span.is_transparent());
|
||||
span.to_style_with_paint(
|
||||
&self.bounds(),
|
||||
fallback_fonts,
|
||||
remove_alpha,
|
||||
paragraph.line_height(),
|
||||
fill_layer,
|
||||
)
|
||||
};
|
||||
let text: String = span.apply_text_transform();
|
||||
if !text.is_empty() {
|
||||
has_text = true;
|
||||
@@ -871,8 +956,14 @@ impl TextContent {
|
||||
/// Performs an Auto Width text layout.
|
||||
fn text_layout_auto_width(&self) -> TextContentLayoutResult {
|
||||
// Left-aligned MAX-width pass: longest_line() is glyph width, not the huge container.
|
||||
let mut measure_builders =
|
||||
self.paragraph_builders(None, false, Some(skia::textlayout::TextAlign::Left), None);
|
||||
let mut measure_builders = self.paragraph_builders(
|
||||
None,
|
||||
false,
|
||||
Some(skia::textlayout::TextAlign::Left),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
let normalized_line_height =
|
||||
calculate_normalized_line_height(&mut measure_builders, f32::MAX);
|
||||
@@ -1464,6 +1555,15 @@ pub struct TextSpan {
|
||||
}
|
||||
|
||||
impl TextSpan {
|
||||
/// Fill at `layer` counting from the bottom (`0` = last / bottommost fill).
|
||||
pub fn fills_from_bottom(&self, layer: usize) -> Option<&shapes::Fill> {
|
||||
if layer < self.fills.len() {
|
||||
Some(&self.fills[self.fills.len() - 1 - layer])
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
text: String,
|
||||
|
||||
@@ -140,6 +140,22 @@ pub extern "C" fn store_image() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Registers the public URL an image was loaded from for SVG export.
|
||||
///
|
||||
/// Layout: UTF-8 URL bytes in the alloc buffer. The image UUID is passed as
|
||||
/// the four u32 arguments (same quartet as `store_image` / `is_image_cached`).
|
||||
#[no_mangle]
|
||||
#[wasm_error]
|
||||
pub extern "C" fn store_image_url(a: u32, b: u32, c: u32, d: u32) -> Result<()> {
|
||||
let id = uuid_from_u32_quartet(a, b, c, d);
|
||||
let url_bytes = mem::bytes();
|
||||
let url = String::from_utf8(url_bytes)
|
||||
.map_err(|_| Error::CriticalError("Invalid UTF-8 in image source URL".to_string()))?;
|
||||
mem::free_bytes()?;
|
||||
get_resources().images.set_source_url(id, url);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stores an image from an existing WebGL texture, avoiding re-decoding
|
||||
/// Expected memory layout:
|
||||
/// - bytes 0-15: shape UUID
|
||||
|
||||
Reference in new issue
Block a user