Compare commits

..
7 Commits
Author SHA1 Message Date
makesomethingshitandAndrey Antukh 99c036feac 🐛 Close nitrate modal when navigating to current plan (#11615)
Signed-off-by: makesomethingshit <junsoo1172@gmail.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-09-10 18:17:11 +02:00
Alejandro Alonso aa78ca0be8 🐛 Keep text image fills aligned during drag (#11610)
Cached Skia paragraphs bake absolute image/gradient shaders at layout
time. On move, clones reuse those paragraphs while painting at the new
selrect, so glyphs move and the fill stays put. Record the paint origin
when layout is built and translate the canvas when painting from cache
so shaders track the text. Also sync bounds before update_layout so
fills bake against the current container.
2026-09-10 16:55:45 +02:00
Andrey Antukh 7c27ed812a ♻️ Consolidate HIGHLIGHTS.md into CHANGES.md 🚀 section (#11531)
* ♻️ Consolidate HIGHLIGHTS.md into CHANGES.md 🚀 section

Eliminate the redundant HIGHLIGHTS.md file and make CHANGES.md
the single source of truth for version highlights.

- Add 🚀 section for 2.15.0 (MCP server integration)
- Add 4 missing highlight entries to 2.17.0 🚀 section
- Rewrite frontend parser to extract from CHANGES.md 🚀
  subsections instead of flat HIGHLIGHTS.md format
- Decouple parse-latest-released-version from highlights
  extraction so it works independently of 🚀 content
- Conditionally render highlights section in modal when non-empty
- Rewrite tests for new parser behavior (11 tests, 21 assertions)
- Delete HIGHLIGHTS.md and remove .gitignore exception
- Add step 8b to update-changelog skill for proactively
  proposing highlights during release workflows
- Add missing-highlights and missing-highlight-reference
  anomaly types to the changelog anomaly report script

Closes #11530

AI-assisted-by: qwen3.7-plus

* ♻️ Use consistent string library and add multi-version test

Address code review findings:

- Use str/split (cuerdas) consistently in extract-rocket-items
  instead of mixing cstr/split (clojure.string)
- Add parse-highlights-extracts-multiple-versions test to verify
  the parser correctly extracts 🚀 items from multiple
  versions in a single CHANGES.md body

AI-assisted-by: qwen3.7-plus

* ♻️ Scope 🚀 checks to X.Y.0 and split gaps from anomalies

Type C now only checks released X.Y.0 versions, since patches never carry 🚀 subsections by design. Type D requires both issue AND PR references with exact format, accepting multi-PR entries. C/D are reported as highlight gaps in their own section and no longer count toward the anomaly total. Key Principles and anomaly definitions updated to match. Addresses review comments on PR #11531.

AI-assisted-by: muse-spark-1.3-contributor

*  Render markdown links and bold in check-updates highlights

The highlights modal showed raw markdown from CHANGES.md 🚀 lines (brackets and URLs). Add a pure parse-highlight-item parser for inline links and bold, render fragments with literal hiccup in the modal (links open in a new tab), and style links and strong elements. Non-http URLs and malformed markup degrade to plain text. Adds 12 unit tests.

AI-assisted-by: muse-spark-1.3-contributor

* 🐛 Point full changelog link to main instead of staging

The view-changelog button in the check-updates modal linked to the staging branch. Point it to main, which holds the published changelog. Version detection still fetches from staging.

AI-assisted-by: muse-spark-1.3-contributor
2026-09-10 16:40:32 +02:00
Shlok Goyal 286ccb03fa 🐛 Preserve stroke dash and gap values on color change (#11557)
Signed-off-by: Shlok Goyal <shlokgoyal1279@gmail.com>
2026-09-10 14:36:51 +02:00
Alejandro Alonso dbe5941a23 Export layer blur to WASM SVG (#11580)
SkSVGDevice drops paint image-filters, so re-emit visible layer blur as a
native feGaussianBlur filter on the composite <g>. Match canvas sigma via
radius_to_sigma(value * scale), and skip Skia blur filters on the SVG
VectorRenderer path so shapes do not vanish.

Closes #11380
2026-09-10 14:25:47 +02:00
makesomethingshit 30849babcc 🐛 Fix fontFamilies token property mapping in Plugin API (#11566)
* 🐛 Fix fontFamilies token property mapping in Plugin API

The Plugin API exposes the font-family token property as `fontFamilies`,
while Penpot stores the canonical applied-token attribute as
`:font-family`. The bidirectional plugin/internal attribute map did not
contain that alias, so explicit `applyToken(..., ["fontFamilies"])`
validation rejected the property and applied-token readback exposed the
undocumented singular `fontFamily`.

Add `:font-family -> :font-families` to the existing canonical alias
map. The reverse mapping is derived automatically, keeping application
and readback symmetric without introducing a font-specific code path.

Closes #11405

AI-assisted-by: Omen Alpha
Signed-off-by: 최준수 <junsoo1172@gmail.com>

* 🐛 Fix fontFamilies e2e test to target a text shape

The fontFamilies end-to-end regression created a flex layout frame,
whose attribute set (frame-with-layout-attributes) excludes
:font-family. The workspace token application filters such shapes,
so the internal binding and readback assertions would pass vacuously
without exercising the alias.

Target an actual `:text` shape (ctho/add-text) instead, so the test
verifies the full JS "fontFamilies" -> schema -> alias -> canonical
:font-family -> camelCase readback path.

AI-assisted-by: Omen Alpha
Signed-off-by: 최준수 <junsoo1172@gmail.com>

* 🐛 Fix fontFamilies test WASM error and add changelog entry

The text-shape fontFamilies e2e applies a layout-affecting token via
wasm renderer path, hitting missing WASM exports under Node. Merge
thw/setup-wasm-mocks! into the :each fixture and add plugins
CHANGELOG entry for the fontFamilies alias fix.

AI-assisted-by: muse-spark-1.3-contributor
Related to #11566

Signed-off-by: makesomethingshit <junsoo1172@gmail.com>

---------

Signed-off-by: 최준수 <junsoo1172@gmail.com>
Signed-off-by: makesomethingshit <junsoo1172@gmail.com>
2026-09-10 13:43:41 +02:00
Alejandro Alonso dc12f1db91 Export image-filled strokes to WASM SVG as linked images (#11559)
Closes #11384

Skia's SVG backend drops save_layer+SrcIn, so image strokes are re-emitted
as a linked <image> clipped to an opaque stroke silhouette (filled outline,
clip-rule evenodd). Open-path caps join the silhouette and grow the image
dest by cap_bounds_margin so markers stay textured.
2026-09-10 13:29:14 +02:00
39 changed files with 1484 additions and 307 deletions

No files matched your search

+125 -11
View File
@@ -357,6 +357,39 @@ Insert the new version section right after the `# CHANGELOG` header (before
the previous version entry). Use the `edit` tool with enough context to make
a unique match.
### 8b. Propose and populate the `:rocket: Epics and highlights` subsection
After inserting the version section, proactively create or populate the
`### :rocket: Epics and highlights` subsection. This section surfaces the
most impactful changes for self-hosted users checking for updates.
**When to create:** If the version section does not already have a
`### :rocket: Epics and highlights` subsection, create one. Place it before
`### :sparkles:` (matching existing order in CHANGES.md).
**How to identify highlights:** Review the `:sparkles:` entries for the
version and select 25 of the most impactful/user-visible ones. Criteria:
- New user-visible features (not internal refactors)
- Significant capability additions
- Items that create "FOMO" for self-hosted users on older versions
**Use release notes as hints:** Check
`frontend/src/app/main/ui/releases/v2_<MINOR>.cljs` for the corresponding
version. The slide titles and feature descriptions there are curated
marketing content indicating what the team considers highlight-worthy. Match
those themes to changelog entries. Treat these files as optional hints — they
may not exist for every version.
**Format requirement:** Every `:rocket:` entry MUST follow the standard
changelog format with issue/PR references:
```
- <description> [#<ISSUE>](https://github.com/penpot/penpot/issues/<ISSUE>) (PR: [#<PR>](https://github.com/penpot/penpot/pull/<PR>))
```
An entry without issue AND PR references is a highlight gap (warning, not an anomaly).
**Preserve existing entries:** If the `:rocket:` section already exists from
a prior run, preserve its entries. Do not remove or rewrite them.
### 9. Verify
Read the top of `CHANGES.md` and confirm:
@@ -468,9 +501,8 @@ Markdown viewer.
## What is an anomaly
**An anomaly is a milestone-mismatch between an issue and its referenced
PR.** It indicates that the changelog claim "this issue is fixed by this PR,
all in milestone M" is inconsistent with the actual milestone assignments.
There are exactly two types:
PR.** There are two anomaly types, plus two highlight gaps (warnings that
do not count toward the anomaly total):
1. **Issue is in the milestone, but its referenced PR is in a different
milestone (or has no milestone).** The changelog claims a fix in this
@@ -486,6 +518,13 @@ There are exactly two types:
PR that closes an issue with no milestone references an issue from
another (probably private) project; that is expected and the issue is
not part of this changelog. Do not report it.
3. **missing-highlights (gap):** A released X.Y.0 version section has no
`### :rocket: Epics and highlights` subsection. Patches (X.Y.Z) never
carry highlights, so only minors/majors are checked.
4. **missing-highlight-reference (gap):** A `:rocket:` entry lacks the
required issue AND PR references. Every highlight entry must follow the
standard changelog format with `[#ISSUE]` and `(PR: [#PR])` links
(multi-PR `(PR: [#A](...), [#B](...))` accepted).
**Anything else is not an anomaly.** Other discrepancies (exclusion
labels on in-changelog issues, missing valid issues, unmerged PR
@@ -653,6 +692,40 @@ for pr_num in sorted(changelog_prs):
'issue_milestone': issue_ms, # may be None
})
# --- Type C: released X.Y.0 version sections without :rocket: subsection ---
# Patches (X.Y.Z with Z != 0) never carry :rocket: by design — only minors/majors (X.Y.0).
anomalies_c = [] # list of version strings
rocket_heading_re = re.compile(r'^### :rocket:', re.MULTILINE)
version_sections = re.split(r'(?=^## \d+\.\d+\.\d+)', content, flags=re.MULTILINE)
for vs in version_sections:
m = re.match(r'^## (\d+\.\d+\.\d+)(.*)', vs)
if not m: continue
ver, suffix = m.group(1), m.group(2)
if 'unreleased' in suffix.lower(): continue
if ver.split('.')[2] != '0': continue
if not rocket_heading_re.search(vs):
anomalies_c.append(ver)
# --- Type D: :rocket: entries without issue AND PR references ---
# Both are required: `[#ISSUE](.../issues/N)` and `(PR: [#PR](.../pull/M))`.
# Multi-PR entries `(PR: [#A](...), [#B](...))` are accepted.
anomalies_d = [] # list of dicts: {version, line}
issue_ref_re = re.compile(r'\[#\d+\]\(https://github\.com/penpot/penpot/issues/\d+\)')
pr_ref_re = re.compile(r'\(PR:\s*\[#\d+\]\(https://github\.com/penpot/penpot/pull/\d+\)(\s*,\s*\[#\d+\]\(https://github\.com/penpot/penpot/pull/\d+\))*\)')
for vs in version_sections:
m = re.match(r'^## (\d+\.\d+\.\d+)(.*)', vs)
if not m: continue
ver = m.group(1)
rocket_match = rocket_heading_re.search(vs)
if not rocket_match: continue
# Extract the :rocket: subsection body (up to next ### or ##)
rocket_body = vs[rocket_match.end():]
rocket_body = re.split(r'(?m)^#{2,3}\s', rocket_body)[0]
for line in rocket_body.splitlines():
line = line.strip()
if line.startswith('- ') and not (issue_ref_re.search(line) and pr_ref_re.search(line)):
anomalies_d.append({'version': ver, 'line': line[:100]})
# --- Write report ---
def fmt_ms(ms):
return ms if ms else "_none_"
@@ -664,13 +737,17 @@ with open(OUTPUT, 'w') as f:
n_a = len(anomalies_a)
n_b = len(anomalies_b)
n_c = len(anomalies_c)
n_d = len(anomalies_d)
f.write('## Summary\n\n')
f.write(f'- **Issue in {MILESTONE}, referenced PR in different milestone or no milestone:** {n_a}\n')
f.write(f'- **PR in {MILESTONE}, closing issue in a different milestone:** {n_b}\n')
f.write(f'- **Total anomalies:** {n_a + n_b}\n\n')
f.write(f'- **Total anomalies:** {n_a + n_b}\n')
f.write(f'- **Released X.Y.0 version missing :rocket: section (gap):** {n_c}\n')
f.write(f'- **:rocket: entry without issue AND PR references (gap):** {n_d}\n\n')
# --- Anomalies section ---
# --- Anomalies section (milestone mismatches only) ---
if n_a or n_b:
f.write('## Anomalies\n\n')
f.write('These are milestone mismatches between an issue in the changelog '
@@ -709,9 +786,37 @@ with open(OUTPUT, 'w') as f:
badge = '🔴' if e['issue_milestone'] is None else '⚠️'
f.write(f' - {badge} Closing {issue_link(e["issue"])} is in milestone **{ms_label}** (expected: {MILESTONE})\n')
f.write('\n')
else:
f.write('✅ No anomalies found. All (issue, PR) pairs in the changelog have aligned milestone assignments.\n\n')
# --- Highlight gaps (warnings, not anomalies) ---
if n_c or n_d:
f.write('## Highlight gaps\n\n')
f.write('These are warnings, not anomalies: they do not affect the '
'milestone-mismatch total above. They track `:rocket:` coverage '
'across all released X.Y.0 versions. Historical entries (e.g. '
'Taiga links) predate the current reference convention and are '
'expected to appear here.\n\n')
if n_c:
f.write(f'### Released X.Y.0 version missing :rocket: section\n\n')
f.write('These released minors/majors have no `### :rocket: Epics and highlights` subsection. '
'Add highlights to help self-hosted users understand what they are missing.\n\n')
for ver in anomalies_c:
f.write(f'- Version **{ver}**\n')
f.write('\n')
if n_d:
f.write(f'### :rocket: entry without issue AND PR references\n\n')
f.write('These highlight entries lack the required issue AND PR references. '
'Add `[#ISSUE](...)` and `(PR: [#PR](...))` links.\n\n')
for d in anomalies_d:
f.write(f'- **{d["version"]}**: `{d["line"]}`\n')
f.write('\n')
elif not (n_a or n_b):
f.write('✅ No highlight gaps found. All released X.Y.0 versions have properly referenced :rocket: entries.\n\n')
# --- Context ---
f.write('---\n\n')
f.write('## Context\n\n')
@@ -726,8 +831,7 @@ print(f"Anomaly report written to {OUTPUT}")
PYEOF
```
This generates `CHANGES-ISSUES.md` containing **only the anomalies**
milestone mismatches between issues and their referenced PRs:
This generates `CHANGES-ISSUES.md` containing anomalies and highlight gaps:
1. **Issue in milestone, referenced PR in different milestone or no milestone**
the changelog claims a fix here, but the PR is released elsewhere.
@@ -736,6 +840,13 @@ milestone mismatches between issues and their referenced PRs:
(An issue with *no* milestone belongs to another, probably private,
project — milestones are only required on the "Main" project — so it is
neither an anomaly nor a changelog candidate.)
3. **missing-highlights (gap, warning)** — a released X.Y.0 version section
has no `### :rocket: Epics and highlights` subsection. Patches (X.Y.Z)
never carry highlights.
4. **missing-highlight-reference (gap, warning)** — a `:rocket:` entry lacks
the required issue AND PR references.
Gaps do not count toward the anomaly total.
**Rule violations are not in the report** — they are workflow errors the
LLM must fix directly in `CHANGES.md` during step 6a (pre-flight checks).
@@ -809,10 +920,13 @@ self-contained and clickable in any Markdown viewer.
issue from a different project or context. If the PR title and issue title
are clearly unrelated, or the PR predates the issue by years, treat it as a
data glitch and skip it.
- **Anomaly = milestone mismatch only.** The report contains only milestone
mismatches: (1) the issue is in this milestone but the referenced PR is
in a different milestone (or unassigned), and (2) the PR is in this
milestone but the issue it closes is in a different milestone. An
- **Anomaly = milestone mismatch only; gaps are warnings.** The report's
anomaly total counts only milestone mismatches: (1) the issue is in this
milestone but the referenced PR is in a different milestone (or unassigned),
and (2) the PR is in this milestone but the issue it closes is in a
different milestone. `:rocket:` highlight gaps (missing section on a
released X.Y.0, entry without issue AND PR references) are reported in a
separate `Highlight gaps` section and never count toward the anomaly total. An
*unassigned* (milestone-less) issue closed by a milestone PR is **not**
an anomaly: milestones are required only for the "Main" project, so such
issues come from another (probably private) project and are not changelog
-1
View File
@@ -24,7 +24,6 @@ opencode.json
!AGENTS.md
!CODE_OF_CONDUCT.md
!SECURITY.md
!HIGHLIGHTS.md
/*.png
/*.svg
/*.sql
+8
View File
@@ -218,6 +218,10 @@
### :rocket: Epics and highlights
- Render prototype viewer with WASM (Skia) engine instead of SVG [#10037](https://github.com/penpot/penpot/issues/10037) (PR: [#10038](https://github.com/penpot/penpot/pull/10038))
- Add layer blur effect for visual depth and styling [#9844](https://github.com/penpot/penpot/issues/9844) (PR: [#10034](https://github.com/penpot/penpot/pull/10034))
- Render guides in WebGL for consistent viewer performance [#10068](https://github.com/penpot/penpot/issues/10068) (PR: [#10014](https://github.com/penpot/penpot/pull/10014))
- Add concurrency limiter and status indicators for MCP server communications [#9493](https://github.com/penpot/penpot/issues/9493) (PR: [#9748](https://github.com/penpot/penpot/pull/9748))
- Add typography token row to multiselected texts for better token visibility [#9336](https://github.com/penpot/penpot/issues/9336) (PR: [#9128](https://github.com/penpot/penpot/pull/9128))
### :sparkles: New features & Enhancements
@@ -572,6 +576,10 @@
## 2.15.0
### :rocket: Epics and highlights
- Add MCP server integration for AI-assisted design workflows [#9174](https://github.com/penpot/penpot/issues/9174) (PR: [#9032](https://github.com/penpot/penpot/pull/9032), [#9321](https://github.com/penpot/penpot/pull/9321))
### :sparkles: New features & Enhancements
- Add MCP server integration [GH #9174](https://github.com/penpot/penpot/issues/9174)
-26
View File
@@ -1,26 +0,0 @@
# HIGHLIGHTS
## 2.17.0
- Background blur is here
- WebGL rendering gets stronger
- MCP connection status and more
- Design tokens: more visible, more user-friendly
## 2.16.0
- Design tokens in the design panel
- Major community contributions
- WebGL rendering (beta)
## 2.15.0
- AI connected to real design context
- Multi-directional workflow
- Your stack, your model, your decision
@@ -333,13 +333,16 @@
[:stroke-style
:stroke-alignment
:stroke-width
:stroke-dash
:stroke-gap
:stroke-per-side
:stroke-width-top
:stroke-width-right
:stroke-width-bottom
:stroke-width-left
:stroke-cap-start
:stroke-cap-end])
:stroke-cap-end
:hidden])
;; FIXME: this function initializes an empty stroke, maybe we can move
;; it to common.types
@@ -163,7 +163,6 @@
(-> state
(assoc-in [:workspace-local :edit-path id :content-modifiers] modifiers)
(assoc-in [:workspace-local :edit-path id :moving-handler] moving-handler)
(assoc-in [:workspace-local :edit-path id :edited-handler] primary)
(cond-> (some? new-prev-handler)
(assoc-in [:workspace-local :edit-path id :prev-handler] new-prev-handler)))))))
@@ -287,10 +286,7 @@
content-modifiers))]
(-> state
(assoc-in [:workspace-local :edit-path id :content-modifiers] content-modifiers)
(cond-> (= 1 (count handler-ids))
(assoc-in [:workspace-local :edit-path id :edited-handler]
(first handler-ids))))))))
(assoc-in [:workspace-local :edit-path id :content-modifiers] content-modifiers))))))
(defn- move-node-indices
[state node-indices from-point to-point]
@@ -180,34 +180,6 @@
:else nil)))
(defn node-handler-ids
"Returns a node's curve handlers, its primary handle first."
[content node-index]
(if-let [[index prefix :as primary] (node-primary-handler content node-index)]
(let [[op-idx op-prefix] (path/opposite-index content index prefix)]
(if (some? op-idx)
[primary [op-idx op-prefix]]
[primary]))
[]))
(defn handler-type-reference
"Returns the handler that keeps its geometry when a node's handler type changes.
The other handler adapts to it. Priority: the node's only selected handler,
then its most recently edited handler, then its primary handle."
[content selection edited-handler node-index]
(let [handler-ids (node-handler-ids content node-index)
selected (filterv (get selection :handlers #{}) handler-ids)]
(cond
(= 1 (count selected))
(first selected)
(some #{edited-handler} handler-ids)
edited-handler
:else
(first handler-ids))))
(defn handlers-equal-length?
"True when a node's two handlers are the same distance from the node."
[content index prefix]
@@ -49,8 +49,7 @@
(update-in [:workspace-local :edit-path id :selection]
#(helpers/remap-selection % old-content new-content))
(update-in [:workspace-local :edit-path id :handler-types]
#(helpers/remap-handler-types % old-content new-content))
(update-in [:workspace-local :edit-path id] dissoc :edited-handler)))
#(helpers/remap-handler-types % old-content new-content))))
state)))
ptk/WatchEvent
@@ -81,11 +80,9 @@
(reduce path/make-curve-point content))))))
(defn- apply-handler-type-modifiers
"Returns modifiers that reshape a node's handlers to `type`.
`reference` keeps its geometry; the opposite handler adapts to it."
[content reference type]
(if-let [[idx prefix] reference]
"Returns modifiers that reshape a node's handlers to `type`."
[content node-index type]
(if-let [[idx prefix] (helpers/node-primary-handler content node-index)]
(case type
:mirror (helpers/move-handler-modifiers content idx prefix true true true 0 0)
:aligned (helpers/align-handler-modifiers content idx prefix 0 0)
@@ -101,13 +98,10 @@
(let [id (st/get-path-id state)
content (st/get-path state :content)
selection (st/get-selection state id)
edited (dm/get-in state [:workspace-local :edit-path id :edited-handler])
nodes (helpers/handler-target-nodes content selection)]
(if (and (some? content) (seq nodes))
(let [modifiers (reduce (fn [acc node-index]
(let [reference (helpers/handler-type-reference
content selection edited node-index)]
(d/deep-merge acc (apply-handler-type-modifiers content reference type))))
(d/deep-merge acc (apply-handler-type-modifiers content node-index type)))
{} nodes)
new-content (path/apply-content-modifiers content modifiers)]
(-> (st/set-content state new-content)
@@ -152,8 +146,7 @@
(update-in [:workspace-local :edit-path id :selection]
#(helpers/remap-selection % old-content new-content))
(update-in [:workspace-local :edit-path id :handler-types]
#(helpers/remap-handler-types % old-content new-content))
(update-in [:workspace-local :edit-path id] dissoc :edited-handler))))
#(helpers/remap-handler-types % old-content new-content)))))
(defn remove-segments
"Removes segments and opens the path at their endpoints."
@@ -29,11 +29,11 @@
(def ^:private telemetry-origin
"check-updates-modal")
(def ^:private highlights-md-url
"https://raw.githubusercontent.com/penpot/penpot/refs/heads/staging/HIGHLIGHTS.md")
(def ^:private changelog-md-url
"https://raw.githubusercontent.com/penpot/penpot/refs/heads/staging/CHANGES.md")
(def ^:private changelog-url
"https://github.com/penpot/penpot/blob/staging/CHANGES.md")
"https://github.com/penpot/penpot/blob/main/CHANGES.md")
(def ^:private release-notes-url
"https://penpot.app/release-notes")
@@ -44,6 +44,9 @@
(def ^:private bullet-re
#"^- (.+)$")
(def ^:private rocket-heading-re
#"(?m)^### :rocket: Epics and highlights\s*$")
(defn- unreleased-suffix?
[suffix]
(str/includes? (str/lower (or suffix "")) "unreleased"))
@@ -56,9 +59,54 @@
item)))
vec))
(defn- extract-rocket-items
"Given a version section body, find the :rocket: subsection and
extract its bullet items. Returns nil if no :rocket: or empty."
[version-body]
(when-let [[_ rocket-body] (str/split version-body rocket-heading-re 2)]
(let [subsection (-> (str/split rocket-body #"(?m)(?=^#{2,3}\s)") first)]
(when subsection
(let [items (parse-section-items subsection)]
(when (seq items) items))))))
(def ^:private inline-md-re
#"\[([^\]]+)\]\(((?:\([^)]*\)|[^)\s])*)\)|\*\*([^*]+)\*\*")
(defn- http-url?
[url]
(boolean (re-matches #"https?://.*" (or url ""))))
(defn parse-highlight-item
"Parse one highlight string with inline markdown (links, bold) into a
vector of {:type :text/:link/:bold, :text ..., :href ...} descriptors.
Anything unrecognized degrades to :text. Total over strings; nil and
empty input return []."
[text]
(if (or (not (string? text)) (= "" text))
[]
(loop [out [] s text]
(if (= "" s)
out
(if-let [[m link-text link-href bold-text] (re-find inline-md-re s)]
(let [idx (cstr/index-of s m)
before (subs s 0 idx)
after (subs s (+ idx (count m)))
out (if (= "" before) out (conj out {:type :text :text before}))]
(cond
(some? link-text)
(if (http-url? link-href)
(recur (conj out {:type :link :text link-text :href link-href}) after)
(recur (conj out {:type :text :text m}) after))
:else
(recur (conj out {:type :bold :text bold-text}) after)))
(conj out {:type :text :text s}))))))
(defn parse-highlights
"Parse HIGHLIGHTS.md into released version sections with bullet items.
Skips Unreleased headings. Preserves file order (newest first)."
"Parse CHANGES.md into released version sections with bullet items from
the :rocket: Epics and highlights subsection. Skips Unreleased headings,
versions without a :rocket: section, and versions with an empty one.
Preserves file order (newest first)."
[markdown]
(if-not (string? markdown)
[]
@@ -66,14 +114,19 @@
(keep (fn [part]
(when-let [[_ version suffix] (re-find version-heading-re part)]
(when-not (unreleased-suffix? suffix)
{:version version
:items (parse-section-items part)}))))
(when-let [items (extract-rocket-items part)]
{:version version
:items items})))))
vec)))
(defn parse-latest-released-version
"Return the first non-unreleased `## X.Y.Z` heading from a highlights body."
"Return the first non-unreleased `## X.Y.Z` heading from a CHANGES.md body."
[markdown]
(some-> (parse-highlights markdown) first :version))
(when (string? markdown)
(some->> (re-seq version-heading-re markdown)
(keep (fn [[_ version suffix]]
(when-not (unreleased-suffix? suffix) version)))
first)))
(defn highlights-until-installed
"Keep released sections newer than the installed version (major, minor,
@@ -101,8 +154,8 @@
(defn- handle-highlights
[installed body]
(let [sections (parse-highlights body)
latest (some-> sections first :version)]
(let [latest (parse-latest-released-version body)
sections (parse-highlights body)]
(cond
(nil? latest)
(show-unable-dialog)
@@ -124,7 +177,7 @@
(->> (http/send! {:method :get
:mode :cors
:omit-default-headers true
:uri highlights-md-url
:uri changelog-md-url
:response-type :text})
(rx/subs!
(fn [response]
@@ -280,23 +333,37 @@
:class (stl/css :modal-msg)}
(tr "dashboard.check-updates.available-message")]
[:> text* {:as "h3"
:typography t/headline-small
:class (stl/css :highlights-title)}
(tr "dashboard.check-updates.highlights-title")]
(when (seq highlights)
[:*
[:> text* {:as "h3"
:typography t/headline-small
:class (stl/css :highlights-title)}
(tr "dashboard.check-updates.highlights-title")]
[:div {:class (stl/css :highlights-scroll)}
(for [section highlights]
(let [version (:version section)
items (:items section)]
[:div {:key version
:class (stl/css :highlights-section)}
[:div {:class (stl/css :highlights-version)} version]
[:ul {:class (stl/css :highlights-list)}
(for [item items]
[:li {:key item
:class (stl/css :highlights-item)}
item])]]))]]
[:div {:class (stl/css :highlights-scroll)}
(for [section highlights]
(let [version (:version section)
items (:items section)]
[:div {:key version
:class (stl/css :highlights-section)}
[:div {:class (stl/css :highlights-version)} version]
[:ul {:class (stl/css :highlights-list)}
(for [item items]
[:li {:key item
:class (stl/css :highlights-item)}
(for [[idx frag] (map-indexed vector (parse-highlight-item item))]
(case (:type frag)
:link
[:a {:key idx
:href (:href frag)
:target "_blank"
:rel "noopener noreferrer"}
(:text frag)]
:bold
[:strong {:key idx} (:text frag)]
(:text frag)))])]]))]])]
[:div {:class (stl/css :modal-footer :modal-footer-available)}
[:> button* {:variant "secondary"
@@ -162,6 +162,20 @@
border-radius: $br-circle;
background-color: var(--color-accent-primary);
}
a {
color: var(--color-accent-primary);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
strong {
color: var(--color-foreground-primary);
font-weight: 700;
}
}
.modal-footer {
@@ -49,7 +49,8 @@
(mf/use-fn
(fn []
(st/emit! (ev/event {::ev/name "open-current-subscription"
::ev/origin "dashboard:plan-confirmation-modal"}))))
::ev/origin "dashboard:plan-confirmation-modal"}))
(modal/hide!)))
on-close
(mf/use-fn
+3 -1
View File
@@ -41,7 +41,9 @@
:m1 :margin-top
:m2 :margin-right
:m3 :margin-bottom
:m4 :margin-left})
:m4 :margin-left
:font-family :font-families})
(def ^:private map:token-attr-plugin->token-attr
(merge
@@ -76,3 +76,48 @@
(t/is (= (:stroke-alignment stroke') :inner))
(t/is (= (:stroke-color stroke') "#FABADA"))
(t/is (= (:stroke-width stroke') 2))))))))
(t/deftest test-update-stroke-color-preserves-dash-gap
;; Custom dash/gap on a dashed stroke describe stroke geometry, not color;
;; a stroke color change must preserve them (issue #11549).
(t/async
done
(let [store (ths/setup-store
(-> (cthf/sample-file :file1 :page-label :page1)
(cths/add-sample-shape :shape1 :strokes
[{:stroke-color "#000000"
:stroke-opacity 1
:stroke-width 2
:stroke-style :dashed
:stroke-dash 4
:stroke-gap 20}])
(cths/add-sample-shape :shape2 :strokes
[{:stroke-color "#000000"
:stroke-opacity 1
:stroke-width 2
:stroke-style :dashed}])))
events [(dc/change-stroke-color #{(cthi/id :shape1)} {:color "#FABADA"} 0)
(dc/change-stroke-color #{(cthi/id :shape2)} {:color "#FABADA"} 0)]]
(ths/run-store
store done events
(fn [new-state]
(let [objects (dsh/lookup-page-objects new-state)
shape1' (get objects (cthi/id :shape1))
stroke1' (first (:strokes shape1'))
shape2' (get objects (cthi/id :shape2))
stroke2' (first (:strokes shape2'))]
;; dashed stroke with custom dash/gap keeps them after color change
(t/is (some? shape1'))
(t/is (= (:stroke-color stroke1') "#FABADA"))
(t/is (= (:stroke-style stroke1') :dashed))
(t/is (= (:stroke-width stroke1') 2))
(t/is (= (:stroke-dash stroke1') 4))
(t/is (= (:stroke-gap stroke1') 20))
;; dashed stroke without explicit dash/gap stays unset:
;; no implicit default is materialized into stored data
(t/is (some? shape2'))
(t/is (= (:stroke-color stroke2') "#FABADA"))
(t/is (= (:stroke-style stroke2') :dashed))
(t/is (nil? (:stroke-dash stroke2')))
(t/is (nil? (:stroke-gap stroke2')))))))))
@@ -802,78 +802,3 @@
(t/is (empty? (emit-of (mk {:nodes #{3} :segments #{} :handlers #{}})))))))
;; Path-local undo and redo events use a seeded local stack.
;; --- Handler type changes pick which handler keeps its geometry
(defn- aligned-uneven-handlers-content
"Returns content whose node (10,0) has aligned handlers at (8,0) and (16,0)."
[]
(path/content
[{:command :move-to :params {:x 0 :y 0}}
{:command :curve-to
:params {:c1x 2 :c1y 0 :c2x 8 :c2y 0 :x 10 :y 0}}
{:command :curve-to
:params {:c1x 16 :c1y 0 :c2x 28 :c2y 0 :x 30 :y 0}}]))
(t/deftest making-handlers-equal-keeps-the-selected-handler-length
(let [id (random-uuid)
content (aligned-uneven-handlers-content)
state (pth/selectable-path-state
id content {:nodes #{} :segments #{} :handlers #{[2 :c1]}})
result (-> (ptk/update (path.tools/set-handler-type :mirror) state)
(path.state/get-path :content))]
;; The node is aligned with handlers of unequal length.
(t/is (= :aligned (path.helpers/derive-handler-type content 1)))
;; The selected handler keeps its length and the opposite one adapts.
(t/is (= (gpt/point 16 0) (path/get-handler-point result 2 :c1)))
(t/is (= (gpt/point 4 0) (path/get-handler-point result 1 :c2)))))
(t/deftest making-handlers-equal-falls-back-to-the-last-edited-handler
(let [id (random-uuid)
content (aligned-uneven-handlers-content)
state (-> (pth/selectable-path-state
id content {:nodes #{1} :segments #{} :handlers #{}})
(assoc-in [:workspace-local :edit-path id :edited-handler]
[2 :c1]))
result (-> (ptk/update (path.tools/set-handler-type :mirror) state)
(path.state/get-path :content))]
(t/is (= (gpt/point 16 0) (path/get-handler-point result 2 :c1)))
(t/is (= (gpt/point 4 0) (path/get-handler-point result 1 :c2)))))
(t/deftest making-handlers-equal-uses-the-incoming-handler-with-no-hint
(let [id (random-uuid)
content (aligned-uneven-handlers-content)
state (pth/selectable-path-state
id content {:nodes #{1} :segments #{} :handlers #{}})
result (-> (ptk/update (path.tools/set-handler-type :mirror) state)
(path.state/get-path :content))]
(t/is (= (gpt/point 8 0) (path/get-handler-point result 1 :c2)))
(t/is (= (gpt/point 12 0) (path/get-handler-point result 2 :c1)))))
(t/deftest aligning-handlers-takes-the-angle-of-the-selected-handler
(let [id (random-uuid)
content (path/content
[{:command :move-to :params {:x 0 :y 0}}
{:command :curve-to
:params {:c1x 2 :c1y 0 :c2x 10 :c2y -3 :x 10 :y 0}}
{:command :curve-to
:params {:c1x 16 :c1y 0 :c2x 28 :c2y 0 :x 30 :y 0}}])
state (pth/selectable-path-state
id content {:nodes #{} :segments #{} :handlers #{[2 :c1]}})
result (-> (ptk/update (path.tools/set-handler-type :aligned) state)
(path.state/get-path :content))]
;; The selected handler stays put; the opposite one rotates onto its axis
;; keeping its own length.
(t/is (= (gpt/point 16 0) (path/get-handler-point result 2 :c1)))
(t/is (= (gpt/point 7 0) (path/get-handler-point result 1 :c2)))))
(t/deftest dragging-a-handler-records-it-as-the-last-edited-one
(let [id (random-uuid)
content (aligned-uneven-handlers-content)
state (pth/selectable-path-state
id content {:nodes #{} :segments #{} :handlers #{[2 :c1]}})
state' (ptk/update
(path.edition/modify-selected-handlers id [2 :c1] {} 3 0 :independent false)
state)]
(t/is (= [2 :c1]
(get-in state' [:workspace-local :edit-path id :edited-handler])))))
@@ -22,9 +22,14 @@
[cljs.test :as t :include-macros true]
[frontend-tests.helpers.mock :as mock]
[frontend-tests.helpers.state :as ths]
[frontend-tests.helpers.wasm :as thw]
[potok.v2.core :as ptk]))
(t/use-fixtures :each {:before cthi/reset-idmap!})
(t/use-fixtures :each
{:before (fn []
(cthi/reset-idmap!)
(thw/setup-wasm-mocks!))
:after thw/teardown-wasm-mocks!})
(def ^:private get-resolved-value @#'ptok/get-resolved-value)
@@ -81,6 +86,18 @@
(t/is (= :m3 (ptok/token-attr-plugin->token-attr :margin-bottom)))
(t/is (= :m4 (ptok/token-attr-plugin->token-attr :margin-left))))
(t/deftest token-attr-plugin->token-attr-resolves-font-family-alias
;; Plugin-facing `fontFamilies` (kebab-cased to `:font-families` by the
;; schema layer) maps to the canonical internal `:font-family`.
(t/is (= :font-family (ptok/token-attr-plugin->token-attr :font-families)))
(t/is (= :font-family (ptok/token-attr-plugin->token-attr "font-families"))))
(t/deftest token-attr->token-attr-plugin-resolves-font-family-alias
;; Symmetric direction: the canonical internal attribute maps to the
;; plural plugin-facing name so applied-token readback serializes as
;; camelCase `fontFamilies`, not the undocumented singular `fontFamily`.
(t/is (= :font-families (ptok/token-attr->token-attr-plugin :font-family))))
(t/deftest token-attr-plugin->token-attr-coerces-string-input
;; This is the actual regression — JS plugin calls supply strings.
(t/is (= :fill (ptok/token-attr-plugin->token-attr "fill")))
@@ -150,6 +167,57 @@
(done)))
0))))
(t/deftest shape-apply-token-accepts-font-families
(t/async
done
(let [set-id (cthi/new-id! :token-set)
token-id (cthi/new-id! :font-family-token)
file (-> (cthf/sample-file :file1 :page-label :page1)
(ctho/add-text :text1 "Hello World!")
(ctht/add-tokens-lib)
(ctht/update-tokens-lib
#(-> %
(ctob/add-set
(ctob/make-token-set :id set-id
:name "fonts"))
(ctob/add-theme
(ctob/make-token-theme :name "theme"
:sets #{"fonts"}))
(ctob/set-active-themes #{"/theme"})
(ctob/add-token
set-id
(ctob/make-token :id token-id
:name "font.primary"
:type :font-family
:value ["Inter"])))))
store (ths/setup-store file)
_ (set! st/state store)
_ (set! st/stream (ptk/input-stream store))
^js context (api/create-context "00000000-0000-0000-0000-000000000000")
^js page (.-currentPage context)
^js shape (.getShapeById page (str (cthi/id :text1)))
^js library (.-library context)
^js local (.-local library)
^js catalog (.-tokens local)
^js token-set (.getSetById catalog (str set-id))
^js token (.getTokenById token-set (str token-id))]
(.applyToken shape token #js ["fontFamilies"])
(js/setTimeout
(fn []
(let [shape-id (cthi/id :text1)
page-id (cthf/current-page-id file)]
;; Plugin readback exposes the documented plural key.
(t/is (= "font.primary" (.. shape -tokens -fontFamilies)))
;; The undocumented singular spelling must not leak.
(t/is (undefined? (.. shape -tokens -fontFamily)))
;; Internal state keeps the canonical `:font-family` key.
(t/is (= "font.primary"
(get-in @store
[:files (:id file) :data :pages-index page-id
:objects shape-id :applied-tokens :font-family])))
(done)))
0))))
(t/deftest token-attr?-rejects-unknown-input
(t/is (false? (boolean (ptok/token-attr? :not-a-real-attr))))
(t/is (false? (boolean (ptok/token-attr? "not-a-real-attr"))))
@@ -10,30 +10,49 @@
[app.main.ui.dashboard.check-updates :as dcu]
[cljs.test :as t :include-macros true]))
(def ^:private sample-highlights
(str "# HIGHLIGHTS\n"
(def ^:private sample-changes
(str "# CHANGELOG\n"
"\n"
"## 2.18.0 (Unreleased)\n"
"\n"
"- To do\n"
"### :sparkles: New features & Enhancements\n"
"\n"
"## 2.17.2\n"
"- Something WIP\n"
"\n"
"- Background blur is here\n"
"- WebGL rendering gets stronger\n"
"## 2.17.0\n"
"\n"
"## 2.17.1\n"
"### :rocket: Epics and highlights\n"
"\n"
"- MCP connection status and more\n"
"- Design tokens: more visible, more user-friendly\n"))
"- Background blur [#9844](https://github.com/penpot/penpot/issues/9844) (PR: [#10034](https://github.com/penpot/penpot/pull/10034))\n"
"- WebGL rendering [#10068](https://github.com/penpot/penpot/issues/10068) (PR: [#10014](https://github.com/penpot/penpot/pull/10014))\n"
"\n"
"### :sparkles: New features & Enhancements\n"
"\n"
"- Other stuff\n"
"\n"
"## 2.16.0\n"
"\n"
"### :rocket: Epics and highlights\n"
"\n"
"### :sparkles: New features & Enhancements\n"
"\n"
"- Tokens stuff\n"
"\n"
"## 2.15.0\n"
"\n"
"### :sparkles: New features & Enhancements\n"
"\n"
"- MCP server\n"))
(t/deftest parse-latest-released-version-skips-unreleased
(t/is (= "2.17.2" (dcu/parse-latest-released-version sample-highlights))))
;; --- parse-latest-released-version ---
(t/deftest parse-latest-released-version-from-changes
(t/is (= "2.17.0" (dcu/parse-latest-released-version sample-changes))))
(t/deftest parse-latest-released-version-first-released
(t/is (= "2.17.2"
(t/is (= "2.17.0"
(dcu/parse-latest-released-version
"## 2.17.2\n\n- Fix\n\n## 2.17.1\n\n- Fix\n"))))
"## 2.17.0\n\n### :sparkles:\n\n- Fix\n\n## 2.16.0\n\n### :sparkles:\n\n- Fix\n"))))
(t/deftest parse-latest-released-version-only-unreleased
(t/is (nil? (dcu/parse-latest-released-version
@@ -41,30 +60,154 @@
(t/deftest parse-latest-released-version-empty
(t/is (nil? (dcu/parse-latest-released-version "")))
(t/is (nil? (dcu/parse-latest-released-version "# HIGHLIGHTS\n"))))
(t/is (nil? (dcu/parse-latest-released-version "# CHANGELOG\n"))))
(t/deftest parse-highlights-skips-unreleased-and-collects-bullets
(t/is (= [{:version "2.17.2"
:items ["Background blur is here"
"WebGL rendering gets stronger"]}
{:version "2.17.1"
:items ["MCP connection status and more"
"Design tokens: more visible, more user-friendly"]}]
(dcu/parse-highlights sample-highlights))))
;; --- parse-highlights ---
(t/deftest parse-highlights-extracts-rocket-items
(let [result (dcu/parse-highlights sample-changes)]
(t/is (= [{:version "2.17.0"
:items ["Background blur [#9844](https://github.com/penpot/penpot/issues/9844) (PR: [#10034](https://github.com/penpot/penpot/pull/10034))"
"WebGL rendering [#10068](https://github.com/penpot/penpot/issues/10068) (PR: [#10014](https://github.com/penpot/penpot/pull/10014))"]}]
result))))
(t/deftest parse-highlights-skips-empty-rocket
;; 2.16.0 has an empty :rocket: section — should be skipped
(let [result (dcu/parse-highlights sample-changes)]
(t/is (not (some #(= "2.16.0" (:version %)) result)))))
(t/deftest parse-highlights-skips-missing-rocket
;; 2.15.0 has no :rocket: section — should be skipped
(let [result (dcu/parse-highlights sample-changes)]
(t/is (not (some #(= "2.15.0" (:version %)) result)))))
(t/deftest parse-highlights-skips-unreleased
;; 2.18.0 (Unreleased) should be skipped
(let [result (dcu/parse-highlights sample-changes)]
(t/is (not (some #(= "2.18.0" (:version %)) result)))))
(t/deftest parse-highlights-empty-input
(t/is (= [] (dcu/parse-highlights "")))
(t/is (= [] (dcu/parse-highlights nil)))
(t/is (= [] (dcu/parse-highlights 42))))
(t/deftest parse-highlights-extracts-multiple-versions
(let [input (str "## 2.17.0\n\n### :rocket: Epics and highlights\n\n"
"- Feature A [#1](https://github.com/penpot/penpot/issues/1)\n\n"
"## 2.16.0\n\n### :rocket: Epics and highlights\n\n"
"- Feature B [#2](https://github.com/penpot/penpot/issues/2)\n")
result (dcu/parse-highlights input)]
(t/is (= 2 (count result)))
(t/is (= "2.17.0" (:version (first result))))
(t/is (= "2.16.0" (:version (second result))))))
;; --- version-compare ---
(t/deftest version-compare
(t/is (zero? (v/compare-versions "2.17.1" "2.17.1")))
(t/is (pos? (v/compare-versions "2.17.2" "2.17.1")))
(t/is (neg? (v/compare-versions "2.17.1" "2.17.2")))
(t/is (zero? (v/compare-versions "2.17.0" "2.17.0")))
(t/is (pos? (v/compare-versions "2.17.0" "2.16.0")))
(t/is (neg? (v/compare-versions "2.16.0" "2.17.0")))
(t/is (pos? (v/compare-versions "3.0.0" "2.99.99")))
(t/is (neg? (v/compare-versions "2.17.2" "2.17.10"))))
(t/is (neg? (v/compare-versions "2.17.0" "2.17.10"))))
;; --- highlights-until-installed ---
(t/deftest highlights-until-installed
(let [sections (dcu/parse-highlights sample-highlights)]
(t/is (= [{:version "2.17.2"
:items ["Background blur is here"
"WebGL rendering gets stronger"]}]
(dcu/highlights-until-installed sections "2.17.1")))
(t/is (= [] (dcu/highlights-until-installed sections "2.17.2")))
(t/is (= sections (dcu/highlights-until-installed sections "2.16.0")))
(t/is (= [] (dcu/highlights-until-installed sections "2.17.10")))))
(let [sections (dcu/parse-highlights sample-changes)]
;; installed 2.16.0 → shows 2.17.0 highlights
(t/is (= [{:version "2.17.0"
:items ["Background blur [#9844](https://github.com/penpot/penpot/issues/9844) (PR: [#10034](https://github.com/penpot/penpot/pull/10034))"
"WebGL rendering [#10068](https://github.com/penpot/penpot/issues/10068) (PR: [#10014](https://github.com/penpot/penpot/pull/10014))"]}]
(dcu/highlights-until-installed sections "2.16.0")))
;; installed 2.17.0 → no newer highlights
(t/is (= [] (dcu/highlights-until-installed sections "2.17.0")))
;; installed older version → shows all available highlights
(t/is (= sections (dcu/highlights-until-installed sections "2.14.0")))
;; installed newer than any highlight → empty
(t/is (= [] (dcu/highlights-until-installed sections "2.99.0")))))
;; --- parse-highlight-item ---
(t/deftest parse-highlight-item-link
(t/is (= [{:type :text :text "See "}
{:type :link :text "#9844"
:href "https://github.com/penpot/penpot/issues/9844"}]
(dcu/parse-highlight-item
"See [#9844](https://github.com/penpot/penpot/issues/9844)"))))
(t/deftest parse-highlight-item-bold
(t/is (= [{:type :bold :text "New plugin system."}]
(dcu/parse-highlight-item "**New plugin system.**"))))
(t/deftest parse-highlight-item-mixed-real-line
(t/is (= [{:type :text :text "Background blur "}
{:type :link :text "#9844"
:href "https://github.com/penpot/penpot/issues/9844"}
{:type :text :text " (PR: "}
{:type :link :text "#10034"
:href "https://github.com/penpot/penpot/pull/10034"}
{:type :text :text ")"}]
(dcu/parse-highlight-item
"Background blur [#9844](https://github.com/penpot/penpot/issues/9844) (PR: [#10034](https://github.com/penpot/penpot/pull/10034))"))))
(t/deftest parse-highlight-item-plain-text
(t/is (= [{:type :text :text "Just plain text, no markdown"}]
(dcu/parse-highlight-item "Just plain text, no markdown"))))
(t/deftest parse-highlight-item-empty
(t/is (= [] (dcu/parse-highlight-item "")))
(t/is (= [] (dcu/parse-highlight-item nil))))
(t/deftest parse-highlight-item-unbalanced-fallback
(t/is (= [{:type :text :text "Broken [link without end"}]
(dcu/parse-highlight-item "Broken [link without end")))
(t/is (= [{:type :text :text "Unclosed **bold"}]
(dcu/parse-highlight-item "Unclosed **bold"))))
(t/deftest parse-highlight-item-non-http-url-is-plain-text
(t/is (= [{:type :text :text "[click](javascript:alert(1))"}]
(dcu/parse-highlight-item "[click](javascript:alert(1))"))))
;; --- parse-highlight-item edge cases ---
(t/deftest parse-highlight-item-taiga-link
(t/is (= [{:type :text :text "Grid CSS layout "}
{:type :link :text "Taiga #4915"
:href "https://tree.taiga.io/project/penpot/epic/4915"}]
(dcu/parse-highlight-item
"Grid CSS layout [Taiga #4915](https://tree.taiga.io/project/penpot/epic/4915)"))))
(t/deftest parse-highlight-item-multi-pr-line
(t/is (= [{:type :text :text "Add MCP "}
{:type :link :text "#9174"
:href "https://github.com/penpot/penpot/issues/9174"}
{:type :text :text " (PR: "}
{:type :link :text "#9032"
:href "https://github.com/penpot/penpot/pull/9032"}
{:type :text :text ", "}
{:type :link :text "#9321"
:href "https://github.com/penpot/penpot/pull/9321"}
{:type :text :text ")"}]
(dcu/parse-highlight-item
"Add MCP [#9174](https://github.com/penpot/penpot/issues/9174) (PR: [#9032](https://github.com/penpot/penpot/pull/9032), [#9321](https://github.com/penpot/penpot/pull/9321))"))))
(t/deftest parse-highlight-item-link-only
(t/is (= [{:type :link :text "#1"
:href "https://github.com/penpot/penpot/issues/1"}]
(dcu/parse-highlight-item
"[#1](https://github.com/penpot/penpot/issues/1)"))))
(t/deftest parse-highlight-item-bold-adjacent-to-link
(t/is (= [{:type :bold :text "Hot"}
{:type :text :text " "}
{:type :link :text "#1"
:href "https://github.com/penpot/penpot/issues/1"}]
(dcu/parse-highlight-item
"**Hot** [#1](https://github.com/penpot/penpot/issues/1)"))))
(t/deftest parse-highlight-item-bold-inside-link-is-not-nested
;; Link wins; inner ** stays raw text (documented, no nesting)
(t/is (= [{:type :link :text "a **b**"
:href "https://github.com/penpot/penpot/issues/1"}]
(dcu/parse-highlight-item
"[a **b**](https://github.com/penpot/penpot/issues/1)"))))
+1
View File
@@ -11,6 +11,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.
- **plugins-runtime**: Fixed the `fontFamilies` token property mapping so `Shape.applyToken(token, ["fontFamilies"])` resolves to the canonical `:font-family` attribute and applied-token readback exposes the documented `fontFamilies` key instead of the undocumented singular `fontFamily`. Closes #11405.
## 1.5.0 (2026-07-08)
+36 -6
View File
@@ -1,6 +1,6 @@
use skia_safe::{self as skia, Paint};
use crate::shapes::{Shape, Type};
use crate::shapes::{radius_to_sigma, Shape, Type};
use crate::state::ShapesPoolRef;
use crate::render::vector::draw_shape_geometry;
@@ -136,17 +136,40 @@ impl SvgLayerCanvas {
}
/// Finalizes a fragment canvas as a `<clipPath>` def.
///
/// Rewrite fill-rule to clip-rule: clipPaths ignore fill-rule, so evenodd
/// stroke rings would otherwise fill solid.
pub(super) fn finish_clip_path_fragment(&mut self, id: &str, canvas: skia::svg::Canvas) {
let data = canvas.end();
let doc = String::from_utf8_lossy(data.as_bytes());
let inner = extract_inner_svg(&doc);
let prefix = format!("f{}_", self.frag_no);
self.frag_no += 1;
let geometry = sanitize_skia_svg_fragment(&remap_ids(inner, &prefix));
let geometry = sanitize_skia_svg_fragment(&remap_ids(inner, &prefix))
.replace("fill-rule=", "clip-rule=");
self.defs.push_str(&format!(
"<clipPath id=\"{id}\" clipPathUnits=\"userSpaceOnUse\">{geometry}</clipPath>"
));
}
/// Registers a layer-blur `<filter>` and returns its id.
///
/// `sigma` is Skia/canvas stdDeviation (`radius_to_sigma(value * scale)`).
/// Padding (±50%) avoids the default 10% objectBoundingBox clip on large blurs.
pub(super) fn push_layer_blur_filter(&mut self, sigma: f32) -> String {
let id = self.unique("blur");
self.defs.push_str(&format!(
concat!(
"<filter id=\"{id}\" x=\"-50%\" y=\"-50%\" width=\"200%\" height=\"200%\" ",
"color-interpolation-filters=\"sRGB\">",
"<feGaussianBlur stdDeviation=\"{sigma}\"/>",
"</filter>"
),
id = id,
sigma = sigma
));
id
}
}
/// Draws a clip geometry into `cv` (already set up with the page transform).
@@ -167,11 +190,11 @@ fn draw_clip_geometry(cv: &skia::Canvas, shape: &Shape, tree: ShapesPoolRef, pai
}
/// Builds the `<g>` attribute string for a shape's composite effects (opacity,
/// blend mode). Returns `None` when the shape needs no wrapper.
/// blend mode, layer blur). Returns `None` when the shape needs no wrapper.
///
/// Layer blur / shadows are intentionally omitted here — they need native SVG
/// filter re-emission to survive `SkSVGDevice` and land in later PRs.
pub(super) fn effect_attrs(element: &Shape) -> Option<String> {
/// Layer blur is a native SVG `<filter>` (SkSVGDevice drops paint image-filters).
/// Shadows still need dedicated re-emission in a later PR.
pub(super) fn effect_attrs(builder: &mut SvgLayerCanvas, element: &Shape) -> Option<String> {
let mut parts: Vec<String> = Vec::new();
let opacity = element.opacity();
@@ -183,6 +206,13 @@ pub(super) fn effect_attrs(element: &Shape) -> Option<String> {
parts.push(format!("style=\"mix-blend-mode:{css}\""));
}
if let Some(blur) = element.visible_layer_blur() {
// Match canvas `Shape::image_filter`: sigma from radius × export scale.
let sigma = radius_to_sigma(blur.value * builder.scale);
let id = builder.push_layer_blur_filter(sigma);
parts.push(format!("filter=\"url(#{id})\""));
}
if parts.is_empty() {
None
} else {
+18
View File
@@ -378,6 +378,24 @@ fn stroke_with_style(
stroke
}
fn image_stroke(kind: StrokeKind, style: StrokeStyle, width: f32, image_id: Uuid) -> 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 = test_image_fill(image_id);
stroke
}
pub(super) fn image_solid_stroke(kind: StrokeKind, width: f32, image_id: Uuid) -> Stroke {
image_stroke(kind, StrokeStyle::Solid, width, image_id)
}
pub(super) fn image_dotted_stroke(kind: StrokeKind, width: f32, image_id: Uuid) -> Stroke {
image_stroke(kind, StrokeStyle::Dotted, width, image_id)
}
/// Text with a linked image fill (register URL via `render_with`).
pub(super) fn add_image_text(
pool: &mut ShapesPool,
+3 -12
View File
@@ -1,11 +1,9 @@
use crate::error::Result;
use crate::render::shape_renderer::ShapeRenderer;
use crate::render::vector::VectorRenderer;
use crate::shapes::{Shape, Stroke};
use crate::state::ShapesPoolRef;
use super::document::{effect_attrs, SvgLayerCanvas};
use super::images::emit_fills;
use super::images::{emit_fills, emit_strokes};
use super::render_tree;
use crate::render::RenderResources;
@@ -16,9 +14,7 @@ pub(super) fn render_frame(
tree: ShapesPoolRef,
scale: f32,
) -> Result<()> {
let matrix = element.centered_transform();
let effects = effect_attrs(element);
let effects = effect_attrs(builder, element);
if let Some(attrs) = &effects {
builder.open_group(attrs);
}
@@ -51,12 +47,7 @@ pub(super) fn render_frame(
// 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();
canvas.save();
canvas.concat(&matrix);
let mut renderer = VectorRenderer::new(canvas, shared, scale, false);
renderer.draw_strokes(element, &visible_strokes)?;
canvas.restore();
emit_strokes(builder, shared, element, &visible_strokes, scale)?;
}
if effects.is_some() {
+1 -1
View File
@@ -13,7 +13,7 @@ pub(super) fn render_group(
tree: ShapesPoolRef,
scale: f32,
) -> Result<()> {
let effects = effect_attrs(element);
let effects = effect_attrs(builder, element);
if let Some(attrs) = &effects {
builder.open_group(attrs);
}
+94 -4
View File
@@ -1,12 +1,14 @@
use crate::error::Result;
use crate::math::Rect as MathRect;
use crate::render::get_dest_rect;
use crate::render::get_image_dest_rect;
use crate::render::shape_renderer::ShapeRenderer;
use crate::render::vector::VectorRenderer;
use crate::shapes::{Fill, ImageFill, Shape};
use crate::render::vector::{paint_svg_stroke_silhouette, VectorRenderer};
use crate::shapes::{Fill, ImageFill, Shape, Stroke};
use crate::state::ShapesPoolRef;
use super::document::SvgLayerCanvas;
use crate::math::Rect as MathRect;
use crate::render::{get_image_dest_rect, RenderResources};
use crate::render::RenderResources;
/// Emits fills bottom -> top for SVG export.
///
@@ -69,6 +71,94 @@ fn emit_image_fill(
Ok(())
}
/// Emits strokes bottom -> top for SVG export.
///
/// Image strokes with a registered URL become a linked `<image>` clipped to the
/// stroke silhouette (Skia drops the GPU save_layer + SrcIn path). Other strokes
/// go through [`VectorRenderer`].
pub(super) fn emit_strokes(
builder: &mut SvgLayerCanvas,
shared: &mut RenderResources,
shape: &Shape,
strokes: &[&Stroke],
scale: f32,
) -> Result<()> {
if strokes.is_empty() {
return Ok(());
}
let matrix = shape.centered_transform();
// strokes[0] is topmost; draw bottom -> top.
for stroke in strokes.iter().rev() {
match &stroke.fill {
Fill::Image(image_fill) if shared.images.source_url(&image_fill.id()).is_some() => {
emit_image_stroke(builder, shared, shape, stroke, image_fill, scale)?;
}
_ => {
let canvas = builder.canvas();
canvas.save();
canvas.concat(&matrix);
let mut renderer = VectorRenderer::new(canvas, shared, scale, false);
renderer.draw_strokes(shape, std::slice::from_ref(stroke))?;
canvas.restore();
}
}
}
Ok(())
}
/// Linked `<image>` clipped to the stroke outline (opaque filled path).
fn emit_image_stroke(
builder: &mut SvgLayerCanvas,
shared: &RenderResources,
shape: &Shape,
stroke: &Stroke,
image_fill: &ImageFill,
scale: f32,
) -> Result<()> {
let Some(url) = shared.images.source_url(&image_fill.id()) else {
return Ok(());
};
let clip_id = builder.unique("imgstrokeclip");
let canvas = builder.new_fragment();
{
let cv: &skia_safe::Canvas = &canvas;
cv.save();
cv.concat(&shape.centered_transform());
if !paint_svg_stroke_silhouette(cv, shape, stroke, scale) {
cv.restore();
return Ok(());
}
cv.restore();
}
builder.finish_clip_path_fragment(&clip_id, canvas);
let href = xml_escape_attr(url);
let dest = image_stroke_dest_rect(shape, stroke);
emit_linked_image_element(builder, shape, image_fill, dest, &href, &clip_id);
Ok(())
}
/// Where to place the linked image for an image-filled stroke.
///
/// Starts from the same dest as the GPU path (`selrect` + `stroke.delta()`), then
/// grows on open paths so marker caps are still covered by the `<image>`.
fn image_stroke_dest_rect(shape: &Shape, stroke: &Stroke) -> MathRect {
let mut dest = get_dest_rect(&shape.selrect(), stroke.delta());
if !shape.is_open() {
return dest;
}
let cap_margin = stroke.cap_bounds_margin();
if cap_margin <= 0.0 {
return dest;
}
let mut with_caps = shape.selrect();
with_caps.inset((-cap_margin, -cap_margin));
dest.join(with_caps);
dest
}
/// Emits `<g clip-path>` + `<image href>` at `dest_rect`, under the page CTM.
pub(super) fn emit_linked_image_element(
builder: &mut SvgLayerCanvas,
+13 -7
View File
@@ -60,9 +60,10 @@ fn svg_page_bounds(shape: &Shape, tree: ShapesPoolRef, scale: f32) -> skia::Rect
/// composed as native SVG `<g>` wrappers. Frame `clip content` uses a native
/// `<clipPath>`.
///
/// Special-case re-emission for shadows, layer blur, masks, text strokes, and
/// image strokes is intentionally out of scope for this cut. Solid Inner/Outer
/// and dotted/dashed strokes are emitted as filled outlines.
/// Layer blur is re-emitted as a native SVG `feGaussianBlur` filter wrapper.
/// Shadows, masks, and text strokes still need dedicated SVG re-emission.
/// Solid Inner/Outer and dotted/dashed strokes go out as filled outlines;
/// image-filled strokes use a linked `<image>` clipped to the stroke.
pub fn render_to_svg(
shared: &mut RenderResources,
id: &Uuid,
@@ -135,7 +136,7 @@ use groups::render_group;
use text::render_text_fill;
use document::effect_attrs;
use images::emit_fills;
use images::{emit_fills, emit_strokes};
/// Renders `id`'s subtree to an SVG body, returning `(defs, body)`.
fn render_body(
@@ -186,7 +187,7 @@ fn render_leaf(
tree: ShapesPoolRef,
scale: f32,
) -> Result<()> {
let effects = effect_attrs(element);
let effects = effect_attrs(builder, element);
if let Some(attrs) = &effects {
builder.open_group(attrs);
}
@@ -211,17 +212,22 @@ fn render_leaf(
canvas.concat(&matrix);
let mut renderer = VectorRenderer::new(canvas, shared, scale, false);
renderer.draw_fill_inner_shadows(element)?;
canvas.restore();
let visible_strokes: Vec<_> = element.visible_strokes().collect();
if !visible_strokes.is_empty() {
renderer.draw_strokes(element, &visible_strokes)?;
emit_strokes(builder, shared, element, &visible_strokes, scale)?;
if !element.has_fills() {
let canvas = builder.canvas();
canvas.save();
canvas.concat(&matrix);
let mut renderer = VectorRenderer::new(canvas, shared, scale, false);
for stroke in &visible_strokes {
renderer.draw_stroke_inner_shadows(element, stroke)?;
}
canvas.restore();
}
}
canvas.restore();
}
}
@@ -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"><defs><clipPath id="imgstrokeclip0" clipPathUnits="userSpaceOnUse">
<path 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" clip-rule="evenodd"/>
</clipPath></defs><g clip-path="url(#imgstrokeclip0)"><image href="images/test-fill.svg" x="-16" y="-16" width="132" height="112" preserveAspectRatio="xMidYMid slice" transform="matrix(1 0 0 1 16 16)"/></g></svg>
@@ -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"><defs><clipPath id="imgstrokeclip0" clipPathUnits="userSpaceOnUse">
<path transform="translate(16 16)" d="M-8 -8L108 -8L108 88L-8 88L-8 -8ZM100 0L0 0L0 80L100 80L100 0Z" clip-rule="evenodd"/>
</clipPath></defs><g clip-path="url(#imgstrokeclip0)"><image href="images/test-fill.svg" x="-16" y="-16" width="132" height="112" preserveAspectRatio="xMidYMid slice" transform="matrix(1 0 0 1 16 16)"/></g></svg>
@@ -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="223.7846" height="123.78461" viewBox="0 0 223.7846 123.78461"><defs><filter id="blur0" x="-50%" y="-50%" width="200%" height="200%" color-interpolation-filters="sRGB"><feGaussianBlur stdDeviation="3.9641016"/></filter></defs><g filter="url(#blur0)">
<rect fill="blue" transform="translate(11.8923 11.8923)" width="90" height="100"/>
<rect fill="#00C800" transform="translate(11.8923 11.8923)" x="110" width="90" height="100"/>
</g></svg>
@@ -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="137.64102" height="117.64102" viewBox="0 0 137.64102 117.64102"><defs><filter id="blur0" x="-50%" y="-50%" width="200%" height="200%" color-interpolation-filters="sRGB"><feGaussianBlur stdDeviation="6.2735023"/></filter></defs><g filter="url(#blur0)">
<rect fill="red" transform="translate(18.8205 18.8205)" width="100" height="80"/>
</g></svg>
@@ -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="344" height="294" viewBox="0 0 344 294"><defs><clipPath id="imgstrokeclip0" clipPathUnits="userSpaceOnUse">
<path transform="translate(102 102)" d="M146 -6L0 -6L0 6L134 6L134 84L0 84L0 96L146 96L146 -6Z" clip-rule="evenodd"/>
<path transform="translate(102 102)" d="M-24 0L24 48L24 -48L-24 0Z"/>
<ellipse transform="translate(102 102)" cx="0" cy="90" rx="24" ry="24"/>
</clipPath></defs><g clip-path="url(#imgstrokeclip0)"><image href="images/test-fill.svg" x="-48" y="-48" width="236" height="186" preserveAspectRatio="xMidYMid slice" transform="matrix(1 0 0 1 102 102)"/></g></svg>
@@ -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"><defs><clipPath id="imgstrokeclip0" clipPathUnits="userSpaceOnUse">
<path transform="translate(8 8)" d="M104 -4L0 -4L0 4L96 4L96 76L0 76L0 84L104 84L104 -4Z" clip-rule="evenodd"/>
</clipPath></defs><g clip-path="url(#imgstrokeclip0)"><image href="images/test-fill.svg" x="-8" y="-8" width="116" height="96" preserveAspectRatio="xMidYMid slice" transform="matrix(1 0 0 1 8 8)"/></g></svg>
@@ -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"><defs><clipPath id="imgstrokeclip0" clipPathUnits="userSpaceOnUse">
<path 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" clip-rule="evenodd"/>
</clipPath></defs><g clip-path="url(#imgstrokeclip0)"><image href="images/test-fill.svg" x="2" y="2" width="116" height="96" preserveAspectRatio="xMidYMid slice" transform="matrix(1 0 0 1 -6 -6)"/></g></svg>
@@ -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"><defs><clipPath id="imgstrokeclip0" clipPathUnits="userSpaceOnUse">
<path transform="translate(-6 -6)" d="M114 6L6 6L6 94L114 94L114 6ZM14 86L14 14L106 14L106 86L14 86Z" clip-rule="evenodd"/>
</clipPath></defs><g clip-path="url(#imgstrokeclip0)"><image href="images/test-fill.svg" x="2" y="2" width="116" height="96" preserveAspectRatio="xMidYMid slice" transform="matrix(1 0 0 1 -6 -6)"/></g></svg>
@@ -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="imgstrokeclip0" clipPathUnits="userSpaceOnUse">
<path d="M100 0L0 0L0 80L100 80L100 0ZM10 10L10 70L90 70L90 10L10 10Z" clip-rule="evenodd"/>
</clipPath></defs><g clip-path="url(#imgstrokeclip0)"><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>
@@ -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"><defs><clipPath id="imgstrokeclip0" clipPathUnits="userSpaceOnUse">
<path transform="translate(-10 -10)" d="M10 10L130 10L130 110L10 110L10 10ZM120 20L20 20L20 100L120 100L120 20Z" clip-rule="evenodd"/>
</clipPath></defs><g clip-path="url(#imgstrokeclip0)"><image href="images/test-fill.svg" x="0" y="0" width="140" height="120" preserveAspectRatio="xMidYMid slice" transform="matrix(1 0 0 1 -10 -10)"/></g></svg>
@@ -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">
<rect fill="red" width="100" height="80"/>
</svg>
+348 -1
View File
@@ -1,7 +1,8 @@
use super::fixtures::*;
use crate::shapes::{
BlendMode, Fill, ImageFill, ImageFillTransform, SolidColor, StrokeCap, StrokeKind,
radius_to_sigma, BlendMode, Blur, BlurType, Fill, ImageFill, ImageFillTransform, SolidColor,
StrokeCap, StrokeKind,
};
use crate::state::ShapesPool;
use crate::uuid::Uuid;
@@ -85,6 +86,116 @@ fn exports_leaf_opacity_and_blend_mode_as_group_wrappers() {
insta::assert_snapshot!(svg);
}
#[test]
fn exports_leaf_layer_blur_as_fe_gaussian_blur() {
let mut pool = ShapesPool::new();
let id = uid(1);
add_solid_rect(
&mut pool,
id,
Uuid::nil(),
(0.0, 0.0, 100.0, 80.0),
skia::Color::from_rgb(255, 0, 0),
);
let blur_value = 10.0;
{
let shape = pool.get_mut(&id).unwrap();
shape.set_blur(Some(Blur::new(BlurType::LayerBlur, false, blur_value)));
}
let svg = render(&pool, id);
let expected_sigma = radius_to_sigma(blur_value * 1.0);
assert!(
svg.contains("<filter") && svg.contains("feGaussianBlur"),
"layer blur must emit an SVG filter: {svg}"
);
assert!(
svg.contains(&format!("stdDeviation=\"{expected_sigma}\"")),
"stdDeviation must match canvas radius_to_sigma(value * scale): {svg}"
);
assert!(
svg.contains("filter=\"url(#blur"),
"shape group must reference the blur filter: {svg}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn skips_hidden_layer_blur() {
let mut pool = ShapesPool::new();
let id = uid(1);
add_solid_rect(
&mut pool,
id,
Uuid::nil(),
(0.0, 0.0, 100.0, 80.0),
skia::Color::from_rgb(255, 0, 0),
);
{
let shape = pool.get_mut(&id).unwrap();
shape.set_blur(Some(Blur::new(BlurType::LayerBlur, true, 10.0)));
}
let svg = render(&pool, id);
assert!(
!svg.contains("feGaussianBlur") && !svg.contains("filter=\"url(#blur"),
"hidden layer blur must not emit a filter: {svg}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn exports_group_layer_blur_wrapping_children() {
let mut pool = ShapesPool::new();
let group_id = uid(1);
let a = uid(2);
let b = uid(3);
add_group(
&mut pool,
group_id,
Uuid::nil(),
(0.0, 0.0, 200.0, 100.0),
&[a, b],
);
{
let group = pool.get_mut(&group_id).unwrap();
group.set_blur(Some(Blur::new(BlurType::LayerBlur, false, 6.0)));
}
add_solid_rect(
&mut pool,
a,
group_id,
(0.0, 0.0, 90.0, 100.0),
skia::Color::from_rgb(0, 0, 255),
);
add_solid_rect(
&mut pool,
b,
group_id,
(110.0, 0.0, 200.0, 100.0),
skia::Color::from_rgb(0, 200, 0),
);
let svg = render(&pool, group_id);
let expected_sigma = radius_to_sigma(6.0);
assert!(
svg.contains(&format!("stdDeviation=\"{expected_sigma}\"")),
"group layer blur stdDeviation: {svg}"
);
// Filter wrapper must open before child geometry.
let filter_pos = svg
.find("filter=\"url(#blur")
.expect("group filter wrapper");
let child_pos = svg.find("fill=\"#").expect("child fill");
assert!(
filter_pos < child_pos,
"group blur must wrap children: {svg}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn exports_a_group_with_two_rects_and_group_opacity() {
let mut pool = ShapesPool::new();
@@ -1257,3 +1368,239 @@ fn exports_image_fill_on_frame() {
);
insta::assert_snapshot!(svg);
}
fn assert_linked_image_stroke(svg: &str) {
assert!(
svg.contains("<image") && svg.contains(TEST_IMAGE_URL),
"image stroke must emit a linked <image>: {svg}"
);
assert!(
svg.contains("imgstrokeclip") && svg.contains("clip-path=\"url(#"),
"image stroke must clip to the stroke outline: {svg}"
);
assert!(
!svg.contains("data:image"),
"must not base64-embed the stroke image: {svg}"
);
}
fn assert_evenodd_stroke_clip(svg: &str) {
assert!(
svg.contains("clip-rule=\"evenodd\""),
"stroke clip must use clip-rule=evenodd: {svg}"
);
assert!(
!svg.contains("fill-rule=\"evenodd\""),
"clipPath should rewrite fill-rule to clip-rule: {svg}"
);
}
#[test]
fn exports_rect_with_solid_center_image_stroke() {
let mut pool = ShapesPool::new();
let id = uid(1);
let image_id = uid(42);
add_stroked_rect(
&mut pool,
id,
Uuid::nil(),
(10.0, 10.0, 110.0, 90.0),
image_solid_stroke(StrokeKind::Center, 8.0, image_id),
);
let svg = render_with(&pool, id, |resources| {
resources
.images
.set_source_url(image_id, TEST_IMAGE_URL.to_string());
});
assert_linked_image_stroke(&svg);
assert_evenodd_stroke_clip(&svg);
insta::assert_snapshot!(svg);
}
#[test]
fn exports_rect_with_solid_inner_image_stroke() {
let mut pool = ShapesPool::new();
let id = uid(1);
let image_id = uid(42);
add_stroked_rect(
&mut pool,
id,
Uuid::nil(),
(0.0, 0.0, 100.0, 80.0),
image_solid_stroke(StrokeKind::Inner, 10.0, image_id),
);
let svg = render_with(&pool, id, |resources| {
resources
.images
.set_source_url(image_id, TEST_IMAGE_URL.to_string());
});
assert_linked_image_stroke(&svg);
assert_evenodd_stroke_clip(&svg);
insta::assert_snapshot!(svg);
}
#[test]
fn exports_rect_with_solid_outer_image_stroke() {
let mut pool = ShapesPool::new();
let id = uid(1);
let image_id = uid(42);
add_stroked_rect(
&mut pool,
id,
Uuid::nil(),
(20.0, 20.0, 120.0, 100.0),
image_solid_stroke(StrokeKind::Outer, 10.0, image_id),
);
let svg = render_with(&pool, id, |resources| {
resources
.images
.set_source_url(image_id, TEST_IMAGE_URL.to_string());
});
assert_linked_image_stroke(&svg);
assert_evenodd_stroke_clip(&svg);
insta::assert_snapshot!(svg);
}
#[test]
fn exports_rect_with_dotted_center_image_stroke() {
let mut pool = ShapesPool::new();
let id = uid(1);
let image_id = uid(42);
add_stroked_rect(
&mut pool,
id,
Uuid::nil(),
(10.0, 10.0, 110.0, 90.0),
image_dotted_stroke(StrokeKind::Center, 8.0, image_id),
);
let svg = render_with(&pool, id, |resources| {
resources
.images
.set_source_url(image_id, TEST_IMAGE_URL.to_string());
});
assert_linked_image_stroke(&svg);
assert_evenodd_stroke_clip(&svg);
insta::assert_snapshot!(svg);
}
#[test]
fn exports_closed_path_with_solid_outer_image_stroke() {
let mut pool = ShapesPool::new();
let id = uid(1);
let image_id = uid(42);
add_stroked_closed_path(
&mut pool,
id,
Uuid::nil(),
(0.0, 0.0, 100.0, 80.0),
image_solid_stroke(StrokeKind::Outer, 8.0, image_id),
);
let svg = render_with(&pool, id, |resources| {
resources
.images
.set_source_url(image_id, TEST_IMAGE_URL.to_string());
});
assert_linked_image_stroke(&svg);
assert_evenodd_stroke_clip(&svg);
insta::assert_snapshot!(svg);
}
#[test]
fn exports_open_path_with_solid_center_image_stroke() {
let mut pool = ShapesPool::new();
let id = uid(1);
let image_id = uid(42);
add_stroked_open_path(
&mut pool,
id,
Uuid::nil(),
(0.0, 0.0, 100.0, 80.0),
image_solid_stroke(StrokeKind::Center, 8.0, image_id),
);
let svg = render_with(&pool, id, |resources| {
resources
.images
.set_source_url(image_id, TEST_IMAGE_URL.to_string());
});
assert_linked_image_stroke(&svg);
assert_evenodd_stroke_clip(&svg);
insta::assert_snapshot!(svg);
}
#[test]
fn exports_open_path_with_image_stroke_and_caps() {
// Caps go into the clip silhouette with the outline. Image dest must grow
// past stroke.delta() so triangle/circle markers stay textured.
let mut pool = ShapesPool::new();
let id = uid(1);
let image_id = uid(42);
let mut stroke = image_solid_stroke(StrokeKind::Center, 12.0, image_id);
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_with(&pool, id, |resources| {
resources
.images
.set_source_url(image_id, TEST_IMAGE_URL.to_string());
});
assert_linked_image_stroke(&svg);
assert_evenodd_stroke_clip(&svg);
let clip = svg
.split("<clipPath")
.nth(1)
.and_then(|s| s.split("</clipPath>").next())
.expect("imgstroke clipPath");
assert!(
clip.matches("<path ").count() >= 2
|| clip.contains("<circle")
|| clip.contains("<ellipse"),
"clip must include cap geometry besides the stroke outline: {svg}"
);
// TriangleArrow margin is width*4 = 48.
assert!(
svg.contains(r#"x="-48""#)
&& svg.contains(r#"y="-48""#)
&& svg.contains(r#"width="236""#)
&& svg.contains(r#"height="186""#),
"image dest must cover cap margin (48), not only stroke.delta: {svg}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn exports_closed_path_with_dotted_outer_image_stroke() {
let mut pool = ShapesPool::new();
let id = uid(1);
let image_id = uid(42);
add_stroked_closed_path(
&mut pool,
id,
Uuid::nil(),
(0.0, 0.0, 100.0, 80.0),
image_dotted_stroke(StrokeKind::Outer, 8.0, image_id),
);
let svg = render_with(&pool, id, |resources| {
resources
.images
.set_source_url(image_id, TEST_IMAGE_URL.to_string());
});
assert_linked_image_stroke(&svg);
assert_evenodd_stroke_clip(&svg);
insta::assert_snapshot!(svg);
}
+17 -2
View File
@@ -358,8 +358,19 @@ pub fn try_paint_from_layout_cache(
fn paint_from_cached_layout(canvas: &Canvas, shape: &Shape, text_content: &TextContent) {
let selrect = shape.selrect();
let x = selrect.x();
let base_y = selrect.y();
// Absolute image/gradient shaders were baked at `layout_paint_origin`. Paint
// glyphs in that space and translate the canvas so both track selrect moves
// without rebuilding Skia paragraphs.
let anchor = text_content.cached_layout_paint_anchor(&selrect);
let offset = text_content.cached_layout_paint_offset(&selrect);
let needs_translate = offset.x.abs() > f32::EPSILON || offset.y.abs() > f32::EPSILON;
if needs_translate {
canvas.save();
canvas.translate((offset.x, offset.y));
}
let x = anchor.x;
let base_y = anchor.y;
let paragraphs = &text_content.layout.paragraphs;
let draw_decorations = text_content.has_text_decorations();
@@ -389,6 +400,10 @@ fn paint_from_cached_layout(canvas: &Canvas, shape: &Shape, text_content: &TextC
}
y_accum += paragraph.height();
}
if needs_translate {
canvas.restore();
}
}
#[allow(clippy::too_many_arguments)]
+148 -48
View File
@@ -44,6 +44,18 @@ impl<'a> VectorRenderer<'a> {
compose_fills,
}
}
/// Layer-blur paint filter for this backend.
///
/// SVG export (`compose_fills == false`) returns `None`: `SkSVGDevice` drops
/// paint image-filters (the shape would vanish). Layer blur is re-emitted as
/// a native SVG `<filter>` wrapper instead.
fn layer_blur_filter(&self, shape: &Shape) -> Option<skia::ImageFilter> {
if !self.compose_fills {
return None;
}
shape.image_filter(1.)
}
}
impl ShapeRenderer for VectorRenderer<'_> {
@@ -52,17 +64,24 @@ impl ShapeRenderer for VectorRenderer<'_> {
return Ok(());
}
let blur_filter = self.layer_blur_filter(shape);
let has_image_fills = fills.iter().any(|f| matches!(f, Fill::Image(_)));
if !self.compose_fills || has_image_fills {
// fills[0] is the topmost layer; draw bottom → top (matches GPU + classic SVG).
for fill in fills.iter().rev() {
match fill {
Fill::Image(image_fill) => {
draw_image_fill(self.shared, self.canvas, shape, image_fill)?;
draw_image_fill(
self.shared,
self.canvas,
shape,
image_fill,
blur_filter.as_ref(),
)?;
}
_ => {
let mut paint = fill.to_paint(&shape.selrect, true);
if let Some(filter) = shape.image_filter(1.) {
if let Some(filter) = blur_filter.clone() {
paint.set_image_filter(filter);
}
draw_shape_geometry(self.canvas, shape, &paint);
@@ -75,7 +94,7 @@ impl ShapeRenderer for VectorRenderer<'_> {
let mut paint = merge_fills(fills, shape.selrect);
paint.set_anti_alias(true);
if let Some(filter) = shape.image_filter(1.) {
if let Some(filter) = blur_filter {
paint.set_image_filter(filter);
}
@@ -124,7 +143,7 @@ impl ShapeRenderer for VectorRenderer<'_> {
}
let layer_bounds = shape.layer_bounds();
for shadow in shape.inner_shadows_visible() {
let paint = shadow.get_inner_shadow_paint(true, shape.image_filter(1.).as_ref());
let paint = shadow.get_inner_shadow_paint(true, self.layer_blur_filter(shape).as_ref());
self.canvas.save_layer(
&skia::canvas::SaveLayerRec::default()
.bounds(&layer_bounds)
@@ -163,7 +182,7 @@ impl ShapeRenderer for VectorRenderer<'_> {
let text_content = text_content.new_bounds(shape.selrect());
let mut paragraph_builders = text_content.paragraph_builder_group_from_text(None);
let blur_filter = shape.image_filter(1.);
let blur_filter = self.layer_blur_filter(shape);
// Text drop shadows: one filter layer per shadow over fill + stroke
// silhouettes (mirrors GPU `render_text_shadows`).
@@ -353,6 +372,9 @@ impl ShapeRenderer for VectorRenderer<'_> {
}
fn apply_blur_layer(&mut self, shape: &Shape) -> bool {
if !self.compose_fills {
return false;
}
let blur = match shape.blur {
Some(b) if !b.hidden && b.blur_type == BlurType::LayerBlur && b.value > 0.0 => b,
_ => return false,
@@ -1033,6 +1055,7 @@ fn draw_image_fill(
canvas: &Canvas,
shape: &Shape,
image_fill: &crate::shapes::ImageFill,
blur_filter: Option<&skia::ImageFilter>,
) -> Result<()> {
// Use a CPU-backed image copy — GPU-backed images can't be drawn
// on the PDF canvas which has no GPU context.
@@ -1053,8 +1076,8 @@ fn draw_image_fill(
let mut paint = Paint::default();
paint.set_anti_alias(true);
if let Some(filter) = shape.image_filter(1.) {
paint.set_image_filter(filter);
if let Some(filter) = blur_filter {
paint.set_image_filter(filter.clone());
}
canvas.draw_image_rect_with_sampling_options(
@@ -1093,13 +1116,49 @@ fn draw_single_stroke(
Ok(())
}
/// Shape path in local coords for SVG stroke outline expansion.
fn svg_stroke_shape_path(shape: &Shape) -> Option<Path> {
match &shape.shape_type {
Type::Rect(r) => Some(Path::new(rect_segments_local(shape, r.corners))),
Type::Frame(f) => Some(Path::new(rect_segments_local(shape, f.corners))),
Type::Circle => Some(Path::new(circle_segments_local(shape))),
Type::Path(_) | Type::Bool(_) => {
let path = shape.shape_type.path()?;
let mut local = path.clone();
if let Some(t) = shape.to_path_transform() {
local.transform(&t);
}
Some(local)
}
Type::Text(_) | Type::SVGRaw(_) | Type::Group(_) => None,
}
}
fn svg_stroke_solid_outline(stroke: &Stroke, is_open: bool) -> Option<bool> {
let kind = stroke.render_kind(is_open);
match stroke.style {
StrokeStyle::Solid => match kind {
// Solid Center already serializes as a native SVG stroke.
StrokeKind::Center => None,
StrokeKind::Inner | StrokeKind::Outer => {
if is_open {
None
} else {
Some(true)
}
}
},
// PathEffects (path_1d / dash) do not survive SkSVGDevice; expand them.
StrokeStyle::Dotted | StrokeStyle::Dashed | StrokeStyle::Mixed => Some(false),
}
}
/// 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.
@@ -1109,41 +1168,12 @@ fn draw_svg_stroke_as_fill(canvas: &Canvas, shape: &Shape, stroke: &Stroke) -> b
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,
let Some(solid_outline) = svg_stroke_solid_outline(stroke, is_open) else {
return 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(shape_path) = svg_stroke_shape_path(shape) else {
return false;
};
let Some(outline) = stroke_to_path(
@@ -1166,18 +1196,88 @@ fn draw_svg_stroke_as_fill(canvas: &Canvas, shape: &Shape, stroke: &Stroke) -> b
// 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,
);
}
paint_svg_stroke_caps(canvas, shape, stroke, false);
}
true
}
/// Opaque stroke region for SVG clipPath silhouettes.
///
/// Expands every alignment (including solid Center) to a filled outline so we
/// do not rely on save_layer + SrcIn. Returns false when there is nothing to draw.
pub(super) fn paint_svg_stroke_silhouette(
canvas: &Canvas,
shape: &Shape,
stroke: &Stroke,
scale: f32,
) -> bool {
let is_open = shape.is_open();
if stroke.per_side_widths().is_some()
&& matches!(shape.shape_type, Type::Rect(_) | Type::Frame(_))
{
let corners = shape.shape_type.corners();
let mut paint = stroke.to_paint(&shape.selrect, shape.svg_attrs.as_ref(), true);
paint.set_shader(None);
paint.set_color(skia::Color::BLACK);
super::strokes::draw_stroke_on_rect(
canvas,
stroke,
&shape.selrect,
&corners,
&paint,
scale,
None,
None,
true,
);
return true;
}
let Some(shape_path) = svg_stroke_shape_path(shape) else {
return false;
};
// Expand Center too: a native stroke attribute cannot clip an image.
let solid_outline = matches!(stroke.style, StrokeStyle::Solid);
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 = Paint::default();
paint.set_style(skia::PaintStyle::Fill);
paint.set_anti_alias(true);
paint.set_color(skia::Color::BLACK);
canvas.draw_path(&outline.to_skia_path(shape.svg_attrs.as_ref()), &paint);
if is_open {
paint_svg_stroke_caps(canvas, shape, stroke, true);
}
true
}
fn paint_svg_stroke_caps(canvas: &Canvas, shape: &Shape, stroke: &Stroke, opaque: bool) {
let Some(cap_path) = transformed_skia_path(shape) else {
return;
};
let mut cap_paint =
stroke.to_stroked_paint(true, &shape.selrect, shape.svg_attrs.as_ref(), true);
if opaque {
cap_paint.set_shader(None);
cap_paint.set_color(skia::Color::BLACK);
}
super::strokes::handle_stroke_caps(&cap_path, stroke, canvas, true, &cap_paint, None, 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.
+53 -1
View File
@@ -403,8 +403,8 @@ impl Shape {
self.invalidate_extrect();
self.selrect.set_ltrb(left, top, right, bottom);
if let Type::Text(ref mut text) = self.shape_type {
// `update_layout` syncs bounds via set_xywh before baking fill paints.
text.update_layout(self.selrect);
text.set_xywh(left, top, self.selrect.width(), self.selrect.height());
}
}
@@ -652,6 +652,13 @@ impl Shape {
self.background_blur.filter(|blur| !blur.hidden)
}
/// Visible layer blur (`!hidden`, `LayerBlur`, `value > 0`).
pub fn visible_layer_blur(&self) -> Option<Blur> {
self.blur.filter(|blur| {
!blur.hidden && blur.blur_type == BlurType::LayerBlur && blur.value > 0.0
})
}
#[cfg(test)]
pub fn add_child(&mut self, id: Uuid) {
self.children.push(id);
@@ -2059,6 +2066,51 @@ mod tests {
assert_eq!(shape.visible_background_blur(), None);
}
/// Cases mirrored from Penpot MCP board `layer-blur-cases` (file "blur"):
/// leaf-visible-blur, leaf-hidden-blur, leaf-zero-blur, leaf-background-blur,
/// group-with-blur.
#[test]
fn visible_layer_blur_requires_non_hidden_positive_layer_blur() {
let mut shape = any_shape();
// leaf-visible-blur / group-with-blur
let visible = Blur::new(BlurType::LayerBlur, false, 10.0);
shape.set_blur(Some(visible));
assert_eq!(shape.visible_layer_blur(), Some(visible));
let group_blur = Blur::new(BlurType::LayerBlur, false, 6.0);
shape.set_blur(Some(group_blur));
assert_eq!(shape.visible_layer_blur(), Some(group_blur));
// leaf-hidden-blur
shape.set_blur(Some(Blur::new(BlurType::LayerBlur, true, 10.0)));
assert_eq!(shape.visible_layer_blur(), None);
// leaf-zero-blur
shape.set_blur(Some(Blur::new(BlurType::LayerBlur, false, 0.0)));
assert_eq!(shape.visible_layer_blur(), None);
// no blur
shape.set_blur(None);
assert_eq!(shape.visible_layer_blur(), None);
}
#[test]
fn visible_layer_blur_ignores_background_blur() {
let mut shape = any_shape();
// leaf-background-blur: Plugin API uses `backgroundBlur`, not `blur`.
shape.set_background_blur(Some(Blur::new(BlurType::BackgroundBlur, false, 8.0)));
assert_eq!(shape.visible_layer_blur(), None);
assert_eq!(
shape.visible_background_blur(),
Some(Blur::new(BlurType::BackgroundBlur, false, 8.0))
);
let layer = Blur::new(BlurType::LayerBlur, false, 4.0);
shape.set_blur(Some(layer));
assert_eq!(shape.visible_layer_blur(), Some(layer));
}
#[test]
fn test_set_corners() {
let mut shape = any_shape();
+112
View File
@@ -420,6 +420,10 @@ pub struct TextContent {
content_version: u64,
layout_version: u64,
layout_width: Option<f32>,
/// Canvas origin used when absolute fill shaders (image/gradient) were baked
/// into cached Skia paragraphs. Kept across move clones so paint can
/// translate glyphs + shaders together. See `cached_layout_paint_offset`.
layout_paint_origin: Option<Point>,
}
impl PartialEq for TextContent {
@@ -443,6 +447,7 @@ impl TextContent {
content_version: 0,
layout_version: 0,
layout_width: None,
layout_paint_origin: None,
}
}
@@ -458,6 +463,7 @@ impl TextContent {
content_version: 0,
layout_version: 0,
layout_width: None,
layout_paint_origin: None,
}
}
@@ -465,6 +471,23 @@ impl TextContent {
self.bounds
}
/// Anchor used when painting from the layout cache. Absolute image/gradient
/// shaders were built in this coordinate space; paint glyphs here and apply
/// [`cached_layout_paint_offset`] on the canvas so both move together.
pub fn cached_layout_paint_anchor(&self, selrect: &Rect) -> Point {
self.layout_paint_origin
.unwrap_or_else(|| Point::new(selrect.x(), selrect.y()))
}
/// Canvas translation from the baked paint origin to the current selrect.
/// Zero when there is no recorded origin (fall back to painting at selrect).
pub fn cached_layout_paint_offset(&self, selrect: &Rect) -> Point {
match self.layout_paint_origin {
Some(origin) => Point::new(selrect.x() - origin.x, selrect.y() - origin.y),
None => Point::new(0.0, 0.0),
}
}
/// Text content for paint when [`Rect`] size may differ from stored bounds
/// (e.g. modifier transform). Reuses `self` when width/height match; otherwise
/// clones paragraphs into a rebound copy with an empty layout cache.
@@ -1130,10 +1153,14 @@ impl TextContent {
self.layout.set(result.0, result.1);
self.size
.copy_finite_size(result.2, default_width, default_height);
// Paragraph paints (incl. absolute image/gradient shaders) were built
// against `self.bounds()` in `paragraph_builder_group_from_text`.
self.layout_paint_origin = Some(Point::new(self.bounds.x(), self.bounds.y()));
}
pub fn force_next_layout_update(&mut self) {
self.layout_width = None;
self.layout_paint_origin = None;
self.layout.cached_extrect.set(None);
// Bump the content version so update_layout can't early-return: auto-width
// shapes always match their container and clearing the cache above doesn't
@@ -1142,6 +1169,10 @@ impl TextContent {
}
pub fn update_layout(&mut self, selrect: Rect) -> TextContentSize {
// Keep bounds in sync before building paints so absolute fill shaders
// match the container we are laying out for.
self.set_xywh(selrect.x(), selrect.y(), selrect.width(), selrect.height());
// Auto-width ignores selrect width so get-text-dimensions can reuse the cached layout.
let layout_matches_container = self.grow_type() == GrowType::AutoWidth
|| self
@@ -1319,6 +1350,7 @@ impl Default for TextContent {
content_version: 0,
layout_version: 0,
layout_width: None,
layout_paint_origin: None,
}
}
}
@@ -2225,6 +2257,86 @@ mod tests {
}
}
#[test]
fn layout_paint_origin_set_when_layout_result_applied() {
let mut content = sample_text_content();
content.set_xywh(40.0, 60.0, 200.0, 100.0);
let empty =
TextContentLayoutResult(vec![], vec![], TextContentSize::new_with_size(200.0, 100.0));
content.set_layout_from_result(empty, 200.0, 100.0);
let selrect = Rect::from_xywh(40.0, 60.0, 200.0, 100.0);
assert_eq!(
content.cached_layout_paint_anchor(&selrect),
Point::new(40.0, 60.0)
);
assert_eq!(
content.cached_layout_paint_offset(&selrect),
Point::new(0.0, 0.0)
);
}
#[test]
fn cached_layout_paint_offset_tracks_selrect_move() {
let mut content = sample_text_content();
content.layout_paint_origin = Some(Point::new(10.0, 20.0));
// Simulate a move clone: bounds follow the new selrect, origin stays.
content.set_xywh(110.0, 220.0, 200.0, 100.0);
let selrect = Rect::from_xywh(110.0, 220.0, 200.0, 100.0);
let offset = content.cached_layout_paint_offset(&selrect);
assert_eq!(offset, Point::new(100.0, 200.0));
assert_eq!(
content.cached_layout_paint_anchor(&selrect),
Point::new(10.0, 20.0)
);
}
#[test]
fn cached_layout_paint_offset_zero_without_origin() {
let content = sample_text_content();
let selrect = Rect::from_xywh(50.0, 75.0, 200.0, 100.0);
assert_eq!(
content.cached_layout_paint_offset(&selrect),
Point::new(0.0, 0.0)
);
assert_eq!(
content.cached_layout_paint_anchor(&selrect),
Point::new(50.0, 75.0)
);
}
#[test]
fn layout_paint_origin_survives_bounds_transform_on_clone() {
let mut content = sample_text_content();
content.set_xywh(10.0, 20.0, 200.0, 100.0);
content.layout_paint_origin = Some(Point::new(10.0, 20.0));
content.layout.paragraphs = Rc::new(vec![vec![]]);
content.layout_width = Some(200.0);
content.layout_version = 1;
content.content_version = 1;
let mut moved = content.clone();
let mut move_matrix = Matrix::new_identity();
move_matrix.set_translate_x(50.0);
move_matrix.set_translate_y(30.0);
moved.transform(&move_matrix);
assert_eq!(moved.bounds().x(), 60.0);
assert_eq!(moved.bounds().y(), 50.0);
assert!(Rc::ptr_eq(
&content.layout.paragraphs,
&moved.layout.paragraphs
));
let selrect = Rect::from_xywh(60.0, 50.0, 200.0, 100.0);
assert_eq!(
moved.cached_layout_paint_anchor(&selrect),
Point::new(10.0, 20.0)
);
assert_eq!(
moved.cached_layout_paint_offset(&selrect),
Point::new(50.0, 30.0)
);
}
#[test]
fn layout_clone_shares_skia_paragraphs() {
let mut layout = TextContentLayout::new();