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

This commit is contained in:
Alejandro Alonso 2026-04-07 10:18:35 +02:00
commit 83833896c9
22 changed files with 632 additions and 204 deletions

View File

@ -40,6 +40,13 @@ export class WasmWorkspacePage extends WorkspacePage {
this.canvas = page.getByTestId("canvas-wasm-shapes");
}
async waitForIdle(options) {
return this.page.evaluate(
(options) => new Promise((resolve) => globalThis.requestIdleCallback(resolve, options)),
options
);
}
async waitForFirstRender() {
await this.pageName.waitFor();
await this.canvas.waitFor();

View File

@ -53,19 +53,18 @@ export class WorkspacePage extends BaseWebSocketPage {
for (let i = 0; i < amount; i++) {
await this.page.keyboard.press("ArrowLeft");
}
await this.waitForIdle();
await this.waitForIdle({ timeout: 100 });
}
async moveToRight(amount = 0) {
for (let i = 0; i < amount; i++) {
await this.page.keyboard.press("ArrowRight");
}
await this.waitForIdle();
await this.waitForIdle({ timeout: 100 });
}
async moveFromStart(offset = 0) {
await this.page.keyboard.press("Home");
await this.waitForIdle();
await this.moveToRight(offset);
}
@ -107,10 +106,10 @@ export class WorkspacePage extends BaseWebSocketPage {
return this.changeNumericInput(this.letterSpacing, newValue);
}
async waitForIdle() {
async waitForIdle(options) {
await this.page.evaluate(
() => new Promise((resolve) => globalThis.requestIdleCallback(resolve)),
);
(options) => new Promise(
(resolve) => globalThis.requestIdleCallback(resolve, options)), options);
}
};

View File

