Compare commits

...
Author SHA1 Message Date
Alejandro Alonso 2e5157e6aa Export image fills to WASM SVG as linked images (#11529)
Emit clipped `<image href>` for fills with a registered media URL instead
of Skia base64 embeds. Register URLs from the frontend and exporter via
store_image_url. Cover rect, path, frame, and text (glyph clip) cases in
SVG export tests.

Closes #11383
2026-09-08 17:30:53 +02:00
efa2518fe8 🐛 Fix deep-harden of host plugin context on load (#11521)
* 🐛 Fix deep-harden of host plugin context on load

ses.harden(context) in loadPlugin deep-freezes every host-owned object
and function reachable through the context. The host keeps needing to
modify those across page navigation (listener wrappers, proxies), so a
later property augmentation (e.g. assigning toString) throws
'TypeError: Cannot assign to read only property toString' and kills the
MCP session (penpot/penpot#11001).

Pass the host context through untouched; sandbox isolation stays at the
compartment boundary (hardened sandbox-owned globals + ses.safeReturn).

Regression test: load-plugin-context.spec.ts (no ses mock).

AI-assisted-by: muse-spark-1.3
Signed-off-by: Junsoo Choi <junsoo1172@gmail.com>

* 🐛 Add real SES bootstrap to host-context regression test

The previous load-plugin-context.spec.ts had no SES bootstrap, so it
failed on the original code with 'ReferenceError: harden is not defined'
instead of the intended freeze assertion, and passed on the fixed code
merely by avoiding ses.harden.

Now the spec bootstraps real SES (repairIntrinsics + hardenIntrinsics),
adds a control test proving real ses.harden deep-freezes host-owned
functions (Object.isFrozen === true, later toString assignment throws
TypeError - the #11001 crash signature), and keeps the regression test
asserting loadPlugin leaves host functions unfrozen and patchable.

AI-assisted-by: muse-spark-1.3
Signed-off-by: Junsoo Choi <junsoo1172@gmail.com>

* 🐛 Add production-order hardening contrast evidence

Proves the initialization-ordering hazard behind #11001 (cf. #8636):
in production, index.ts runs repairIntrinsics only at module load while
hardenIntrinsics runs later in createSandbox. The original loadPlugin
called ses.harden(context) between those steps, freezing the shared
Function.prototype with plain data properties so later override taming
is skipped and any subsequent fn.toString assignment throws TypeError.

Kept in a separate spec file so the full SES bootstrap in
load-plugin-context.spec.ts cannot mask the ordering effect.

AI-assisted-by: muse-spark-1.3
Signed-off-by: Junsoo Choi <junsoo1172@gmail.com>

* 🐛 Apply approved lint fix and CHANGELOG entry

Restores the two approved deliverables missing from the previous push:
the prefer-rest-params fix in load-plugin-harden-order.spec.ts
(replacing the deprecated arguments usage) and the plugins-runtime
CHANGELOG entry for the host-context harden fix (#11001).

AI-assisted-by: muse-spark-1.3

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

* 🐛 Remove deep-hardening of host plugin context on load

Signed-off-by: makesomethingshit <junsoo1172@gmail.com>
Co-authored-by: multica-agent <github@multica.ai>
Signed-off-by: Junsoo Choi <junsoo1172@gmail.com>

* 🐛 Align CHANGELOG and context comment with reviewed evidence

AI-assisted-by: multica-agent
Signed-off-by: Junsoo Choi <junsoo1172@gmail.com>
Co-authored-by: multica-agent <github@multica.ai>

* 🔥 Remove SES semantic tests from plugin regression coverage

Drop the tests that only verify SES library semantics rather than Penpot
application behavior:

- Delete load-plugin-harden-order.spec.ts (pure SES initialization-order
  evidence, never calls loadPlugin).
- Remove the ses.harden control test and its SES bootstrap setup from
  load-plugin-context.spec.ts.
- Remove the #8636 hardening-order contrast test and the now-unused ses
  import from load-plugin-real-path.spec.ts.

Keep the application-level regression coverage: the real loadPlugin
initialization path, permission enforcement, host-context isolation and
safeReturn protection. No production code changes.

Signed-off-by: Junsoo Choi <junsoo1172@gmail.com>
AI-assisted-by: Omen Alpha

---------

Signed-off-by: Junsoo Choi <junsoo1172@gmail.com>
Signed-off-by: makesomethingshit <junsoo1172@gmail.com>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-09-08 12:32:45 +02:00
26 changed files with 1180 additions and 59 deletions

No files matched your search

+17
View File
@@ -25,6 +25,7 @@
[app.common.uuid :as uuid]
;; Required for side effects: binds the generated enums.
[app.wasm.enums]
[cuerdas.core :as str]
[promesa.core :as p]
[shadow.esm :refer [dynamic-import]]))
@@ -190,6 +191,22 @@
(aget buf 0) (aget buf 1) (aget buf 2) (aget buf 3)
false)))))
(defn store-image-url!
"Registers the public URL an image was loaded from. The SVG export emits
linked `<image href>` from these, and falls back to Skia base64 when missing,
so this should run for every media id the scene references (including
already-cached images).
Does NOT call `mem/free`, for the same reason as `store-font-url!`."
[image-id url]
(when (and (some? url) (not (str/blank? url)))
(let [bytes (js/Buffer.from url "utf-8")
ptr (mem/alloc (.-byteLength bytes))
quart (uuid/get-u32 image-id)]
(mem/write-buffer ptr (mem/get-heap-u8) bytes)
(h/call wasm/internal-module "_store_image_url"
(aget quart 0) (aget quart 1) (aget quart 2) (aget quart 3)))))
(defn store-image!
"Uploads one image's *encoded* bytes (PNG/JPEG — Skia decodes, no WebGL) into
the WASM image store via `_store_image`. Buffer layout matches the Rust reader:
+6 -1
View File
@@ -369,13 +369,18 @@
"Fetches and stores every image the scene references (shape, stroke and
text-span fills, enumerated by `app.common.types.shape.images`). Unlike fonts,
the image store is not reset per request, so already-held images are skipped
and repeated exports of a file reuse them."
and repeated exports of a file reuse them.
Always registers a public media URL for each id so SVG export can emit linked
`<image href>` even when the encoded bytes were already cached."
[scene params]
(let [all-ids (images/scene-image-ids scene)
new-ids (remove wasm/image-cached? all-ids)]
(l/dbg :hint "wasm render: provisioning images"
:total (count all-ids)
:cached (- (count all-ids) (count new-ids)))
(doseq [image-id all-ids]
(wasm/store-image-url! image-id (public-uri (str "assets/by-file-media-id/" image-id))))
(->> new-ids
(map (fn [image-id]
(->> (fetch-file-media-bytes image-id params)
+24
View File
@@ -880,6 +880,25 @@
(h/call wasm/internal-module "_store_image")
true)))))
(defn- store-image-url!
"Registers the public URL an image was loaded from so SVG export can emit a
linked `<image href>` instead of a Skia base64 embed."
[image-id url]
(when (and (wasm/live?) (some? url) (not (str/blank? url)))
(let [buffer (uuid/get-u32 image-id)
encoder (js/TextEncoder.)
encoded (.encode encoder url)
size (.-byteLength encoded)
offset (mem/alloc size)
heap (mem/get-heap-u8)]
(.set heap encoded offset)
(h/call wasm/internal-module "_store_image_url"
(aget buffer 0)
(aget buffer 1)
(aget buffer 2)
(aget buffer 3))
true)))
(defn- store-image-texture
"Creates a WebGL texture from a decoded image and passes the texture ID to
WASM. This avoids decoding the image twice (once in browser, once in WASM)."
@@ -922,6 +941,7 @@
so Skia rasterizes them."
[shape-id image-id thumbnail?]
(let [url (cf/resolve-file-media {:id image-id} thumbnail?)]
(store-image-url! image-id url)
{:key url
:thumbnail? thumbnail?
:callback
@@ -959,6 +979,8 @@
(aget buffer 2)
(aget buffer 3)
thumbnail?)]
;; Always register the URL (SVG export needs it even when bytes are cached).
(store-image-url! id (cf/resolve-file-media {:id id} thumbnail?))
(when (zero? cached-image?)
(fetch-image shape-id id thumbnail?)))))
@@ -993,6 +1015,7 @@
(aget buffer 2)
(aget buffer 3)
thumbnail?)]
(store-image-url! id (cf/resolve-file-media {:id id} thumbnail?))
(when (zero? cached-image?)
(fetch-image shape-id id thumbnail?))))
(types.fills/get-image-ids fills))))))
@@ -1021,6 +1044,7 @@
(aget buffer 2)
(aget buffer 3)
thumbnail?)]
(store-image-url! image-id (cf/resolve-file-media {:id image-id} thumbnail?))
(when (zero? cached-image?)
(fetch-image shape-id image-id thumbnail?))))
image-ids))))
+1
View File
@@ -10,6 +10,7 @@
- **plugins-runtime**: `Library.createComponent()` now rejects invalid input (an empty shape list, or a shape inside a component copy) with a validation error instead of returning a component proxy pointing at nothing.
- **plugins-runtime**: Setting an individual padding/margin side (`leftPadding`, `topMargin`, …) now re-derives the padding/margin type, switching to `multiple` when the four sides stop being symmetric (so the value is actually painted) and back to `simple` once top/bottom and left/right are mirrored again.
- **plugins-runtime**: Removed the premature deep-hardening of the host plugin context, which froze shared host functions (including `Function.prototype`) before SES override taming, causing `TypeError: Cannot assign to read only property 'toString'` on later host-side function extension. Related to #11001.
## 1.5.0 (2026-07-08)
@@ -0,0 +1,82 @@
import { describe, it, vi, expect, beforeEach } from 'vitest';
import { loadPlugin, setContextBuilder, getPlugins } from './load-plugin';
import { createPlugin } from './create-plugin';
import { ses } from './ses.js';
import type { Context } from '@penpot/plugin-types';
import type { Manifest } from './models/manifest.model.js';
vi.mock('./create-plugin', () => ({
createPlugin: vi.fn(),
}));
// NOTE: `./ses.js` is intentionally NOT mocked here: the test spies on the
// real `ses.harden` to assert that `loadPlugin` never hardens the host
// context.
describe('loadPlugin host context boundary (regression for #11001)', () => {
let manifest: Manifest;
beforeEach(() => {
manifest = {
pluginId: 'test-plugin',
name: 'Test Plugin',
host: '',
code: '',
permissions: ['content:read'],
};
vi.mocked(createPlugin).mockResolvedValue({
plugin: {
close: vi.fn(),
sendMessage: vi.fn(),
},
} as unknown as Awaited<ReturnType<typeof createPlugin>>);
});
it('does not freeze host-owned functions reachable through the context', async () => {
const hostListener = function hostListener() {
return 'host-value';
};
const nestedHostObject = {
nestedFn() {
return 'nested';
},
};
const hostContext = {
addListener: hostListener,
nested: nestedHostObject,
} as unknown as Context;
setContextBuilder(() => hostContext);
const hardenSpy = vi.spyOn(ses, 'harden');
await loadPlugin(manifest);
// The host context itself must be passed through untouched so the host
// can keep modifying its own runtime objects (e.g. on page navigation).
expect(createPlugin).toHaveBeenCalledWith(
hostContext,
manifest,
expect.any(Function),
undefined,
);
// Host-owned functions must remain extensible: page navigation and
// runtime code may patch/augment them (e.g. assigning `toString` on a
// wrapped listener). A deep `ses.harden(context)` here would freeze
// them and turn such later assignments into
// `TypeError: Cannot assign to read only property 'toString'`.
expect(Object.isFrozen(hostListener)).toBe(false);
expect(Object.isExtensible(hostListener)).toBe(true);
expect(Object.isFrozen(nestedHostObject)).toBe(false);
expect(() => {
hostListener.toString = () => 'patched-by-host';
}).not.toThrow();
expect(hardenSpy).not.toHaveBeenCalled();
expect(getPlugins()).toHaveLength(1);
hardenSpy.mockRestore();
});
});
@@ -0,0 +1,169 @@
import { describe, it, vi, expect, beforeAll } from 'vitest';
import 'ses';
import { loadPlugin, setContextBuilder, getPlugins } from './load-plugin';
import type { Context } from '@penpot/plugin-types';
import type { Manifest } from './models/manifest.model.js';
// Real initialization-path regression tests for #11001.
//
// NOTE: `./create-plugin`, `./plugin-manager` and
// `./create-sandbox` are intentionally NOT mocked here. This spec exercises
// the real `loadPlugin → createPlugin → createPluginManager → createSandbox`
// path with the real SES implementation, mirroring the production
// initialization order from `plugins-runtime/src/index.ts`:
// repairIntrinsics (module load) → loadPlugin → createSandbox/hardenIntrinsics
//
// `hardenIntrinsics()` is deliberately NOT called up front: the first test
// must run in the production window where only `repairIntrinsics` has run.
// Tests run in declaration order; the later tests build on the locked-down
// state the first real `loadPlugin` leaves behind (via `createSandbox`).
//
// Note on SES isolation: this suite depends on Vitest's default
// file-level isolation. Each test file runs in a separate worker
// process, so SES intrinsics frozen here do not leak into other
// spec files.
const REPAIR_OPTIONS = {
evalTaming: 'unsafeEval',
stackFiltering: 'verbose',
errorTaming: 'unsafe',
consoleTaming: 'unsafe',
errorTrapping: 'none',
unhandledRejectionTrapping: 'none',
};
function makeManifest(
code: string,
permissions: Manifest['permissions'],
): Manifest {
return {
pluginId: 'test-plugin',
name: 'Test Plugin',
host: '',
code,
permissions,
};
}
function makeHostFixture() {
const listenerTypes: string[] = [];
const listeners = new Map<symbol, string>();
// Inline code (empty host + non-URL code) resolves without network, so no
// fetch mock is needed. UI/modal APIs are never touched by the probe code.
const createRectangle = vi.fn(() => ({ type: 'rectangle-marker' }));
const selection: object[] = [{ id: 'shape-1' }];
const context = {
addListener: (type: string, _callback: (...args: unknown[]) => unknown) => {
const id = Symbol(type);
listeners.set(id, type);
listenerTypes.push(type);
return id;
},
removeListener: (id: symbol) => {
listeners.delete(id);
},
theme: 'dark',
createRectangle,
selection,
// Host-only member: present on the raw context but NOT part of the
// public penpot API. Plugin code must never see it (see B-2 below).
__internalSecret: 'host-internal',
} as unknown as Context;
return { context, listenerTypes, createRectangle, selection };
}
function lastCompartmentGlobalThis(): Record<string, unknown> {
const plugins = getPlugins();
const last = plugins[plugins.length - 1] as unknown as {
compartment: { compartment: { globalThis: Record<string, unknown> } };
};
return last.compartment.compartment.globalThis;
}
describe('loadPlugin real initialization path (regression for #11001)', () => {
beforeAll(() => {
// Production module-load step only: repairs intrinsics WITHOUT
// installing override taming, exactly like `index.ts` at import time.
(
globalThis as unknown as { repairIntrinsics(opts: object): void }
).repairIntrinsics({ ...REPAIR_OPTIONS });
});
it('loads through the real path and keeps host function augmentation working', async () => {
const fixture = makeHostFixture();
setContextBuilder(() => fixture.context);
await loadPlugin(
makeManifest('penpot.on("finish", function () {});', ['content:read']),
);
// The plugin code really ran inside the sandbox: the manager registers
// `themechange` + `finish`, and the plugin code adds its own `finish`
// listener through the public API.
expect(fixture.listenerTypes).toEqual(['themechange', 'finish', 'finish']);
expect(getPlugins()).toHaveLength(1);
// The user-facing behavior from #11001: host-side augmentation of a
// fresh function (e.g. assigning `toString` during page navigation)
// succeeds after a real plugin load.
const freshWrapper = function freshWrapper() {
return 'navigation-wrapper';
};
expect(() => {
freshWrapper.toString = () => 'patched-by-runtime';
}).not.toThrow();
expect(fixture.listenerTypes.length).toBe(3);
});
it('denies the write API without permission and leaves the host untouched', async () => {
const fixture = makeHostFixture();
setContextBuilder(() => fixture.context);
await expect(
loadPlugin(makeManifest('penpot.createRectangle();', ['content:read'])),
).rejects.toThrow(/content:write/);
expect(fixture.createRectangle).not.toHaveBeenCalled();
});
it('allows the same write API with permission', async () => {
const fixture = makeHostFixture();
setContextBuilder(() => fixture.context);
await loadPlugin(
makeManifest('penpot.createRectangle();', [
'content:read',
'content:write',
]),
);
expect(fixture.createRectangle).toHaveBeenCalledTimes(1);
});
it('does not expose raw host-only context members to plugin code', async () => {
const fixture = makeHostFixture();
setContextBuilder(() => fixture.context);
await loadPlugin(
makeManifest('globalThis.__probe = typeof penpot.__internalSecret;', [
'content:read',
]),
);
// The public `penpot` object is a boundary proxy over a curated API, not
// the raw host context, so host-only members are invisible inside.
expect(lastCompartmentGlobalThis()['__probe']).toBe('undefined');
});
it('keeps safeReturn protection on returned values without blocking allowed edits', async () => {
const fixture = makeHostFixture();
setContextBuilder(() => fixture.context);
await loadPlugin(
makeManifest(
'penpot.createRectangle(); ' +
'globalThis.__selectionFrozen = Object.isFrozen(penpot.selection);',
['content:read', 'content:write'],
),
);
expect(fixture.createRectangle).toHaveBeenCalledTimes(1);
expect(lastCompartmentGlobalThis()['__selectionFrozen']).toBe(true);
});
});
@@ -3,7 +3,6 @@ import type { Context } from '@penpot/plugin-types';
import { loadManifest } from './parse-manifest.js';
import { Manifest } from './models/manifest.model.js';
import { createPlugin } from './create-plugin.js';
import { ses } from './ses.js';
let plugins: Awaited<ReturnType<typeof createPlugin>>[] = [];
@@ -54,8 +53,23 @@ export const loadPlugin = async function (
closeAllPlugins();
// The host context is not deeply frozen at this load stage.
//
// The context still contains host-internal function objects and shared
// prototypes that the host may legitimately extend after plugin load
// (for example, by assigning custom properties). Deep-freezing here
// would freeze those prototypes before SES override taming completes,
// preventing later host-side mutations with a "Cannot assign to read
// only property" TypeError.
//
// Responsibility boundary: this function forwards the context to the
// sandbox layer without deep-freezing it. The public API that plugins
// consume is constructed by the API module (`api/index.ts`), and
// `createSandbox`'s proxy handler applies `ses.safeReturn` to values
// crossing into the sandbox. Compartment isolation and intrinsics
// hardening are performed by createSandbox, not here.
const plugin = await createPlugin(
ses.harden(context) as Context,
context,
manifest,
() => {
plugins = plugins.filter((api) => api !== plugin);
+5
View File
@@ -12,6 +12,7 @@
#
# Text snapshots reference `fonts/sourcesanspro-regular.ttf`; this script copies
# the bundled font into `target/svg-preview/fonts/` so the gallery renders text.
# Image-fill snapshots reference `images/test-fill.svg`; same idea for fills.
#
# When a test produced a pending change there will be a `*.snap.new` next to the
# accepted `*.snap`; the gallery then shows "accepted" vs "new" side by side.
@@ -27,10 +28,14 @@ OUT_DIR="$SCRIPT_DIR/target/svg-preview"
OUT="$OUT_DIR/index.html"
FONT_SRC="$SCRIPT_DIR/src/fonts/sourcesanspro-regular.ttf"
FONT_DIR="$OUT_DIR/fonts"
IMAGE_SRC="$SCRIPT_DIR/src/render/svg/fixtures/test-fill.svg"
IMAGE_DIR="$OUT_DIR/images"
mkdir -p "$OUT_DIR"
mkdir -p "$FONT_DIR"
mkdir -p "$IMAGE_DIR"
cp "$FONT_SRC" "$FONT_DIR/"
cp "$IMAGE_SRC" "$IMAGE_DIR/"
# Prints the SVG body of a snapshot file: everything after the second `---`
# line (the YAML front matter insta writes).
+15
View File
@@ -82,6 +82,9 @@ pub struct ImageStore {
tick: Cell<u64>,
/// gpu-only
context: Option<Box<DirectContext>>,
/// Source URL registered when the image was fetched (SVG export references
/// this in linked `<image>` elements).
source_urls: HashMap<Uuid, String>,
}
/// Creates a Skia image from an existing WebGL texture.
@@ -227,6 +230,7 @@ impl ImageStore {
total_bytes: 0,
tick: Cell::new(0),
context: Some(Box::new(context.clone())),
source_urls: HashMap::new(),
}
}
@@ -239,6 +243,7 @@ impl ImageStore {
total_bytes: 0,
tick: Cell::new(0),
context: None,
source_urls: HashMap::new(),
}
}
@@ -476,4 +481,14 @@ impl ImageStore {
None
}
}
pub(crate) fn set_source_url(&mut self, id: Uuid, url: String) {
if !url.is_empty() {
self.source_urls.insert(id, url);
}
}
pub(crate) fn source_url(&self, id: &Uuid) -> Option<&str> {
self.source_urls.get(id).map(String::as_str)
}
}
+23 -1
View File
@@ -16,7 +16,7 @@ use crate::render::vector::draw_shape_geometry;
/// Accumulates the SVG document body while drawing.
pub(crate) struct SvgLayerCanvas {
pub(super) scale: f32,
scale: f32,
page_rect: skia::Rect,
tx: f32,
ty: f32,
@@ -97,6 +97,28 @@ impl SvgLayerCanvas {
self.out.push_str("</g>");
}
/// Appends raw SVG markup to the body (flushes any pending Skia fragment first).
pub(super) fn push_raw(&mut self, markup: &str) {
self.flush();
self.out.push_str(markup);
}
/// CTM for leaf content placed in page space: Scale * Translate * Centered.
pub(super) fn page_shape_matrix_attr(&self, shape: &Shape) -> String {
let mut ctm = skia::Matrix::scale((self.scale, self.scale));
ctm = ctm * skia::Matrix::translate((self.tx, self.ty));
ctm = ctm * shape.centered_transform();
format!(
"matrix({} {} {} {} {} {})",
ctm.scale_x(),
ctm.skew_y(),
ctm.skew_x(),
ctm.scale_y(),
ctm.translate_x(),
ctm.translate_y()
)
}
/// Emits a `<clipPath>` from a shape's geometry (in device/page space).
///
/// A mask can be a group too. Since a group has no geometry of its own, we
+147 -2
View File
@@ -5,7 +5,7 @@ use skia_safe as skia;
use crate::globals::TestRenderResourcesGuard;
use crate::render::{FontStore, RenderResources};
use crate::shapes::{
Fill, FontFamily, FontStyle, Frame, Group, GrowType, Paragraph, Path, Rect, Segment,
Fill, FontFamily, FontStyle, Frame, Group, GrowType, ImageFill, Paragraph, Path, Rect, Segment,
SolidColor, Stroke, StrokeKind, StrokeStyle, TextAlign, TextContent, TextDirection, TextSpan,
Type,
};
@@ -18,6 +18,10 @@ use super::render_tree_to_svg;
/// Font URL referenced in exported SVG `@font-face` rules.
pub(super) const TEST_FONT_URL: &str = "fonts/sourcesanspro-regular.ttf";
/// Media URL referenced by linked `<image href>` fills in SVG export tests.
/// Relative path so `./preview-snapshots` can resolve it under `target/svg-preview/`.
pub(super) const TEST_IMAGE_URL: &str = "images/test-fill.svg";
fn register_test_font_urls(fonts: &mut FontStore) {
let family = FontFamily::new(Uuid::nil(), 400, FontStyle::Normal);
fonts.set_source_url(&family.alias(), TEST_FONT_URL.to_string());
@@ -28,6 +32,31 @@ pub(super) fn uid(n: u32) -> Uuid {
uuid_from_u32_quartet(0, 0, 0, n)
}
/// Adds a rectangle filled with a linked image (must call `render_with` / register URL).
pub(super) fn add_image_rect(
pool: &mut ShapesPool,
id: Uuid,
parent: Uuid,
(l, t, r, b): (f32, f32, f32, f32),
image_id: Uuid,
keep_aspect_ratio: bool,
opacity: u8,
) {
add_rect_with_fills(
pool,
id,
parent,
(l, t, r, b),
vec![Fill::Image(ImageFill::new(
image_id,
opacity,
200,
100,
keep_aspect_ratio,
))],
);
}
/// Adds a solid-filled rectangle to the pool.
pub(super) fn add_solid_rect(
pool: &mut ShapesPool,
@@ -68,15 +97,101 @@ pub(super) fn add_frame(
(l, t, r, b): (f32, f32, f32, f32),
color: skia::Color,
clip: bool,
) {
add_frame_with_fills(
pool,
id,
parent,
(l, t, r, b),
vec![Fill::Solid(SolidColor(color))],
clip,
);
}
fn add_frame_with_fills(
pool: &mut ShapesPool,
id: Uuid,
parent: Uuid,
(l, t, r, b): (f32, f32, f32, f32),
fills: Vec<Fill>,
clip: bool,
) {
let shape = pool.add_shape(id);
shape.set_parent(parent);
shape.set_shape_type(Type::Frame(Frame::default()));
shape.set_selrect(l, t, r, b);
shape.set_fills(vec![Fill::Solid(SolidColor(color))]);
shape.set_fills(fills);
shape.set_clip(clip);
}
/// Frame whose background is a linked image fill.
pub(super) fn add_image_frame(
pool: &mut ShapesPool,
id: Uuid,
parent: Uuid,
(l, t, r, b): (f32, f32, f32, f32),
image_id: Uuid,
clip: bool,
) {
add_frame_with_fills(
pool,
id,
parent,
(l, t, r, b),
vec![test_image_fill(image_id)],
clip,
);
}
fn triangle_segments(closed: bool) -> Vec<Segment> {
let mut segments = vec![
Segment::MoveTo((10.0, 90.0)),
Segment::LineTo((50.0, 10.0)),
Segment::LineTo((90.0, 90.0)),
];
if closed {
segments.push(Segment::Close);
}
segments
}
fn add_path_with_fills(
pool: &mut ShapesPool,
id: Uuid,
parent: Uuid,
(l, t, r, b): (f32, f32, f32, f32),
segments: Vec<Segment>,
fills: Vec<Fill>,
) {
let shape = pool.add_shape(id);
shape.set_parent(parent);
shape.set_shape_type(Type::Path(Path::new(segments)));
shape.set_selrect(l, t, r, b);
shape.set_fills(fills);
}
fn test_image_fill(image_id: Uuid) -> Fill {
Fill::Image(ImageFill::new(image_id, 255, 200, 100, true))
}
/// Triangle path (open or closed) with a linked image fill.
pub(super) fn add_image_path(
pool: &mut ShapesPool,
id: Uuid,
parent: Uuid,
closed: bool,
image_id: Uuid,
) {
add_path_with_fills(
pool,
id,
parent,
(0.0, 0.0, 100.0, 100.0),
triangle_segments(closed),
vec![test_image_fill(image_id)],
);
}
/// Adds an empty (unmasked) group.
pub(super) fn add_group(
pool: &mut ShapesPool,
@@ -249,9 +364,39 @@ fn stroke_with_style(
stroke
}
/// Text with a linked image fill (register URL via `render_with`).
pub(super) fn add_image_text(
pool: &mut ShapesPool,
id: Uuid,
bounds: (f32, f32, f32, f32),
text: &str,
font_size: f32,
image_id: Uuid,
) {
add_text_with_fills(
pool,
id,
bounds,
text,
font_size,
vec![test_image_fill(image_id)],
);
}
pub(super) fn render(pool: &ShapesPool, root: Uuid) -> String {
render_with(pool, root, |_resources| {})
}
/// Like [`render`], but lets the test register extra resources (e.g. image URLs)
/// before export.
pub(super) fn render_with(
pool: &ShapesPool,
root: Uuid,
setup: impl FnOnce(&mut RenderResources),
) -> String {
let mut resources = RenderResources::try_new_headless().expect("headless resources");
register_test_font_urls(&mut resources.fonts);
setup(&mut resources);
let _guard = TestRenderResourcesGuard::install(&mut resources);
let bytes = render_tree_to_svg(&mut resources, &root, pool, 1.0).expect("svg export");
String::from_utf8(bytes).expect("utf8 svg")
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100" viewBox="0 0 200 100">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#2563eb"/>
<stop offset="100%" stop-color="#7c3aed"/>
</linearGradient>
</defs>
<rect width="200" height="100" fill="url(#g)"/>
<circle cx="60" cy="50" r="28" fill="#fbbf24"/>
<rect x="110" y="22" width="70" height="56" rx="8" fill="#f8fafc" opacity="0.9"/>
<text x="145" y="58" text-anchor="middle" font-family="system-ui,sans-serif" font-size="22" font-weight="700" fill="#1e293b">IMG</text>
</svg>

After

Width:  |  Height:  |  Size: 610 B

+3 -7
View File
@@ -5,6 +5,7 @@ use crate::shapes::{Shape, Stroke};
use crate::state::ShapesPoolRef;
use super::document::{effect_attrs, SvgLayerCanvas};
use super::images::emit_fills;
use super::render_tree;
use crate::render::RenderResources;
@@ -29,14 +30,9 @@ pub(super) fn render_frame(
builder.open_group(&format!("clip-path=\"url(#{clip_id})\""));
}
// Frame background (frame space).
// Frame background (frame space), with linked `<image>` for image fills.
if !element.fills.is_empty() {
let canvas = builder.canvas();
canvas.save();
canvas.concat(&matrix);
let mut renderer = VectorRenderer::new(canvas, shared, scale, false);
renderer.draw_fills(element, &element.fills)?;
canvas.restore();
emit_fills(builder, shared, element, &element.fills, tree, scale)?;
}
// Children (absolute coords).
+109
View File
@@ -0,0 +1,109 @@
use crate::error::Result;
use crate::render::shape_renderer::ShapeRenderer;
use crate::render::vector::VectorRenderer;
use crate::shapes::{Fill, ImageFill, Shape};
use crate::state::ShapesPoolRef;
use super::document::SvgLayerCanvas;
use crate::render::RenderResources;
/// Emits fills bottom -> top for SVG export.
///
/// Non-image fills go through Skia's SVG canvas. Image fills with a registered
/// source URL become native linked `<image>` elements (see `store_image_url`);
/// without a URL they fall back to Skia (base64-embed) when a CPU image exists.
pub(super) fn emit_fills(
builder: &mut SvgLayerCanvas,
shared: &mut RenderResources,
shape: &Shape,
fills: &[Fill],
tree: ShapesPoolRef,
scale: f32,
) -> Result<()> {
if fills.is_empty() {
return Ok(());
}
// fills[0] is the topmost layer; draw bottom → top.
for fill in fills.iter().rev() {
match fill {
Fill::Image(image_fill) if shared.images.source_url(&image_fill.id()).is_some() => {
emit_image_fill(builder, shared, shape, image_fill, tree)?;
}
fill => {
let matrix = shape.centered_transform();
let canvas = builder.canvas();
canvas.save();
canvas.concat(&matrix);
let mut renderer = VectorRenderer::new(canvas, shared, scale, false);
renderer.draw_fills(shape, std::slice::from_ref(fill))?;
canvas.restore();
}
}
}
Ok(())
}
/// Emits a linked SVG `<image>` clipped to the shape geometry.
///
/// Skia's SVG backend would base64-embed a PNG from `draw_image_rect`; we emit
/// a native `<image href="...">` instead so the export stays linked to the
/// registered media URL (see `store_image_url`).
fn emit_image_fill(
builder: &mut SvgLayerCanvas,
shared: &RenderResources,
shape: &Shape,
image_fill: &ImageFill,
tree: ShapesPoolRef,
) -> Result<()> {
let Some(url) = shared.images.source_url(&image_fill.id()) else {
return Ok(());
};
let clip_id = builder.unique("imgclip");
builder.push_clip_path(&clip_id, shape, tree);
let href = xml_escape_attr(url);
emit_linked_image_element(builder, shape, image_fill, &href, &clip_id);
Ok(())
}
/// Emits `<g clip-path>` + `<image href>` using the shape selrect and page CTM.
pub(super) fn emit_linked_image_element(
builder: &mut SvgLayerCanvas,
shape: &Shape,
image_fill: &ImageFill,
href: &str,
clip_id: &str,
) {
let selrect = shape.selrect();
let opacity = image_fill.opacity() as f32 / 255.0;
let preserve = if image_fill.keep_aspect_ratio() {
"xMidYMid slice"
} else {
"none"
};
let transform = builder.page_shape_matrix_attr(shape);
let opacity_attr = if (opacity - 1.0).abs() < f32::EPSILON {
String::new()
} else {
format!(r#" opacity="{opacity}""#)
};
builder.open_group(&format!("clip-path=\"url(#{clip_id})\""));
builder.push_raw(&format!(
r#"<image href="{href}" x="{}" y="{}" width="{}" height="{}" preserveAspectRatio="{preserve}"{opacity_attr} transform="{transform}"/>"#,
selrect.left(),
selrect.top(),
selrect.width(),
selrect.height(),
));
builder.close_group();
}
pub(super) fn xml_escape_attr(s: &str) -> String {
s.replace('&', "&amp;")
.replace('"', "&quot;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
+20 -4
View File
@@ -8,7 +8,8 @@ use crate::shapes::{Shape, Type};
use crate::state::ShapesPoolRef;
use crate::uuid::Uuid;
use super::vector::{render_leaf_content, VectorRenderer};
use super::shape_renderer::ShapeRenderer;
use super::vector::VectorRenderer;
use super::RenderResources;
/// Collects the registered font aliases used by every text span in the subtree
@@ -125,6 +126,7 @@ pub(crate) fn render_tree_to_svg(
mod document;
mod frames;
mod groups;
mod images;
mod text;
use document::SvgLayerCanvas;
@@ -133,6 +135,7 @@ use groups::render_group;
use text::render_text_fill;
use document::effect_attrs;
use images::emit_fills;
/// Renders `id`'s subtree to an SVG body, returning `(defs, body)`.
fn render_body(
@@ -172,7 +175,7 @@ fn render_tree(
| Type::Path(_)
| Type::Bool(_)
| Type::Text(_)
| Type::SVGRaw(_) => render_leaf(builder, shared, element, scale),
| Type::SVGRaw(_) => render_leaf(builder, shared, element, tree, scale),
}
}
@@ -180,6 +183,7 @@ fn render_leaf(
builder: &mut SvgLayerCanvas,
shared: &mut RenderResources,
element: &Shape,
tree: ShapesPoolRef,
scale: f32,
) -> Result<()> {
let effects = effect_attrs(element);
@@ -189,14 +193,26 @@ fn render_leaf(
{
if matches!(element.shape_type, Type::Text(_)) {
render_text_fill(builder, element)?;
render_text_fill(builder, shared, element)?;
} else {
emit_fills(builder, shared, element, &element.fills, tree, scale)?;
let matrix = element.centered_transform();
let canvas = builder.canvas();
canvas.save();
canvas.concat(&matrix);
let mut renderer = VectorRenderer::new(canvas, shared, scale, false);
render_leaf_content(&mut renderer, element)?;
renderer.draw_fill_inner_shadows(element)?;
let visible_strokes: Vec<_> = element.visible_strokes().collect();
if !visible_strokes.is_empty() {
renderer.draw_strokes(element, &visible_strokes)?;
if !element.has_fills() {
for stroke in &visible_strokes {
renderer.draw_stroke_inner_shadows(element, stroke)?;
}
}
}
canvas.restore();
}
}
@@ -0,0 +1,8 @@
---
source: src/render/svg/tests.rs
expression: svg
---
<?xml version="1.0" encoding="utf-8" ?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100" height="80" viewBox="0 0 100 80"><defs><clipPath id="imgclip0" clipPathUnits="userSpaceOnUse">
<rect width="100" height="80"/>
</clipPath></defs><g clip-path="url(#imgclip0)"><image href="images/test-fill.svg" x="0" y="0" width="100" height="80" preserveAspectRatio="xMidYMid slice" transform="matrix(1 0 0 1 0 0)"/></g></svg>
@@ -0,0 +1,8 @@
---
source: src/render/svg/tests.rs
expression: svg
---
<?xml version="1.0" encoding="utf-8" ?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="80" height="80" viewBox="0 0 80 80"><defs><clipPath id="imgclip0" clipPathUnits="userSpaceOnUse">
<path transform="translate(-10 -10)" d="M10 90L50 10L90 90L10 90Z"/>
</clipPath></defs><g clip-path="url(#imgclip0)"><image href="images/test-fill.svg" x="0" y="0" width="100" height="100" preserveAspectRatio="xMidYMid slice" transform="matrix(1 0 0 1 -10 -10)"/></g></svg>
@@ -0,0 +1,8 @@
---
source: src/render/svg/tests.rs
expression: svg
---
<?xml version="1.0" encoding="utf-8" ?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="120" viewBox="0 0 200 120"><defs><clipPath id="imgclip0" clipPathUnits="userSpaceOnUse">
<rect width="200" height="120"/>
</clipPath></defs><g clip-path="url(#imgclip0)"><image href="images/test-fill.svg" x="0" y="0" width="200" height="120" preserveAspectRatio="xMidYMid slice" transform="matrix(1 0 0 1 0 0)"/></g></svg>
@@ -0,0 +1,8 @@
---
source: src/render/svg/tests.rs
expression: svg
---
<?xml version="1.0" encoding="utf-8" ?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="80" height="80" viewBox="0 0 80 80"><defs><clipPath id="imgclip0" clipPathUnits="userSpaceOnUse">
<path transform="translate(-10 -10)" d="M10 90L50 10L90 90"/>
</clipPath></defs><g clip-path="url(#imgclip0)"><image href="images/test-fill.svg" x="0" y="0" width="100" height="100" preserveAspectRatio="xMidYMid slice" transform="matrix(1 0 0 1 -10 -10)"/></g></svg>
@@ -0,0 +1,11 @@
---
source: src/render/svg/tests.rs
assertion_line: 306
expression: svg
---
<?xml version="1.0" encoding="utf-8" ?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="560" height="240" viewBox="0 0 560 240"><defs><style type="text/css"><![CDATA[@font-face{font-family:"Source Sans Pro";font-style:normal;font-weight:400;src:url("fonts/sourcesanspro-regular.ttf") format("truetype");}]]></style><clipPath id="txtimgclip0" clipPathUnits="userSpaceOnUse">
<text font-size="200" font-family="Source Sans Pro" x="0, 130.37109, 263.08594, 360.83984" y="181">
HOLA
</text>
</clipPath></defs><g clip-path="url(#txtimgclip0)"><image href="images/test-fill.svg" x="0" y="0" width="560" height="240" preserveAspectRatio="xMidYMid slice" transform="matrix(1 0 0 1 0 0)"/></g></svg>
@@ -0,0 +1,10 @@
---
source: src/render/svg/tests.rs
expression: svg
---
<?xml version="1.0" encoding="utf-8" ?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="408" height="128" viewBox="0 0 408 128"><defs><clipPath id="imgclip0" clipPathUnits="userSpaceOnUse">
<rect transform="translate(-100 -50)" x="100" y="50" width="408" height="128"/>
</clipPath></defs><g clip-path="url(#imgclip0)"><image href="images/test-fill.svg" x="100" y="50" width="408" height="128" preserveAspectRatio="none" opacity="0.5019608" transform="matrix(1 0 0 1 -100 -50)"/></g>
<rect fill="#003FFF" fill-opacity="0.50196081" transform="translate(-100 -50)" x="100" y="50" width="408" height="128"/>
</svg>
+227 -1
View File
@@ -1,6 +1,6 @@
use super::fixtures::*;
use crate::shapes::{BlendMode, Fill, SolidColor, StrokeCap, StrokeKind};
use crate::shapes::{BlendMode, Fill, ImageFill, SolidColor, StrokeCap, StrokeKind};
use crate::state::ShapesPool;
use crate::uuid::Uuid;
@@ -962,3 +962,229 @@ fn exports_solid_text_with_font_face() {
);
insta::assert_snapshot!(svg);
}
#[test]
fn exports_image_fill_on_text() {
let mut pool = ShapesPool::new();
let id = uid(1);
let image_id = uid(42);
add_image_text(
&mut pool,
id,
(0.0, 0.0, 560.0, 240.0),
"HOLA",
200.0,
image_id,
);
let svg = render_with(&pool, id, |resources| {
resources
.images
.set_source_url(image_id, TEST_IMAGE_URL.to_string());
});
assert!(
svg.contains("<image") && svg.contains(TEST_IMAGE_URL),
"text image fill must emit a linked <image>: {svg}"
);
assert!(
svg.contains("clip-path=\"url(#"),
"text image fill must be clipped to glyph silhouette: {svg}"
);
assert!(
svg.contains("<clipPath ") && svg.contains("<text"),
"clipPath must contain text glyphs: {svg}"
);
assert!(
!svg.contains("data:image"),
"must not base64-embed the image: {svg}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn exports_image_fill_as_linked_image() {
let mut pool = ShapesPool::new();
let id = uid(1);
let image_id = uid(42);
add_image_rect(
&mut pool,
id,
Uuid::nil(),
(0.0, 0.0, 100.0, 80.0),
image_id,
true,
255,
);
let svg = render_with(&pool, id, |resources| {
resources
.images
.set_source_url(image_id, TEST_IMAGE_URL.to_string());
});
assert!(
svg.contains("<image"),
"image fill must emit an <image> element: {svg}"
);
assert!(
svg.contains(TEST_IMAGE_URL),
"image href must use the registered URL: {svg}"
);
assert!(
svg.contains("preserveAspectRatio=\"xMidYMid slice\""),
"keep-aspect image fill must slice: {svg}"
);
assert!(
svg.contains("clip-path=\"url(#"),
"image fill must be clipped to shape geometry: {svg}"
);
assert!(
!svg.contains("data:image"),
"must not base64-embed the image: {svg}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn exports_mixed_solid_and_image_fills_in_order() {
// Image under a translucent solid; stretch (keep-aspect off); partial image
// opacity; shape not at the page origin (page translate in CTM).
let mut pool = ShapesPool::new();
let id = uid(1);
let image_id = uid(42);
add_rect_with_fills(
&mut pool,
id,
Uuid::nil(),
(100.0, 50.0, 508.0, 178.0),
vec![
// fills[0] topmost — solid blue @ 50%
Fill::Solid(SolidColor(skia::Color::from_argb(128, 0, 63, 255))),
// fills[1] underneath — linked image, stretch, ~50% opacity
Fill::Image(ImageFill::new(image_id, 128, 400, 300, false)),
],
);
let svg = render_with(&pool, id, |resources| {
resources
.images
.set_source_url(image_id, TEST_IMAGE_URL.to_string());
});
let image_pos = svg.find("<image");
let blue_pos = svg.to_ascii_lowercase().find("fill=\"#003fff\"");
assert!(image_pos.is_some(), "missing image fill: {svg}");
assert!(blue_pos.is_some(), "missing top solid fill: {svg}");
assert!(
image_pos.unwrap() < blue_pos.unwrap(),
"image (bottom) must appear before solid (top): {svg}"
);
assert!(
svg.contains("preserveAspectRatio=\"none\""),
"mixed image fill should stretch when keep-aspect is off: {svg}"
);
// 128/255 → ~0.50196 as f32 (not a rounded "0.5").
assert!(
svg.contains("opacity=\"0.5019608\""),
"image fill opacity must be emitted: {svg}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn exports_image_fill_on_closed_path() {
let mut pool = ShapesPool::new();
let id = uid(1);
let image_id = uid(42);
add_image_path(&mut pool, id, Uuid::nil(), true, image_id);
let svg = render_with(&pool, id, |resources| {
resources
.images
.set_source_url(image_id, TEST_IMAGE_URL.to_string());
});
assert!(
svg.contains("<image") && svg.contains(TEST_IMAGE_URL),
"closed path must emit a linked image fill: {svg}"
);
assert!(
svg.contains("clip-path=\"url(#"),
"image fill must be clipped to the path: {svg}"
);
// Clip geometry should be a path (triangle), not a plain rect.
assert!(
svg.contains("<path") || svg.contains(" d=\""),
"closed-path clip must use path geometry: {svg}"
);
assert!(
svg.contains("preserveAspectRatio=\"xMidYMid slice\""),
"keep-aspect image fill on path: {svg}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn exports_image_fill_on_open_path() {
let mut pool = ShapesPool::new();
let id = uid(1);
let image_id = uid(42);
add_image_path(&mut pool, id, Uuid::nil(), false, image_id);
let svg = render_with(&pool, id, |resources| {
resources
.images
.set_source_url(image_id, TEST_IMAGE_URL.to_string());
});
assert!(
svg.contains("<image") && svg.contains(TEST_IMAGE_URL),
"open path must still emit a linked image fill: {svg}"
);
assert!(
svg.contains("clip-path=\"url(#"),
"image fill must be clipped to the open path geometry: {svg}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn exports_image_fill_on_frame() {
let mut pool = ShapesPool::new();
let id = uid(1);
let image_id = uid(42);
add_image_frame(
&mut pool,
id,
Uuid::nil(),
(0.0, 0.0, 200.0, 120.0),
image_id,
false,
);
let svg = render_with(&pool, id, |resources| {
resources
.images
.set_source_url(image_id, TEST_IMAGE_URL.to_string());
});
assert!(
svg.contains("<image") && svg.contains(TEST_IMAGE_URL),
"frame background must emit a linked image fill: {svg}"
);
assert!(
svg.contains("clip-path=\"url(#"),
"frame image fill must be clipped to the frame: {svg}"
);
assert!(
svg.contains("preserveAspectRatio=\"xMidYMid slice\""),
"frame image fill should keep aspect: {svg}"
);
// No nested board clip when clip_content is off.
assert!(
!svg.contains("id=\"clip0\""),
"unclipped frame should not wrap children in a board clip: {svg}"
);
insta::assert_snapshot!(svg);
}
+114 -11
View File
@@ -1,19 +1,122 @@
use std::collections::HashSet;
use crate::error::Result;
use crate::shapes::Shape;
use crate::render::text;
use crate::shapes::{Fill, ImageFill, Shape};
use crate::uuid::Uuid;
use super::document::SvgLayerCanvas;
use crate::render::text;
use super::images::{emit_linked_image_element, xml_escape_attr};
use crate::render::RenderResources;
/// Emits a text shape's fill as native `<text>` elements.
/// Emits a text shape's fills for SVG export.
///
/// The shared GPU/PDF renderer wraps text in `save_layer`, which `SkSVGDevice`
/// silently drops. Text strokes are handled separately in a later PR.
pub(super) fn render_text_fill(builder: &mut SvgLayerCanvas, element: &Shape) -> Result<()> {
/// Linked image fills become `<image href>` clipped to the glyph silhouette;
/// other fills go through Skia as native `<text>`. Strokes are a later PR.
pub(super) fn render_text_fill(
builder: &mut SvgLayerCanvas,
shared: &RenderResources,
element: &Shape,
) -> Result<()> {
let text_content = element.get_text_content();
let text_content = text_content.new_bounds(element.selrect());
let max_layers = text_content.max_fill_layers();
if max_layers == 0 {
return Ok(());
}
let matrix = element.centered_transform();
let canvas = builder.canvas();
canvas.save();
canvas.concat(&matrix);
text::paint_text_fill(canvas, element);
canvas.restore();
for layer in 0..max_layers {
let linked = linked_image_fills_at_layer(&text_content, layer, shared);
let skip_ids: HashSet<Uuid> = linked.iter().map(|img| img.id()).collect();
for image_fill in &linked {
emit_text_image_fill(builder, shared, element, image_fill, layer)?;
}
if layer_has_skia_fills(&text_content, layer, &skip_ids) {
let mut paragraph_builders = if skip_ids.is_empty() {
text_content.paragraph_builder_group_for_fill_layer(layer)
} else {
text_content
.paragraph_builder_group_for_fill_layer_skipping_images(layer, &skip_ids)
};
let canvas = builder.canvas();
canvas.save();
canvas.concat(&matrix);
text::paint_text_paragraphs(canvas, element, &mut paragraph_builders);
canvas.restore();
}
}
Ok(())
}
fn linked_image_fills_at_layer<'a>(
text_content: &'a crate::shapes::TextContent,
layer: usize,
shared: &RenderResources,
) -> Vec<&'a ImageFill> {
let mut out = Vec::new();
let mut seen = HashSet::new();
for paragraph in text_content.paragraphs() {
for span in paragraph.children() {
if let Some(Fill::Image(img)) = span.fills_from_bottom(layer) {
if shared.images.source_url(&img.id()).is_some() && seen.insert(img.id()) {
out.push(img);
}
}
}
}
out
}
fn layer_has_skia_fills(
text_content: &crate::shapes::TextContent,
layer: usize,
skip_ids: &HashSet<Uuid>,
) -> bool {
text_content.paragraphs().iter().any(|paragraph| {
paragraph
.children()
.iter()
.any(|span| match span.fills_from_bottom(layer) {
Some(Fill::Image(img)) if skip_ids.contains(&img.id()) => false,
Some(_) => true,
None => false,
})
})
}
/// Linked `<image>` clipped to the opaque glyph silhouette for this image layer.
fn emit_text_image_fill(
builder: &mut SvgLayerCanvas,
shared: &RenderResources,
shape: &Shape,
image_fill: &ImageFill,
layer: usize,
) -> Result<()> {
let Some(url) = shared.images.source_url(&image_fill.id()) else {
return Ok(());
};
let clip_id = builder.unique("txtimgclip");
let text_content = shape.get_text_content().new_bounds(shape.selrect());
let mut paragraph_builders =
text_content.paragraph_builder_group_opaque_for_image_layer(layer, image_fill.id());
let canvas = builder.new_fragment();
{
let cv: &skia_safe::Canvas = &canvas;
cv.save();
cv.concat(&shape.centered_transform());
text::paint_text_paragraphs(cv, shape, &mut paragraph_builders);
cv.restore();
}
builder.finish_clip_path_fragment(&clip_id, canvas);
let href = xml_escape_attr(url);
emit_linked_image_element(builder, shape, image_fill, &href, &clip_id);
Ok(())
}
+7 -16
View File
@@ -530,22 +530,13 @@ fn render_text_on_canvas(
}
}
/// Paints text fill for vector SVG export. Skips `save_layer` wrappers that
/// `SkSVGDevice` would drop.
pub fn paint_text_fill(canvas: &Canvas, shape: &Shape) {
let text_content = shape.get_text_content();
let text_content = text_content.new_bounds(shape.selrect());
let max_layers = text_content.max_fill_layers();
if max_layers == 0 {
return;
}
// Each fill layer is painted separately so SkSVGDevice can emit `fill`
// attributes (merged shaders are dropped). Bottom layer first.
for layer in 0..max_layers {
let mut paragraph_builders = text_content.paragraph_builder_group_for_fill_layer(layer);
paint_text_with_emoji_overlay(canvas, shape, &mut paragraph_builders, false);
}
/// Paints pre-built paragraph groups (SVG export path for selective fill layers).
pub fn paint_text_paragraphs(
canvas: &Canvas,
shape: &Shape,
paragraph_builder_groups: &mut [Vec<ParagraphBuilder>],
) {
paint_text_with_emoji_overlay(canvas, shape, paragraph_builder_groups, false);
}
/// Lays out and paints paragraph builders without any layer management.
+114 -14
View File
@@ -796,13 +796,13 @@ impl TextContent {
&self,
use_shadow: Option<bool>,
) -> Vec<ParagraphBuilderGroup> {
self.paragraph_builders(use_shadow, false, None, None)
self.paragraph_builders(use_shadow, false, None, None, None, None)
}
/// Creates paragraph builders with always-opaque paint (BLACK @ alpha 255).
/// Used as a clip mask for inner stroke rendering.
pub fn paragraph_builder_group_opaque(&self) -> Vec<ParagraphBuilderGroup> {
self.paragraph_builders(None, true, None, None)
self.paragraph_builders(None, true, None, None, None, None)
}
/// Maximum number of stacked fills across every span in this text block.
@@ -821,7 +821,42 @@ impl TextContent {
&self,
layer_from_bottom: usize,
) -> Vec<ParagraphBuilderGroup> {
self.paragraph_builders(None, false, None, Some(layer_from_bottom))
self.paragraph_builders(None, false, None, Some(layer_from_bottom), None, None)
}
/// Like [`paragraph_builder_group_for_fill_layer`], but spans whose fill at
/// this layer is an image in `skip_image_ids` get transparent paint (those
/// fills are re-emitted as linked SVG `<image>` elements).
pub fn paragraph_builder_group_for_fill_layer_skipping_images(
&self,
layer_from_bottom: usize,
skip_image_ids: &HashSet<Uuid>,
) -> Vec<ParagraphBuilderGroup> {
self.paragraph_builders(
None,
false,
None,
Some(layer_from_bottom),
None,
Some(skip_image_ids),
)
}
/// Opaque black glyphs only for spans whose fill at `layer_from_bottom` is
/// the given image — used as an SVG `<clipPath>` for linked image fills.
pub fn paragraph_builder_group_opaque_for_image_layer(
&self,
layer_from_bottom: usize,
image_id: Uuid,
) -> Vec<ParagraphBuilderGroup> {
self.paragraph_builders(
None,
false,
None,
None,
Some((layer_from_bottom, image_id)),
None,
)
}
fn paragraph_builders(
@@ -830,6 +865,8 @@ impl TextContent {
opaque: bool,
align_override: Option<skia::textlayout::TextAlign>,
fill_layer: Option<usize>,
opaque_image_layer: Option<(usize, Uuid)>,
skip_image_ids: Option<&HashSet<Uuid>>,
) -> Vec<ParagraphBuilderGroup> {
let fonts = get_font_collection();
let fallback_fonts = get_fallback_fonts();
@@ -843,15 +880,63 @@ impl TextContent {
let mut builder = ParagraphBuilder::new(&paragraph_style, fonts);
let mut has_text = false;
for span in paragraph.children() {
let remove_alpha =
opaque || (use_shadow.unwrap_or(false) && !span.is_transparent());
let text_style = span.to_style_with_paint(
&self.bounds(),
fallback_fonts,
remove_alpha,
paragraph.line_height(),
fill_layer,
);
let text_style = if let Some((layer, image_id)) = opaque_image_layer {
let mut style = span.to_style(
&self.bounds(),
fallback_fonts,
false,
paragraph.line_height(),
);
let mut paint = paint::Paint::default();
match span.fills_from_bottom(layer) {
Some(shapes::Fill::Image(img)) if img.id() == image_id => {
paint.set_color(skia::Color::BLACK);
paint.set_alpha(255);
}
_ => {
paint.set_color(skia::Color::TRANSPARENT);
}
}
style.set_foreground_paint(&paint);
style
} else if let (Some(layer), Some(skip)) = (fill_layer, skip_image_ids) {
let skip_span = matches!(
span.fills_from_bottom(layer),
Some(shapes::Fill::Image(img)) if skip.contains(&img.id())
);
if skip_span {
let mut style = span.to_style(
&self.bounds(),
fallback_fonts,
false,
paragraph.line_height(),
);
let mut paint = paint::Paint::default();
paint.set_color(skia::Color::TRANSPARENT);
style.set_foreground_paint(&paint);
style
} else {
let remove_alpha =
opaque || (use_shadow.unwrap_or(false) && !span.is_transparent());
span.to_style_with_paint(
&self.bounds(),
fallback_fonts,
remove_alpha,
paragraph.line_height(),
fill_layer,
)
}
} else {
let remove_alpha =
opaque || (use_shadow.unwrap_or(false) && !span.is_transparent());
span.to_style_with_paint(
&self.bounds(),
fallback_fonts,
remove_alpha,
paragraph.line_height(),
fill_layer,
)
};
let text: String = span.apply_text_transform();
if !text.is_empty() {
has_text = true;
@@ -871,8 +956,14 @@ impl TextContent {
/// Performs an Auto Width text layout.
fn text_layout_auto_width(&self) -> TextContentLayoutResult {
// Left-aligned MAX-width pass: longest_line() is glyph width, not the huge container.
let mut measure_builders =
self.paragraph_builders(None, false, Some(skia::textlayout::TextAlign::Left), None);
let mut measure_builders = self.paragraph_builders(
None,
false,
Some(skia::textlayout::TextAlign::Left),
None,
None,
None,
);
let normalized_line_height =
calculate_normalized_line_height(&mut measure_builders, f32::MAX);
@@ -1464,6 +1555,15 @@ pub struct TextSpan {
}
impl TextSpan {
/// Fill at `layer` counting from the bottom (`0` = last / bottommost fill).
pub fn fills_from_bottom(&self, layer: usize) -> Option<&shapes::Fill> {
if layer < self.fills.len() {
Some(&self.fills[self.fills.len() - 1 - layer])
} else {
None
}
}
#[allow(clippy::too_many_arguments)]
pub fn new(
text: String,
+16
View File
@@ -140,6 +140,22 @@ pub extern "C" fn store_image() -> Result<()> {
Ok(())
}
/// Registers the public URL an image was loaded from for SVG export.
///
/// Layout: UTF-8 URL bytes in the alloc buffer. The image UUID is passed as
/// the four u32 arguments (same quartet as `store_image` / `is_image_cached`).
#[no_mangle]
#[wasm_error]
pub extern "C" fn store_image_url(a: u32, b: u32, c: u32, d: u32) -> Result<()> {
let id = uuid_from_u32_quartet(a, b, c, d);
let url_bytes = mem::bytes();
let url = String::from_utf8(url_bytes)
.map_err(|_| Error::CriticalError("Invalid UTF-8 in image source URL".to_string()))?;
mem::free_bytes()?;
get_resources().images.set_source_url(id, url);
Ok(())
}
/// Stores an image from an existing WebGL texture, avoiding re-decoding
/// Expected memory layout:
/// - bytes 0-15: shape UUID