@ -96,7 +96,6 @@ test("Update an already created text shape by prepending text", async ({
await workspace.clickLeafLayer("Lorem ipsum");
await workspace.textEditor.startEditing();
await workspace.textEditor.moveFromStart(0);
await page.evaluate(() => new Promise((resolve) => globalThis.requestIdleCallback(resolve)));
await page.keyboard.type("Dolor sit amet ");
await workspace.textEditor.stopEditing();
await workspace.waitForSelectedShapeName("Dolor sit amet Lorem ipsum");

View File

@ -62,9 +62,9 @@
[]
(ptk/reify ::initialize-rasterizer
ptk/EffectEvent
(effect [_ state _]
(when-not (feat/active-feature? state "render-wasm/v1")
(thr/init!)))))
(effect [_ _ _]
;; The rasterizer is used for the dashboard thumbnails
(thr/init!))))
(defn initialize
[]

View File

@ -7,12 +7,14 @@
(ns app.main.data.plugins
(:require
[app.common.data.macros :as dm]
[app.common.exceptions :as ex]
[app.common.files.changes-builder :as pcb]
[app.common.time :as ct]
[app.main.data.changes :as dch]
[app.main.data.event :as ev]
[app.main.data.modal :as modal]
[app.main.data.notifications :as ntf]
[app.main.errors :as errors]
[app.main.store :as st]
[app.plugins.flags :as pflag]
[app.plugins.register :as preg]
@ -20,7 +22,8 @@
[app.util.http :as http]
[app.util.i18n :as i18n :refer [tr]]
[beicon.v2.core :as rx]
[potok.v2.core :as ptk]))
[potok.v2.core :as ptk]
[promesa.core :as p]))
(defn save-plugin-permissions-peek
[id permissions]
@ -54,40 +57,45 @@
(defn start-plugin!
[{:keys [plugin-id name version description host code permissions allow-background]} ^js extensions]
(.ɵloadPlugin
^js ug/global
#js {:pluginId plugin-id
:name name
:version version
:description description
:host host
:code code
:allowBackground (boolean allow-background)
:permissions (apply array permissions)}
nil
extensions))
(-> (.ɵloadPlugin
^js ug/global
#js {:pluginId plugin-id
:name name
:version version
:description description
:host host
:code code
:allowBackground (boolean allow-background)
:permissions (apply array permissions)}
nil
extensions)
(p/catch (fn [cause]
(ex/print-throwable cause :prefix "Plugin Error")
(errors/flash :cause cause :type :handled)))))
(defn- load-plugin!
[{:keys [plugin-id name version description host code icon permissions]}]
(try
(st/emit! (pflag/clear plugin-id)
(save-current-plugin plugin-id))
(st/emit! (pflag/clear plugin-id)
(save-current-plugin plugin-id))
(.ɵloadPlugin ^js ug/global
#js {:pluginId plugin-id
:name name
:description description
:version version
:host host
:code code
:icon icon
:permissions (apply array permissions)}
(fn []
(st/emit! (remove-current-plugin plugin-id))))
(-> (.ɵloadPlugin
^js ug/global
#js {:pluginId plugin-id
:name name
:description description
:version version
:host host
:code code
:icon icon
:permissions (apply array permissions)}
(fn []
(st/emit! (remove-current-plugin plugin-id))))
(catch :default e
(st/emit! (remove-current-plugin plugin-id))
(.error js/console "Error" e))))
(p/catch (fn [cause]
(st/emit! (remove-current-plugin plugin-id))
(ex/print-throwable cause :prefix "Plugin Error")
(errors/flash :cause cause :type :handled)))))
(defn open-plugin!
[{:keys [url] :as manifest} user-can-edit?]

View File

@ -33,6 +33,16 @@
;; Will contain last uncaught exception
(def last-exception nil)
(defn is-plugin-error?
"This is a placeholder that always return false. It will be
overwritten when plugin system is initialized. This works this way
because we can't import plugins here because plugins requries full
DOM.
This placeholder is set on app.plugins/initialize event"
[_]
false)
;; --- Stale-asset error detection and auto-reload
;;
;; When the browser loads JS modules from different builds (e.g. shared.js from
@ -387,6 +397,15 @@
(and (string? stack)
(str/includes? stack "posthog"))))
;; Check if the error is marked as originating from plugin code.
;; The plugin runtime tracks plugin errors in a WeakMap, which works
;; even in SES hardened environments where error objects may be frozen.
(from-plugin? [cause]
(try
(is-plugin-error? cause)
(catch :default _
false)))
(is-ignorable-exception? [cause]
(let [message (ex-message cause)]
(or (from-extension? cause)
@ -405,32 +424,56 @@
(on-unhandled-error [event]
(.preventDefault ^js event)
(when-let [cause (unchecked-get event "error")]
(when-not (is-ignorable-exception? cause)
(if (stale-asset-error? cause)
(cf/throttled-reload :reason (ex-message cause))
(let [data (ex-data cause)
type (get data :type)]
(set! last-exception cause)
(if (= :wasm-error type)
(on-error cause)
(do
(ex/print-throwable cause :prefix "Uncaught Exception")
(ts/asap #(flash :cause cause :type :unhandled)))))))))
(cond
(stale-asset-error? cause)
(cf/throttled-reload :reason (ex-message cause))
;; Plugin errors: log to console and ignore
(from-plugin? cause)
(ex/print-throwable cause :prefix "Plugin Error")
;; Other ignorable exceptions: ignore silently
(is-ignorable-exception? cause)
nil
;; All other errors: show exception page
:else
(let [data (ex-data cause)
type (get data :type)]
(set! last-exception cause)
(if (= :wasm-error type)
(on-error cause)
(do
(ex/print-throwable cause :prefix "Uncaught Exception")
(ts/asap #(flash :cause cause :type :unhandled))))))))
(on-unhandled-rejection [event]
(.preventDefault ^js event)
(when-let [cause (unchecked-get event "reason")]
(when-not (is-ignorable-exception? cause)
(if (stale-asset-error? cause)
(cf/throttled-reload :reason (ex-message cause))
(let [data (ex-data cause)
type (get data :type)]
(set! last-exception cause)
(if (= :wasm-error type)
(on-error cause)
(do
(ex/print-throwable cause :prefix "Uncaught Rejection")
(ts/asap #(flash :cause cause :type :unhandled)))))))))]
(cond
(stale-asset-error? cause)
(cf/throttled-reload :reason (ex-message cause))
;; Plugin errors: log to console and ignore
(from-plugin? cause)
(ex/print-throwable cause :prefix "Plugin Error")
;; Other ignorable exceptions: ignore silently
(is-ignorable-exception? cause)
nil
;; All other errors: show exception page
:else
(let [data (ex-data cause)
type (get data :type)]
(set! last-exception cause)
(if (= :wasm-error type)
(on-error cause)
(do
(ex/print-throwable cause :prefix "Uncaught Rejection")
(ts/asap #(flash :cause cause :type :unhandled))))))))]
(.addEventListener g/window "error" on-unhandled-error)
(.addEventListener g/window "unhandledrejection" on-unhandled-rejection)

View File

@ -370,7 +370,8 @@
(st/emit! (dwt/v2-update-text-shape-content (first ids) content :finalize? true)))))
(st/emit! (dwsh/update-shapes ids #(assoc % :grow-type grow-type)))
(when (features/active-feature? @st/state "render-wasm/v1")
(st/emit! (dwwt/resize-wasm-text-all ids)))
(st/emit! (dwwt/resize-wasm-text-all ids)
(ptk/data-event :layout/update {:ids ids})))
(ts/schedule #(st/emit! (dwu/commit-undo-transaction uid))))))
handle-detach-typography

View File

@ -8,6 +8,7 @@
"RPC for plugins runtime."
(:require
["@penpot/plugins-runtime" :as runtime]
[app.main.errors :as errors]
[app.main.features :as features]
[app.main.store :as st]
[app.plugins.api :as api]
@ -30,6 +31,8 @@
(ptk/reify ::initialize
ptk/WatchEvent
(watch [_ _ stream]
(set! errors/is-plugin-error? runtime/isPluginError)
(->> stream
(rx/filter (ptk/type? ::features/initialize))
(rx/observe-on :async)

View File

@ -278,19 +278,30 @@ export class SelectionController extends EventTarget {
// FIXME: I don't like this approximation. Having to iterate nodes twice
// is bad for performance. I think we need another way of "computing"
// the cascade.
for (const textNode of this.#textNodeIterator.iterateFrom(
startNode,
endNode,
)) {
const textNodesInRange = [
...this.#textNodeIterator.iterateFrom(startNode, endNode),
];
for (const textNode of textNodesInRange) {
const paragraph = textNode.parentElement.parentElement;
this.#applyStylesFromElementToCurrentStyle(paragraph);
}
for (const textNode of this.#textNodeIterator.iterateFrom(
startNode,
endNode,
)) {
const textSpan = textNode.parentElement;
this.#mergeStylesFromElementToCurrentStyle(textSpan);
// Empty trailing text runs (length 0) often carry paragraph fallback styles
// (e.g. font-size 0) and are not user-visible; merging them with real text
// yields false "mixed" in the sidebar. Skip empty nodes when the selection
// also includes non-empty text; if everything is empty, keep prior behavior.
const nonEmptyTextNodes = textNodesInRange.filter(
(textNode) => textNode.length > 0,
);
const spanMergeNodes =
nonEmptyTextNodes.length > 0 ? nonEmptyTextNodes : textNodesInRange;
if (spanMergeNodes.length > 0) {
const firstTextSpan = spanMergeNodes[0].parentElement;
this.#applyStylesFromElementToCurrentStyle(firstTextSpan);
for (let i = 1; i < spanMergeNodes.length; i++) {
const textSpan = spanMergeNodes[i].parentElement;
this.#mergeStylesFromElementToCurrentStyle(textSpan);
}
}
}
return this;

View File

@ -4,7 +4,7 @@ import {
createParagraph,
createParagraphWith,
} from "../content/dom/Paragraph.js";
import { createTextSpan } from "../content/dom/TextSpan.js";
import { createTextSpan, createVoidTextSpan } from "../content/dom/TextSpan.js";
import { createLineBreak } from "../content/dom/LineBreak.js";
import { TextEditorMock } from "../../test/TextEditorMock.js";
import { SelectionController } from "./SelectionController.js";
@ -1683,6 +1683,54 @@ describe("SelectionController", () => {
expect(selectionController.focusAtEnd).toBeTruthy();
})
test("`currentStyle` ignores empty text nodes when merging span styles (no false mixed font-size)", () => {
const textEditorMock = TextEditorMock.createTextEditorMockWithParagraphs([
createParagraph([
createTextSpan(new Text("Hello"), { "font-size": "50" }),
createVoidTextSpan({ "font-size": "0" }),
]),
]);
const root = textEditorMock.root;
const paragraph = root.firstChild;
const firstTextNode = paragraph.firstChild.firstChild;
const lastTextNode = paragraph.firstChild.nextSibling.firstChild;
const selection = document.getSelection();
const selectionController = new SelectionController(
textEditorMock,
selection,
);
textEditorMock.element.focus();
selection.setBaseAndExtent(firstTextNode, 0, lastTextNode, 0);
document.dispatchEvent(new Event("selectionchange"));
expect(selectionController.currentStyle.getPropertyValue("font-size")).toBe(
"50px",
);
});
test("`currentStyle` stays mixed when two non-empty spans have different font sizes", () => {
const textEditorMock = TextEditorMock.createTextEditorMockWithParagraphs([
createParagraph([
createTextSpan(new Text("Hello"), { "font-size": "50" }),
createTextSpan(new Text("World"), { "font-size": "36" }),
]),
]);
const root = textEditorMock.root;
const paragraph = root.firstChild;
const firstTextNode = paragraph.firstChild.firstChild;
const lastTextNode = paragraph.firstChild.nextSibling.firstChild;
const selection = document.getSelection();
const selectionController = new SelectionController(
textEditorMock,
selection,
);
textEditorMock.element.focus();
selection.setBaseAndExtent(firstTextNode, 0, lastTextNode, 5);
document.dispatchEvent(new Event("selectionchange"));
expect(selectionController.currentStyle.getPropertyValue("font-size")).toBe(
"mixed",
);
});
test("`currentStyle` uses text span font-size when anchor is paragraph (Firefox-style word selection)", () => {
const textEditorMock = TextEditorMock.createTextEditorMockWithParagraphs([
createParagraph([

View File

@ -393,7 +393,7 @@ export class AppComponent {
}
#startDownload(name: string, data: Uint8Array) {
const blob = new Blob([data], { type: 'application/octet-stream' });
const blob = new Blob([data as any], { type: 'application/octet-stream' });
// We need to start a download with this URL
const downloadURL = URL.createObjectURL(blob);

View File

@ -8,6 +8,9 @@ import {
ɵunloadPlugin,
} from './lib/load-plugin.js';
// Export the plugin error checker so the frontend can identify plugin errors
export { isPluginError } from './lib/create-sandbox.js';
import type { Context } from '@penpot/plugin-types';
console.log('%c[PLUGINS] Loading plugin system', 'color: #008d7c');

View File

@ -11,6 +11,7 @@ vi.mock('./plugin-manager.js', () => ({
vi.mock('./create-sandbox.js', () => ({
createSandbox: vi.fn(),
markPluginError: vi.fn(),
}));
describe('createPlugin', () => {
@ -116,7 +117,11 @@ describe('createPlugin', () => {
throw new Error('Evaluation error');
});
await createPlugin(mockContext, manifest, onCloseCallback);
try {
await createPlugin(mockContext, manifest, onCloseCallback);
} catch (err) {
expect.assert(err);
}
expect(mockPluginManager.close).toHaveBeenCalled();
});

View File

@ -1,7 +1,7 @@
import type { Context } from '@penpot/plugin-types';
import type { Manifest } from './models/manifest.model.js';
import { createPluginManager } from './plugin-manager.js';
import { createSandbox } from './create-sandbox.js';
import { createSandbox, markPluginError } from './create-sandbox.js';
export async function createPlugin(
context: Context,
@ -13,9 +13,9 @@ export async function createPlugin(
try {
sandbox.evaluate();
} catch (error) {
console.error(error);
markPluginError(error);
plugin.close();
throw error;
}
};
@ -33,7 +33,7 @@ export async function createPlugin(
const sandbox = createSandbox(plugin, apiExtensions);
evaluateSandbox();
await evaluateSandbox();
return {
plugin,

View File

@ -3,6 +3,61 @@ import type { createPluginManager } from './plugin-manager';
import { createApi } from './api';
import { ses } from './ses.js';
/**
* WeakMap used to track errors originating from plugin code.
* Using a WeakMap is safer than extending error objects because:
* 1. It works even if the error object is frozen (SES hardened environment)
* 2. It doesn't require modifying the error object
* 3. It allows garbage collection of error objects when no longer referenced
*/
const pluginErrors = new WeakMap<object, true>();
/**
* Checks if an error originated from plugin code.
*/
export function isPluginError(error: unknown): boolean {
if (error !== null && typeof error === 'object') {
return pluginErrors.has(error as object);
}
return false;
}
/**
* Marks an error as originating from plugin code.
* Uses a WeakMap so it works even if the error object is frozen.
*/
export function markPluginError(error: unknown): void {
if (error !== null && typeof error === 'object') {
pluginErrors.set(error as object, true);
}
}
/**
* Wraps a handler function to mark any thrown errors as plugin errors.
* Errors are marked and re-thrown so they propagate to the global error handler,
* where they can be identified and handled appropriately.
*/
function wrapHandler<T extends (...args: unknown[]) => unknown>(
handler: T,
): (...args: Parameters<T>) => ReturnType<T> {
return function (...args: Parameters<T>) {
try {
const result = handler(...args);
// Handle async functions - mark errors in the returned promise
if (result instanceof Promise) {
return result.catch((error: unknown) => {
markPluginError(error);
throw error;
}) as ReturnType<T>;
}
return result as ReturnType<T>;
} catch (error) {
markPluginError(error);
throw error;
}
};
}
export function createSandbox(
plugin: Awaited<ReturnType<typeof createPluginManager>>,
apiExtensions?: object,
@ -58,9 +113,10 @@ export function createSandbox(
fetch: ses.harden(safeFetch),
setTimeout: ses.harden(
(...[handler, timeout]: Parameters<typeof setTimeout>) => {
const timeoutId = setTimeout(() => {
handler();
}, timeout);
const wrappedHandler = wrapHandler(
typeof handler === 'function' ? handler : () => {},
);
const timeoutId = setTimeout(wrappedHandler, timeout);
plugin.timeouts.add(timeoutId);
@ -72,6 +128,23 @@ export function createSandbox(
plugin.timeouts.delete(id);
}),
setInterval: ses.harden(
(...[handler, interval]: Parameters<typeof setInterval>) => {
const wrappedHandler = wrapHandler(
typeof handler === 'function' ? handler : () => {},
);
const intervalId = setInterval(wrappedHandler, interval);
plugin.intervals.add(intervalId);
return ses.safeReturn(intervalId);
},
) as typeof setInterval,
clearInterval: ses.harden((id: ReturnType<typeof setInterval>) => {
clearInterval(id);
plugin.intervals.delete(id);
}),
/**
* GLOBAL FUNCTIONS ACCESIBLE TO PLUGINS

View File

@ -19,6 +19,10 @@ vi.mock('./create-plugin', () => ({
createPlugin: vi.fn(),
}));
vi.mock('./create-sandbox.js', () => ({
markPluginError: vi.fn(),
}));
vi.mock('./ses.js', () => ({
ses: {
harden: vi.fn().mockImplementation((obj) => obj),
@ -102,16 +106,17 @@ describe('plugin-loader', () => {
});
it('should handle errors and close all plugins', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
vi.mocked(createPlugin).mockRejectedValue(
new Error('Plugin creation failed'),
);
await loadPlugin(manifest);
try {
await loadPlugin(manifest);
} catch (err) {
expect.assert(err);
}
expect(getPlugins()).toHaveLength(0);
expect(consoleSpy).toHaveBeenCalled();
});
it('should handle messages sent to plugins', async () => {

View File

@ -64,11 +64,10 @@ export const loadPlugin = async function (
},
apiExtensions,
);
plugins.push(plugin);
} catch (error) {
closeAllPlugins();
console.error(error);
throw error;
}
};
@ -77,12 +76,12 @@ export const ɵloadPlugin = async function (
closeCallback?: () => void,
apiExtensions?: object,
) {
loadPlugin(manifest, closeCallback, apiExtensions);
await loadPlugin(manifest, closeCallback, apiExtensions);
};
export const ɵloadPluginByUrl = async function (manifestUrl: string) {
const manifest = await loadManifest(manifestUrl);
ɵloadPlugin(manifest);
await ɵloadPlugin(manifest);
};
export const ɵunloadPlugin = function (id: Manifest['pluginId']) {

View File

@ -21,6 +21,7 @@ export async function createPluginManager(
let modal: PluginModalElement | null = null;
let uiMessagesCallbacks: ((message: unknown) => void)[] = [];
const timeouts = new Set<ReturnType<typeof setTimeout>>();
const intervals = new Set<ReturnType<typeof setInterval>>();
const allowDownloads = !!manifest.permissions.find(
(s) => s === 'allow:downloads',
@ -55,6 +56,9 @@ export async function createPluginManager(
timeouts.forEach(clearTimeout);
timeouts.clear();
intervals.forEach(clearInterval);
intervals.clear();
if (modal) {
modal.removeEventListener('close', closePlugin);
modal.remove();
@ -151,6 +155,9 @@ export async function createPluginManager(
get timeouts() {
return timeouts;
},
get intervals() {
return intervals;
},
get code() {
return code;
},

View File

@ -26,7 +26,7 @@ use crate::error::{Error, Result};
use crate::performance;
use crate::shapes::{
all_with_ancestors, radius_to_sigma, Blur, BlurType, Corners, Fill, Shadow, Shape, SolidColor,
Stroke, Type,
Stroke, StrokeKind, Type,
};
use crate::state::{ShapesPoolMutRef, ShapesPoolRef};
use crate::tiles::{self, PendingTiles, TileRect};
@ -1030,6 +1030,8 @@ impl RenderState {
let text_stroke_blur_outset =
Stroke::max_bounds_width(shape.visible_strokes(), false);
let mut paragraph_builders = text_content.paragraph_builder_group_from_text(None);
let stroke_kinds: Vec<StrokeKind> =
shape.visible_strokes().rev().map(|s| s.kind).collect();
let (mut stroke_paragraphs_list, stroke_opacities): (Vec<_>, Vec<_>) = shape
.visible_strokes()
.rev()
@ -1038,7 +1040,6 @@ impl RenderState {
&text_content,
stroke,
&shape.selrect(),
count_inner_strokes,
None,
)
})
@ -1057,22 +1058,41 @@ impl RenderState {
None,
)?;
for (stroke_paragraphs, layer_opacity) in stroke_paragraphs_list
for (i, (stroke_paragraphs, layer_opacity)) in stroke_paragraphs_list
.iter_mut()
.zip(stroke_opacities.iter())
.enumerate()
{
text::render_with_bounds_outset(
Some(self),
None,
&shape,
stroke_paragraphs,
Some(strokes_surface_id),
None,
None,
text_stroke_blur_outset,
None,
*layer_opacity,
)?;
if stroke_kinds[i] == StrokeKind::Inner {
let mut mask_builders = text_content.paragraph_builder_group_opaque();
let mut fill_builders =
text_content.paragraph_builder_group_from_text(None);
text::render_inner_stroke(
Some(self),
None,
&shape,
&mut mask_builders,
stroke_paragraphs,
&mut fill_builders,
Some(strokes_surface_id),
None,
text_stroke_blur_outset,
*layer_opacity,
)?;
} else {
text::render_with_bounds_outset(
Some(self),
None,
&shape,
stroke_paragraphs,
Some(strokes_surface_id),
None,
None,
text_stroke_blur_outset,
None,
*layer_opacity,
)?;
}
}
} else {
let mut drop_shadows = shape.drop_shadow_paints();
@ -1096,7 +1116,6 @@ impl RenderState {
&text_content,
stroke,
&shape.selrect(),
count_inner_strokes,
Some(true),
)
})
@ -1126,6 +1145,8 @@ impl RenderState {
text_drop_shadows_surface_id.into(),
&parent_shadows,
&blur_filter,
&stroke_kinds,
&text_content,
)?;
}
} else {
@ -1168,25 +1189,47 @@ impl RenderState {
text_drop_shadows_surface_id.into(),
&drop_shadows,
&blur_filter,
&stroke_kinds,
&text_content,
)?;
// 4. Stroke fills
for (stroke_paragraphs, layer_opacity) in stroke_paragraphs_list
for (i, (stroke_paragraphs, layer_opacity)) in stroke_paragraphs_list
.iter_mut()
.zip(stroke_opacities.iter())
.enumerate()
{
text::render_with_bounds_outset(
Some(self),
None,
&shape,
stroke_paragraphs,
Some(strokes_surface_id),
None,
blur_filter.as_ref(),
text_stroke_blur_outset,
None,
*layer_opacity,
)?;
if stroke_kinds[i] == StrokeKind::Inner {
let mut mask_builders =
text_content.paragraph_builder_group_opaque();
let mut fill_builders =
text_content.paragraph_builder_group_from_text(None);
text::render_inner_stroke(
Some(self),
None,
&shape,
&mut mask_builders,
stroke_paragraphs,
&mut fill_builders,
Some(strokes_surface_id),
blur_filter.as_ref(),
text_stroke_blur_outset,
*layer_opacity,
)?;
} else {
text::render_with_bounds_outset(
Some(self),
None,
&shape,
stroke_paragraphs,
Some(strokes_surface_id),
None,
blur_filter.as_ref(),
text_stroke_blur_outset,
None,
*layer_opacity,
)?;
}
}
// 5. Stroke inner shadows
@ -1198,6 +1241,8 @@ impl RenderState {
Some(innershadows_surface_id),
&inner_shadows,
&blur_filter,
&stroke_kinds,
&text_content,
)?;
// 6. Fill Inner shadows

View File

@ -1,6 +1,6 @@
use super::{RenderState, SurfaceId};
use crate::render::strokes;
use crate::shapes::{ParagraphBuilderGroup, Shadow, Shape, Stroke, Type};
use crate::shapes::{ParagraphBuilderGroup, Shadow, Shape, Stroke, StrokeKind, TextContent, Type};
use skia_safe::{canvas::SaveLayerRec, Paint, Path};
use crate::error::Result;
@ -127,6 +127,7 @@ fn render_shadow_paint(
}
}
#[allow(clippy::too_many_arguments)]
pub fn render_text_shadows(
render_state: &mut RenderState,
shape: &Shape,
@ -135,6 +136,8 @@ pub fn render_text_shadows(
surface_id: Option<SurfaceId>,
shadows: &[Paint],
blur_filter: &Option<skia_safe::ImageFilter>,
stroke_kinds: &[StrokeKind],
text_content: &TextContent,
) -> Result<()> {
if stroke_paragraphs_group.is_empty() {
return Ok(());
@ -160,18 +163,35 @@ pub fn render_text_shadows(
None,
)?;
for stroke_paragraphs in stroke_paragraphs_group.iter_mut() {
text::render(
None,
Some(canvas),
shape,
stroke_paragraphs,
surface_id,
None,
blur_filter.as_ref(),
None,
None,
)?;
for (i, stroke_paragraphs) in stroke_paragraphs_group.iter_mut().enumerate() {
if i < stroke_kinds.len() && stroke_kinds[i] == StrokeKind::Inner {
let mut mask_builders = text_content.paragraph_builder_group_opaque();
let mut fill_builders = text_content.paragraph_builder_group_from_text(Some(true));
text::render_inner_stroke(
None,
Some(canvas),
shape,
&mut mask_builders,
stroke_paragraphs,
&mut fill_builders,
surface_id,
blur_filter.as_ref(),
0.0,
None,
)?;
} else {
text::render(
None,
Some(canvas),
shape,
stroke_paragraphs,
surface_id,
None,
blur_filter.as_ref(),
None,
None,
)?;
}
}
canvas.restore();

View File

@ -3,8 +3,8 @@ use crate::{
error::Result,
math::Rect,
shapes::{
calculate_position_data, calculate_text_layout_data, merge_fills, set_paint_fill,
ParagraphBuilderGroup, Stroke, StrokeKind, TextContent,
calculate_position_data, calculate_text_layout_data, set_paint_fill, ParagraphBuilderGroup,
Stroke, StrokeKind, TextContent,
},
utils::{get_fallback_fonts, get_font_collection},
};
@ -19,7 +19,6 @@ pub fn stroke_paragraph_builder_group_from_text(
text_content: &TextContent,
stroke: &Stroke,
bounds: &Rect,
count_inner_strokes: usize,
use_shadow: Option<bool>,
) -> (Vec<ParagraphBuilderGroup>, Option<f32>) {
let fallback_fonts = get_fallback_fonts();
@ -33,14 +32,8 @@ pub fn stroke_paragraph_builder_group_from_text(
std::collections::HashMap::new();
for span in paragraph.children().iter() {
let text_paint: skia_safe::Handle<_> = merge_fills(span.fills(), *bounds);
let (stroke_paints, stroke_layer_opacity) = get_text_stroke_paints(
stroke,
bounds,
&text_paint,
count_inner_strokes,
remove_stroke_alpha,
);
let (stroke_paints, stroke_layer_opacity) =
get_text_stroke_paints(stroke, bounds, remove_stroke_alpha);
if group_layer_opacity.is_none() {
group_layer_opacity = stroke_layer_opacity;
@ -79,8 +72,6 @@ pub fn stroke_paragraph_builder_group_from_text(
fn get_text_stroke_paints(
stroke: &Stroke,
bounds: &Rect,
text_paint: &Paint,
count_inner_strokes: usize,
remove_stroke_alpha: bool,
) -> (Vec<Paint>, Option<f32>) {
let mut paints = Vec::new();
@ -104,56 +95,19 @@ fn get_text_stroke_paints(
match stroke.kind {
StrokeKind::Inner => {
let shader = text_paint.shader();
let mut is_opaque = true;
if let Some(shader) = shader {
is_opaque = shader.is_opaque();
}
if is_opaque && count_inner_strokes == 1 {
let mut paint = text_paint.clone();
paint.set_style(skia::PaintStyle::Fill);
paint.set_anti_alias(true);
paints.push(paint);
let mut paint = skia::Paint::default();
paint.set_style(skia::PaintStyle::Stroke);
paint.set_blend_mode(skia::BlendMode::SrcIn);
paint.set_anti_alias(true);
paint.set_stroke_width(stroke.width * 2.0);
fill_for_paint(&mut paint);
paints.push(paint);
} else {
let mut paint = skia::Paint::default();
if remove_stroke_alpha {
paint.set_color(skia::Color::BLACK);
paint.set_alpha(255);
} else {
paint = text_paint.clone();
if needs_opacity_layer {
let opaque_fill = stroke.fill.with_full_opacity();
set_paint_fill(&mut paint, &opaque_fill, bounds, false);
} else {
set_paint_fill(&mut paint, &stroke.fill, bounds, false);
}
}
paint.set_style(skia::PaintStyle::Fill);
paint.set_anti_alias(false);
paints.push(paint);
let mut paint = skia::Paint::default();
let image_filter =
skia_safe::image_filters::erode((stroke.width, stroke.width), None, None);
paint.set_image_filter(image_filter);
paint.set_anti_alias(false);
// Just the stroke paint — mask+SrcIn+DstOver layering is handled
// by render_inner_stroke_on_canvas.
let mut paint = skia::Paint::default();
paint.set_style(skia::PaintStyle::Stroke);
paint.set_anti_alias(true);
paint.set_stroke_width(stroke.width * 2.0);
if remove_stroke_alpha {
paint.set_color(skia::Color::BLACK);
paint.set_alpha(255);
paint.set_blend_mode(skia::BlendMode::DstOut);
paints.push(paint);
} else {
fill_for_paint(&mut paint);
}
paints.push(paint);
}
StrokeKind::Center => {
let mut paint = skia::Paint::default();
@ -330,25 +284,16 @@ fn render_text_on_canvas(
canvas.restore();
}
fn draw_text(
/// Lays out and paints paragraph builders without any layer management.
fn paint_text(
canvas: &Canvas,
shape: &Shape,
paragraph_builder_groups: &mut [Vec<ParagraphBuilder>],
layer_opacity: Option<f32>,
) {
let text_content = shape.get_text_content();
let layout_info =
calculate_text_layout_data(shape, text_content, paragraph_builder_groups, true);
if let Some(opacity) = layer_opacity {
let mut opacity_paint = Paint::default();
opacity_paint.set_alpha_f(opacity);
let layer_rec = SaveLayerRec::default().paint(&opacity_paint);
canvas.save_layer(&layer_rec);
} else {
canvas.save_layer(&SaveLayerRec::default());
}
for para in &layout_info.paragraphs {
para.paragraph.paint(canvas, (para.x, para.y));
for deco in &para.decorations {
@ -364,6 +309,178 @@ fn draw_text(
}
}
fn draw_text(
canvas: &Canvas,
shape: &Shape,
paragraph_builder_groups: &mut [Vec<ParagraphBuilder>],
layer_opacity: Option<f32>,
) {
if let Some(opacity) = layer_opacity {
let mut opacity_paint = Paint::default();
opacity_paint.set_alpha_f(opacity);
let layer_rec = SaveLayerRec::default().paint(&opacity_paint);
canvas.save_layer(&layer_rec);
} else {
canvas.save_layer(&SaveLayerRec::default());
}
paint_text(canvas, shape, paragraph_builder_groups);
}
/// Renders an inner stroke using mask + SrcIn + DstOver layer structure.
///
/// Layer structure:
/// saveLayer() — outer layer
/// saveLayer() — mask group (isolation)
/// paint mask — opaque fill as clip mask
/// saveLayer(SrcIn) — clips stroke to mask shape
/// paint stroke
/// restore
/// restore
/// saveLayer(DstOver) — fill behind the stroke
/// paint fill
/// restore
/// restore
#[allow(clippy::too_many_arguments)]
fn render_inner_stroke_on_canvas(
canvas: &Canvas,
shape: &Shape,
mask_builders: &mut [Vec<ParagraphBuilder>],
stroke_builders: &mut [Vec<ParagraphBuilder>],
fill_builders: &mut [Vec<ParagraphBuilder>],
blur: Option<&ImageFilter>,
layer_opacity: Option<f32>,
) {
if let Some(blur_filter) = blur {
let mut blur_paint = Paint::default();
blur_paint.set_image_filter(blur_filter.clone());
canvas.save_layer(&SaveLayerRec::default().paint(&blur_paint));
}
// Opacity layer wraps the entire composition
if let Some(opacity) = layer_opacity {
let mut opacity_paint = Paint::default();
opacity_paint.set_alpha_f(opacity);
canvas.save_layer(&SaveLayerRec::default().paint(&opacity_paint));
}
// Outer layer
canvas.save_layer(&SaveLayerRec::default());
// Mask group layer (isolates mask from parent surface content)
canvas.save_layer(&SaveLayerRec::default());
// Draw opaque mask (full alpha text shape)
paint_text(canvas, shape, mask_builders);
// SrcIn layer — only keeps stroke pixels where mask has alpha
let mut src_in_paint = Paint::default();
src_in_paint.set_blend_mode(skia::BlendMode::SrcIn);
canvas.save_layer(&SaveLayerRec::default().paint(&src_in_paint));
// Draw stroke
paint_text(canvas, shape, stroke_builders);
canvas.restore(); // SrcIn layer
canvas.restore(); // mask group layer
// Fill with DstOver (behind the stroke result)
let mut dst_over_paint = Paint::default();
dst_over_paint.set_blend_mode(skia::BlendMode::DstOver);
canvas.save_layer(&SaveLayerRec::default().paint(&dst_over_paint));
paint_text(canvas, shape, fill_builders);
canvas.restore(); // DstOver layer
canvas.restore(); // outer layer
if layer_opacity.is_some() {
canvas.restore(); // opacity layer
}
if blur.is_some() {
canvas.restore(); // blur layer
}
}
/// Public API for rendering inner strokes with mask+SrcIn+DstOver approach.
#[allow(clippy::too_many_arguments)]
pub fn render_inner_stroke(
render_state: Option<&mut RenderState>,
canvas: Option<&Canvas>,
shape: &Shape,
mask_builders: &mut [Vec<ParagraphBuilder>],
stroke_builders: &mut [Vec<ParagraphBuilder>],
fill_builders: &mut [Vec<ParagraphBuilder>],
surface_id: Option<SurfaceId>,
blur: Option<&ImageFilter>,
stroke_bounds_outset: f32,
layer_opacity: Option<f32>,
) -> Result<()> {
if let Some(render_state) = render_state {
let target_surface = surface_id.unwrap_or(SurfaceId::Fills);
if let Some(blur_filter) = blur {
let mut text_bounds = shape
.get_text_content()
.calculate_bounds(shape, false)
.to_rect();
if stroke_bounds_outset > 0.0 {
text_bounds.inset((-stroke_bounds_outset, -stroke_bounds_outset));
}
let bounds = blur_filter.compute_fast_bounds(text_bounds);
if bounds.is_finite() && bounds.width() > 0.0 && bounds.height() > 0.0 {
let blur_filter_clone = blur_filter.clone();
if filters::render_with_filter_surface(
render_state,
bounds,
target_surface,
|state, temp_surface| {
let temp_canvas = state.surfaces.canvas(temp_surface);
render_inner_stroke_on_canvas(
temp_canvas,
shape,
mask_builders,
stroke_builders,
fill_builders,
Some(&blur_filter_clone),
layer_opacity,
);
Ok(())
},
)? {
return Ok(());
}
}
}
let canvas = render_state.surfaces.canvas_and_mark_dirty(target_surface);
render_inner_stroke_on_canvas(
canvas,
shape,
mask_builders,
stroke_builders,
fill_builders,
blur,
layer_opacity,
);
return Ok(());
}
if let Some(canvas) = canvas {
render_inner_stroke_on_canvas(
canvas,
shape,
mask_builders,
stroke_builders,
fill_builders,
blur,
layer_opacity,
);
}
Ok(())
}
fn draw_text_decorations(
canvas: &Canvas,
text_style: &TextStyle,

View File

@ -605,6 +605,40 @@ impl TextContent {
paragraph_group
}
/// Creates paragraph builders with always-opaque paint (BLACK @ alpha 255).
/// Used as a clip mask for inner stroke rendering.
pub fn paragraph_builder_group_opaque(&self) -> Vec<ParagraphBuilderGroup> {
let fonts = get_font_collection();
let fallback_fonts = get_fallback_fonts();
let mut paragraph_group = Vec::new();
for paragraph in self.paragraphs() {
let paragraph_style = paragraph.paragraph_to_style();
let mut builder = ParagraphBuilder::new(&paragraph_style, fonts);
let mut has_text = false;
for span in paragraph.children() {
let text_style = span.to_style(
&self.bounds(),
fallback_fonts,
true, // always opaque
paragraph.line_height(),
);
let text: String = span.apply_text_transform();
if !text.is_empty() {
has_text = true;
}
builder.push_style(&text_style);
builder.add_text(&text);
}
if !has_text {
builder.add_text(" ");
}
paragraph_group.push(vec![builder]);
}
paragraph_group
}
/// Performs an Auto Width text layout.
fn text_layout_auto_width(&self) -> TextContentLayoutResult {
let mut paragraph_builders = self.paragraph_builder_group_from_text(None);
@ -1094,6 +1128,7 @@ impl TextSpan {
self.text = text;
}
#[allow(dead_code)]
pub fn fills(&self) -> &[shapes::Fill] {
&self.fills
}