mirror of
https://github.com/penpot/penpot.git
synced 2026-09-11 14:39:35 +00:00
✨ Add single-file export to PDF using the WebGL render (#9860)
This commit is contained in:
parent
577ddfa03e
commit
6ebefa2c16
@ -163,31 +163,46 @@
|
|||||||
(when (= status "ended")
|
(when (= status "ended")
|
||||||
(dom/trigger-download-uri filename mtype resource-uri)))))
|
(dom/trigger-download-uri filename mtype resource-uri)))))
|
||||||
|
|
||||||
|
;; TODO: Remove once we support WASM SVG export
|
||||||
|
(def ^:private wasm-export-types #{:jpeg :webp :png :pdf})
|
||||||
|
|
||||||
|
(defn- wasm-export-enabled?
|
||||||
|
"WASM export is available: the flag is set AND render-wasm is active for the
|
||||||
|
current file. When render-wasm is inactive its shape tree isn't loaded, so a
|
||||||
|
client-side WASM render would crash."
|
||||||
|
[state]
|
||||||
|
(and (contains? cf/flags :wasm-export)
|
||||||
|
(features/active-feature? state "render-wasm/v1")))
|
||||||
|
|
||||||
|
(defn- use-wasm-export?
|
||||||
|
"Whether to take the client-side WASM export path for `export`."
|
||||||
|
[state export]
|
||||||
|
(and (wasm-export-enabled? state)
|
||||||
|
(contains? wasm-export-types (:type export))))
|
||||||
|
|
||||||
(defn request-simple-export
|
(defn request-simple-export
|
||||||
[{:keys [export]}]
|
[{:keys [export]}]
|
||||||
(if (and (contains? cf/flags :wasm-export)
|
(ptk/reify ::request-simple-export
|
||||||
(contains? #{:jpeg :webp :png} (:type export)))
|
ptk/UpdateEvent
|
||||||
(ptk/reify ::request-simple-export-wasm
|
(update [_ state]
|
||||||
ptk/EffectEvent
|
(cond-> state
|
||||||
(effect [_ _ _]
|
(not (use-wasm-export? state export))
|
||||||
(wasm.exports/export-image export)))
|
(update :export assoc :in-progress true :id uuid/zero)))
|
||||||
|
|
||||||
(ptk/reify ::request-simple-export
|
ptk/WatchEvent
|
||||||
ptk/UpdateEvent
|
(watch [_ state _]
|
||||||
(update [_ state]
|
(if (use-wasm-export? state export)
|
||||||
(update state :export assoc :in-progress true :id uuid/zero))
|
(do
|
||||||
|
(case (:type export)
|
||||||
ptk/WatchEvent
|
:pdf (wasm.exports/export-pdf export)
|
||||||
(watch [_ state _]
|
(wasm.exports/export-image export))
|
||||||
|
(rx/empty))
|
||||||
(let [profile-id (:profile-id state)
|
(let [profile-id (:profile-id state)
|
||||||
params {:exports [export]
|
params {:exports [export]
|
||||||
:profile-id profile-id
|
:profile-id profile-id
|
||||||
:cmd :export-shapes
|
:cmd :export-shapes
|
||||||
:wait true
|
:wait true
|
||||||
:is-wasm
|
:is-wasm (wasm-export-enabled? state)}]
|
||||||
(and
|
|
||||||
(features/active-feature? state "render-wasm/v1")
|
|
||||||
(contains? cf/flags :wasm-export))}]
|
|
||||||
(rx/concat
|
(rx/concat
|
||||||
(rx/of ::dwp/force-persist)
|
(rx/of ::dwp/force-persist)
|
||||||
|
|
||||||
@ -221,10 +236,7 @@
|
|||||||
:cmd cmd
|
:cmd cmd
|
||||||
:profile-id profile-id
|
:profile-id profile-id
|
||||||
:force-multiple true
|
:force-multiple true
|
||||||
:is-wasm
|
:is-wasm (wasm-export-enabled? state)}
|
||||||
(and
|
|
||||||
(features/active-feature? state "render-wasm/v1")
|
|
||||||
(contains? cf/flags :wasm-export))}
|
|
||||||
(some? name)
|
(some? name)
|
||||||
(assoc :name name))
|
(assoc :name name))
|
||||||
|
|
||||||
|
|||||||
@ -26,3 +26,18 @@
|
|||||||
(dom/trigger-download-uri filename mtype url)
|
(dom/trigger-download-uri filename mtype url)
|
||||||
(wapi/revoke-uri url)
|
(wapi/revoke-uri url)
|
||||||
nil))
|
nil))
|
||||||
|
|
||||||
|
(defn export-pdf-uri
|
||||||
|
[{:keys [scale object-id]}]
|
||||||
|
(let [bytes (wasm.api/render-shape-pdf object-id (or scale 1))
|
||||||
|
blob (wapi/create-blob bytes "application/pdf")]
|
||||||
|
(wapi/create-uri blob)))
|
||||||
|
|
||||||
|
(defn export-pdf
|
||||||
|
[{:keys [suffix name] :as params}]
|
||||||
|
(let [url (export-pdf-uri params)
|
||||||
|
filename (str name (or suffix "") ".pdf")]
|
||||||
|
(dom/trigger-download-uri filename "application/pdf" url)
|
||||||
|
(js/queueMicrotask #(wapi/revoke-uri url))
|
||||||
|
nil))
|
||||||
|
|
||||||
|
|||||||
@ -2459,6 +2459,25 @@
|
|||||||
(mem/free)
|
(mem/free)
|
||||||
{:x x :y y :width w :height h}))))
|
{:x x :y y :width w :height h}))))
|
||||||
|
|
||||||
|
(defn render-shape-pdf
|
||||||
|
[shape-id scale]
|
||||||
|
(let [buffer (uuid/get-u32 shape-id)
|
||||||
|
|
||||||
|
offset
|
||||||
|
(h/call wasm/internal-module "_render_shape_pdf"
|
||||||
|
(aget buffer 0)
|
||||||
|
(aget buffer 1)
|
||||||
|
(aget buffer 2)
|
||||||
|
(aget buffer 3)
|
||||||
|
scale)
|
||||||
|
|
||||||
|
heap (mem/get-heap-u8)
|
||||||
|
heapu32 (mem/get-heap-u32)
|
||||||
|
length (aget heapu32 (mem/->offset-32 offset))
|
||||||
|
result (dr/read-image-bytes heap (+ offset 4) length)]
|
||||||
|
(mem/free)
|
||||||
|
result))
|
||||||
|
|
||||||
(defn init-wasm-module
|
(defn init-wasm-module
|
||||||
[module]
|
[module]
|
||||||
(let [default-fn (unchecked-get module "default")
|
(let [default-fn (unchecked-get module "default")
|
||||||
|
|||||||
@ -32,6 +32,7 @@ skia-safe = { version = "0.93.1", default-features = false, features = [
|
|||||||
"textlayout",
|
"textlayout",
|
||||||
"binary-cache",
|
"binary-cache",
|
||||||
"webp",
|
"webp",
|
||||||
|
"pdf",
|
||||||
] }
|
] }
|
||||||
thiserror = "2.0.18"
|
thiserror = "2.0.18"
|
||||||
uuid = { version = "1.11.0", features = ["v4", "js"] }
|
uuid = { version = "1.11.0", features = ["v4", "js"] }
|
||||||
|
|||||||
@ -52,6 +52,7 @@ cd penpot/render-wasm
|
|||||||
|
|
||||||
## Technical documentation
|
## Technical documentation
|
||||||
|
|
||||||
|
- [Rendering Architecture (Live vs Vector/PDF)](./docs/rendering_architecture.md)
|
||||||
- [Serialization](./docs/serialization.md)
|
- [Serialization](./docs/serialization.md)
|
||||||
- [Tile Rendering](./docs/tile_rendering.md)
|
- [Tile Rendering](./docs/tile_rendering.md)
|
||||||
- [Texts](./docs/texts.md)
|
- [Texts](./docs/texts.md)
|
||||||
|
|||||||
131
render-wasm/docs/rendering_architecture.md
Normal file
131
render-wasm/docs/rendering_architecture.md
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
# Rendering Architecture: Live (GPU) vs Vector (PDF) Export
|
||||||
|
|
||||||
|
Penpot's WASM engine has **two render paths** that must produce the same picture:
|
||||||
|
|
||||||
|
| Path | Purpose | Backend | Code |
|
||||||
|
|------|---------|---------|------|
|
||||||
|
| **Live / GPU** | On-screen workspace, thumbnails, PNG export | WebGL surfaces + Skia | `render.rs::render_shape` (+ `render/{fills,strokes,shadows,text,...}.rs`) |
|
||||||
|
| **Vector** | True vector PDF (and future SVG) export | Single CPU Skia canvas (no GPU) | `render/vector.rs` → `render/pdf.rs` |
|
||||||
|
|
||||||
|
They share the same shape tree and the same low-level drawing primitives, but
|
||||||
|
compose them differently. Keeping them in sync is the whole game — see
|
||||||
|
[Parity guards](#parity-guards).
|
||||||
|
|
||||||
|
## Why two paths?
|
||||||
|
|
||||||
|
The live path draws each shape into **many intermediate GPU surfaces** (fills,
|
||||||
|
strokes, shadows, …) and composites them. Compositing rasterises. That is fine
|
||||||
|
for the screen and for PNG, but a PDF made that way would be a bitmap.
|
||||||
|
|
||||||
|
The vector path bypasses the GPU surface system and draws **directly onto a
|
||||||
|
Skia PDF canvas**, so paths, text and fills come out as real PDF vector
|
||||||
|
operations. Only inherently pixel-based effects (blur, blurred shadows) are
|
||||||
|
rasterised — by Skia's PDF backend, by design.
|
||||||
|
|
||||||
|
## The two pipelines
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
tree["Shape tree (ShapesPool)"]
|
||||||
|
|
||||||
|
subgraph GPU["Live / GPU path — render.rs"]
|
||||||
|
direction TB
|
||||||
|
g0["render_shape(shape)"]
|
||||||
|
g1["fast_mode? can_render_directly?<br/>tiles, clip stacks, nested fills/blurs"]
|
||||||
|
gF["fills::render → Surface::Fills"]
|
||||||
|
gS["strokes::render → Surface::Strokes"]
|
||||||
|
gI["shadows::* → Surface::InnerShadows"]
|
||||||
|
gD["drop shadows (tree level)<br/>→ Surface::DropShadows"]
|
||||||
|
gC["draw_shape_surface_stack_into<br/>composite surfaces → final z-order"]
|
||||||
|
g0 --> g1 --> gF --> gS --> gI --> gC
|
||||||
|
gD --> gC
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph SHARED["Shared primitives (one source of truth)"]
|
||||||
|
p1["draw_stroke_on_rect / draw_stroke_on_circle"]
|
||||||
|
p2["handle_stroke_caps (arrows, markers)"]
|
||||||
|
p3["render_inner_stroke / render_overlay_emoji (text)"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph VEC["Vector path — render/vector.rs"]
|
||||||
|
direction TB
|
||||||
|
v0["render_to_pdf → render_tree(shape)"]
|
||||||
|
v1["render_group / render_frame / render_leaf<br/>concat centered_transform, save_layer for opacity/blur"]
|
||||||
|
v2["draw_drop_shadows (inline)"]
|
||||||
|
v3["render_leaf_content<R: ShapeRenderer><br/>fills → fill inner shadows → strokes → stroke inner shadows"]
|
||||||
|
v4["one Skia PDF canvas<br/>final z = draw call order"]
|
||||||
|
v0 --> v1 --> v2 --> v3 --> v4
|
||||||
|
end
|
||||||
|
|
||||||
|
tree --> g0
|
||||||
|
tree --> v0
|
||||||
|
|
||||||
|
gS -.uses.-> SHARED
|
||||||
|
v3 -.uses.-> SHARED
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key differences
|
||||||
|
|
||||||
|
| Aspect | Live / GPU | Vector |
|
||||||
|
|--------|-----------|--------|
|
||||||
|
| Drawing target | Many GPU surfaces, then composited | One Skia PDF canvas |
|
||||||
|
| Final z-order | Surface composite order (`draw_shape_surface_stack_into`) | Order of draw calls |
|
||||||
|
| Drop shadows | Rendered at tree level into a separate surface (`render_element_drop_shadows_and_composite`) | Drawn inline per shape/container (`draw_drop_shadows` / `render_container_drop_shadows`) |
|
||||||
|
| Images | GPU textures | CPU image copies (`get_cpu_image`) |
|
||||||
|
| Blur / blurred shadow | GPU filter passes | Rasterised by Skia's PDF backend |
|
||||||
|
| Perf machinery | tiles, `fast_mode`, `can_render_directly` | none (one-shot export) |
|
||||||
|
|
||||||
|
## Export wiring (single vs multiple)
|
||||||
|
|
||||||
|
The client-side WASM export — rendering in the browser through the vector path
|
||||||
|
(`render_shape_pdf` / `render_shape_pixels`) — is wired **only for single
|
||||||
|
exports** (`request-simple-export` in `frontend/.../exports/assets.cljs`), and
|
||||||
|
only when render-wasm is active and the `:wasm-export` flag is set.
|
||||||
|
|
||||||
|
**Multiple/batch export** (`request-multiple-export`) always runs **server-side**
|
||||||
|
via the `:export-shapes` command; it merely passes an `:is-wasm` hint so the
|
||||||
|
server can use its own WASM renderer. So everything documented here (vector PDF,
|
||||||
|
the fixes, parity) applies to single export only.
|
||||||
|
|
||||||
|
## Parity guards
|
||||||
|
|
||||||
|
Three compile-time guards plus shared code keep the two paths from drifting.
|
||||||
|
The contract is documented on the `ShapeRenderer` trait
|
||||||
|
(`render/shape_renderer.rs`).
|
||||||
|
|
||||||
|
1. **Capability guard.** `ShapeRenderer` is the single declaration of per-shape
|
||||||
|
rendering capabilities (`draw_fills`, `draw_strokes`, `draw_drop_shadows`,
|
||||||
|
…). A new effect MUST be added as a trait method, not inline in
|
||||||
|
`render_shape`. Adding a method fails to compile until the vector backend
|
||||||
|
handles it — so a feature can never be silently missing from PDF.
|
||||||
|
2. **Type guard.** Every `match` on `shape.shape_type` in `vector.rs` is
|
||||||
|
exhaustive (no `_ =>`). A new `Type` variant fails to compile until handled.
|
||||||
|
3. **Order guard.** Leaf content draw order/gating lives in exactly one place:
|
||||||
|
`vector::render_leaf_content<R: ShapeRenderer>`. It is generic over the
|
||||||
|
trait so the GPU backend could reuse it verbatim once it implements
|
||||||
|
`ShapeRenderer`.
|
||||||
|
4. **Shared primitives.** Prefer reusing the live-render functions over
|
||||||
|
mirroring them: `draw_stroke_on_rect`, `draw_stroke_on_circle`,
|
||||||
|
`handle_stroke_caps`, `render_inner_stroke`, `render_overlay_emoji`.
|
||||||
|
Whatever is still duplicated is the remaining drift surface.
|
||||||
|
|
||||||
|
### Not yet done — full unification
|
||||||
|
|
||||||
|
The end goal is for `render_shape` to also implement `ShapeRenderer` and route
|
||||||
|
its leaf rendering through `render_leaf_content`, so both paths share order and
|
||||||
|
gating by construction. This is a large refactor of the live hot path (tiles,
|
||||||
|
`fast_mode`, surface compositing, tree-level drop shadows) and **should be
|
||||||
|
gated by pixel parity tests** (a vector-vs-GPU raster diff harness lives on a
|
||||||
|
separate branch) — do not refactor the live path without that safety net.
|
||||||
|
|
||||||
|
## File map
|
||||||
|
|
||||||
|
| What | Where |
|
||||||
|
|------|-------|
|
||||||
|
| Vector entry / PDF | `render/pdf.rs`, `render/vector.rs` |
|
||||||
|
| Parity trait | `render/shape_renderer.rs` |
|
||||||
|
| Order seam | `render/vector.rs::render_leaf_content` |
|
||||||
|
| Live shape render | `render.rs::render_shape` |
|
||||||
|
| Surface compositing | `render.rs::draw_shape_surface_stack_into` |
|
||||||
|
| Shared stroke geometry / caps | `render/strokes.rs` |
|
||||||
|
| Shared text render | `render/text.rs` |
|
||||||
@ -971,6 +971,22 @@ pub fn free_gpu_resources() {
|
|||||||
get_render_state().free_gpu_resources();
|
get_render_state().free_gpu_resources();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[no_mangle]
|
||||||
|
#[wasm_error]
|
||||||
|
pub extern "C" fn render_shape_pdf(a: u32, b: u32, c: u32, d: u32, scale: f32) -> Result<*mut u8> {
|
||||||
|
let id = uuid_from_u32_quartet(a, b, c, d);
|
||||||
|
|
||||||
|
with_state!(state, {
|
||||||
|
let data = state.render_shape_pdf(&id, scale)?;
|
||||||
|
|
||||||
|
let len = data.len() as u32;
|
||||||
|
let mut buf = Vec::with_capacity(4 + data.len());
|
||||||
|
buf.extend_from_slice(&len.to_le_bytes());
|
||||||
|
buf.extend_from_slice(&data);
|
||||||
|
Ok(mem::write_bytes(buf))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub fn main() {
|
pub fn main() {
|
||||||
// Why an empty main?
|
// Why an empty main?
|
||||||
// Right now with the target `wasm32-unknown-emscripten` it is not possible
|
// Right now with the target `wasm32-unknown-emscripten` it is not possible
|
||||||
|
|||||||
@ -6,13 +6,16 @@ pub mod gpu_state;
|
|||||||
pub mod grid_layout;
|
pub mod grid_layout;
|
||||||
mod images;
|
mod images;
|
||||||
mod options;
|
mod options;
|
||||||
|
pub mod pdf;
|
||||||
pub mod rulers;
|
pub mod rulers;
|
||||||
mod shadows;
|
mod shadows;
|
||||||
|
pub mod shape_renderer;
|
||||||
mod strokes;
|
mod strokes;
|
||||||
mod surfaces;
|
mod surfaces;
|
||||||
pub mod text;
|
pub mod text;
|
||||||
pub mod text_editor;
|
pub mod text_editor;
|
||||||
mod ui;
|
mod ui;
|
||||||
|
mod vector;
|
||||||
|
|
||||||
use skia_safe::{self as skia, Matrix, RRect, Rect};
|
use skia_safe::{self as skia, Matrix, RRect, Rect};
|
||||||
use std::borrow::Cow;
|
use std::borrow::Cow;
|
||||||
|
|||||||
@ -213,6 +213,11 @@ impl ImageStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn get_cpu_image(&mut self, id: &Uuid) -> Option<Image> {
|
||||||
|
let gpu_image = self.get(id)?.clone();
|
||||||
|
gpu_image.make_non_texture_image(self.context.as_mut())
|
||||||
|
}
|
||||||
|
|
||||||
fn get_internal(&mut self, id: &Uuid, is_thumbnail: bool) -> Option<&Image> {
|
fn get_internal(&mut self, id: &Uuid, is_thumbnail: bool) -> Option<&Image> {
|
||||||
let key = (*id, is_thumbnail);
|
let key = (*id, is_thumbnail);
|
||||||
// Use entry API to mutate the HashMap in-place if needed
|
// Use entry API to mutate the HashMap in-place if needed
|
||||||
|
|||||||
55
render-wasm/src/render/pdf.rs
Normal file
55
render-wasm/src/render/pdf.rs
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
use skia_safe as skia;
|
||||||
|
|
||||||
|
use crate::error::Result;
|
||||||
|
use crate::state::ShapesPoolRef;
|
||||||
|
use crate::uuid::Uuid;
|
||||||
|
|
||||||
|
use super::vector::{self, VectorTarget};
|
||||||
|
use super::RenderState;
|
||||||
|
|
||||||
|
/// Renders a shape tree to a PDF document and returns the raw PDF bytes.
|
||||||
|
///
|
||||||
|
/// This is a dedicated vector-PDF render path that draws directly to a Skia
|
||||||
|
/// PDF canvas, bypassing the GPU surface system entirely. The result is a
|
||||||
|
/// true vector PDF — paths, text and fills are represented as PDF drawing
|
||||||
|
/// operations rather than rasterised bitmaps. Effects that are inherently
|
||||||
|
/// pixel-based (blur, shadows with blur) are rasterised internally by Skia's
|
||||||
|
/// PDF backend
|
||||||
|
pub fn render_to_pdf(
|
||||||
|
shared: &mut RenderState,
|
||||||
|
id: &Uuid,
|
||||||
|
tree: ShapesPoolRef,
|
||||||
|
scale: f32,
|
||||||
|
) -> Result<Vec<u8>> {
|
||||||
|
let shape = tree
|
||||||
|
.get(id)
|
||||||
|
.ok_or_else(|| crate::error::Error::CriticalError("Shape not found for PDF".to_string()))?;
|
||||||
|
let bounds = shape.extrect(tree, scale);
|
||||||
|
|
||||||
|
let page_w = bounds.width() * scale;
|
||||||
|
let page_h = bounds.height() * scale;
|
||||||
|
|
||||||
|
let mut pdf_bytes: Vec<u8> = Vec::new();
|
||||||
|
|
||||||
|
let metadata = skia::pdf::Metadata {
|
||||||
|
creator: "Penpot".to_string(),
|
||||||
|
producer: "Penpot (Skia PDF)".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let document = skia::pdf::new_document(&mut pdf_bytes, Some(&metadata));
|
||||||
|
|
||||||
|
let mut on_page = document.begin_page((page_w, page_h), None);
|
||||||
|
|
||||||
|
{
|
||||||
|
let page_canvas = on_page.canvas();
|
||||||
|
page_canvas.scale((scale, scale));
|
||||||
|
page_canvas.translate((-bounds.left(), -bounds.top()));
|
||||||
|
vector::render_tree(shared, page_canvas, id, tree, scale, VectorTarget::Pdf)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let document = on_page.end_page();
|
||||||
|
document.close();
|
||||||
|
|
||||||
|
Ok(pdf_bytes)
|
||||||
|
}
|
||||||
21
render-wasm/src/render/shape_renderer.rs
Normal file
21
render-wasm/src/render/shape_renderer.rs
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
use crate::error::Result;
|
||||||
|
use crate::shapes::{Fill, Shape, Stroke};
|
||||||
|
|
||||||
|
/// Capabilities a leaf shape can render, implemented by the canvas-based vector
|
||||||
|
/// export backend (`vector::VectorRenderer`, used for PDF and future SVG).
|
||||||
|
///
|
||||||
|
/// New per-shape features must be added as a method here (compile error until
|
||||||
|
/// the backend handles it, so nothing is silently missing from vector export);
|
||||||
|
/// draw order/gating lives once in `vector::render_leaf_content`.
|
||||||
|
pub trait ShapeRenderer {
|
||||||
|
fn draw_fills(&mut self, shape: &Shape, fills: &[Fill]) -> Result<()>;
|
||||||
|
fn draw_strokes(&mut self, shape: &Shape, strokes: &[&Stroke]) -> Result<()>;
|
||||||
|
fn draw_drop_shadows(&mut self, shape: &Shape) -> Result<()>;
|
||||||
|
fn draw_fill_inner_shadows(&mut self, shape: &Shape) -> Result<()>;
|
||||||
|
fn draw_stroke_inner_shadows(&mut self, shape: &Shape, stroke: &Stroke) -> Result<()>;
|
||||||
|
fn draw_text(&mut self, shape: &Shape) -> Result<()>;
|
||||||
|
fn draw_svg(&mut self, shape: &Shape) -> Result<()>;
|
||||||
|
/// Returns `true` if a layer was pushed; caller must `restore_blur_layer`.
|
||||||
|
fn apply_blur_layer(&mut self, shape: &Shape) -> bool;
|
||||||
|
fn restore_blur_layer(&mut self);
|
||||||
|
}
|
||||||
@ -12,7 +12,7 @@ use crate::render::filters::compose_filters;
|
|||||||
use crate::render::{get_dest_rect, get_source_rect};
|
use crate::render::{get_dest_rect, get_source_rect};
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn draw_stroke_on_rect(
|
pub(super) fn draw_stroke_on_rect(
|
||||||
canvas: &skia::Canvas,
|
canvas: &skia::Canvas,
|
||||||
stroke: &Stroke,
|
stroke: &Stroke,
|
||||||
rect: &Rect,
|
rect: &Rect,
|
||||||
@ -97,7 +97,7 @@ fn draw_stroke_on_rect(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn draw_stroke_on_circle(
|
pub(super) fn draw_stroke_on_circle(
|
||||||
canvas: &skia::Canvas,
|
canvas: &skia::Canvas,
|
||||||
stroke: &Stroke,
|
stroke: &Stroke,
|
||||||
rect: &Rect,
|
rect: &Rect,
|
||||||
@ -288,7 +288,7 @@ fn handle_stroke_cap(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn handle_stroke_caps(
|
pub(super) fn handle_stroke_caps(
|
||||||
path: &skia::Path,
|
path: &skia::Path,
|
||||||
stroke: &Stroke,
|
stroke: &Stroke,
|
||||||
canvas: &skia::Canvas,
|
canvas: &skia::Canvas,
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
use super::{filters, RenderState, Shape, SurfaceId};
|
use super::{filters, RenderState, Shape, SurfaceId, DEFAULT_EMOJI_FONT};
|
||||||
use crate::{
|
use crate::{
|
||||||
error::Result,
|
error::Result,
|
||||||
math::Rect,
|
math::Rect,
|
||||||
shapes::{
|
shapes::{
|
||||||
calculate_text_layout_data, set_paint_fill, ParagraphBuilderGroup, Stroke, StrokeKind,
|
calculate_text_layout_data, set_paint_fill, ParagraphBuilderGroup, ParagraphLayout, Stroke,
|
||||||
TextContent,
|
StrokeKind, TextContent,
|
||||||
},
|
},
|
||||||
utils::{get_fallback_fonts, get_font_collection},
|
utils::{get_fallback_fonts, get_font_collection},
|
||||||
};
|
};
|
||||||
@ -150,6 +150,62 @@ pub fn render_with_bounds_outset(
|
|||||||
stroke_bounds_outset: f32,
|
stroke_bounds_outset: f32,
|
||||||
fill_inset: Option<f32>,
|
fill_inset: Option<f32>,
|
||||||
layer_opacity: Option<f32>,
|
layer_opacity: Option<f32>,
|
||||||
|
) -> Result<()> {
|
||||||
|
render_with_bounds_outset_inner(
|
||||||
|
render_state,
|
||||||
|
canvas,
|
||||||
|
shape,
|
||||||
|
paragraph_builders,
|
||||||
|
surface_id,
|
||||||
|
shadow,
|
||||||
|
blur,
|
||||||
|
stroke_bounds_outset,
|
||||||
|
fill_inset,
|
||||||
|
layer_opacity,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like [`render_with_bounds_outset`] but with emoji bitmap overlay for PDF/vector export.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn render_with_bounds_outset_overlay_emoji(
|
||||||
|
canvas: &Canvas,
|
||||||
|
shape: &Shape,
|
||||||
|
paragraph_builders: &mut [Vec<ParagraphBuilder>],
|
||||||
|
shadow: Option<&Paint>,
|
||||||
|
blur: Option<&ImageFilter>,
|
||||||
|
stroke_bounds_outset: f32,
|
||||||
|
fill_inset: Option<f32>,
|
||||||
|
layer_opacity: Option<f32>,
|
||||||
|
) -> Result<()> {
|
||||||
|
render_with_bounds_outset_inner(
|
||||||
|
None,
|
||||||
|
Some(canvas),
|
||||||
|
shape,
|
||||||
|
paragraph_builders,
|
||||||
|
None,
|
||||||
|
shadow,
|
||||||
|
blur,
|
||||||
|
stroke_bounds_outset,
|
||||||
|
fill_inset,
|
||||||
|
layer_opacity,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn render_with_bounds_outset_inner(
|
||||||
|
render_state: Option<&mut RenderState>,
|
||||||
|
canvas: Option<&Canvas>,
|
||||||
|
shape: &Shape,
|
||||||
|
paragraph_builders: &mut [Vec<ParagraphBuilder>],
|
||||||
|
surface_id: Option<SurfaceId>,
|
||||||
|
shadow: Option<&Paint>,
|
||||||
|
blur: Option<&ImageFilter>,
|
||||||
|
stroke_bounds_outset: f32,
|
||||||
|
fill_inset: Option<f32>,
|
||||||
|
layer_opacity: Option<f32>,
|
||||||
|
overlay_emoji: bool,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
if let Some(render_state) = render_state {
|
if let Some(render_state) = render_state {
|
||||||
let target_surface = surface_id.unwrap_or(SurfaceId::Fills);
|
let target_surface = surface_id.unwrap_or(SurfaceId::Fills);
|
||||||
@ -179,6 +235,7 @@ pub fn render_with_bounds_outset(
|
|||||||
Some(&blur_filter_clone),
|
Some(&blur_filter_clone),
|
||||||
fill_inset,
|
fill_inset,
|
||||||
layer_opacity,
|
layer_opacity,
|
||||||
|
false,
|
||||||
);
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
},
|
},
|
||||||
@ -197,6 +254,7 @@ pub fn render_with_bounds_outset(
|
|||||||
blur,
|
blur,
|
||||||
fill_inset,
|
fill_inset,
|
||||||
layer_opacity,
|
layer_opacity,
|
||||||
|
false,
|
||||||
);
|
);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@ -210,6 +268,7 @@ pub fn render_with_bounds_outset(
|
|||||||
blur,
|
blur,
|
||||||
fill_inset,
|
fill_inset,
|
||||||
layer_opacity,
|
layer_opacity,
|
||||||
|
overlay_emoji,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@ -241,6 +300,30 @@ pub fn render(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Like [`render`] but rasterizes color emoji as bitmap overlays for PDF/vector export.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn render_overlay_emoji(
|
||||||
|
canvas: &Canvas,
|
||||||
|
shape: &Shape,
|
||||||
|
paragraph_builders: &mut [Vec<ParagraphBuilder>],
|
||||||
|
shadow: Option<&Paint>,
|
||||||
|
blur: Option<&ImageFilter>,
|
||||||
|
fill_inset: Option<f32>,
|
||||||
|
layer_opacity: Option<f32>,
|
||||||
|
) -> Result<()> {
|
||||||
|
render_with_bounds_outset_overlay_emoji(
|
||||||
|
canvas,
|
||||||
|
shape,
|
||||||
|
paragraph_builders,
|
||||||
|
shadow,
|
||||||
|
blur,
|
||||||
|
0.0,
|
||||||
|
fill_inset,
|
||||||
|
layer_opacity,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn render_text_on_canvas(
|
fn render_text_on_canvas(
|
||||||
canvas: &Canvas,
|
canvas: &Canvas,
|
||||||
shape: &Shape,
|
shape: &Shape,
|
||||||
@ -249,6 +332,7 @@ fn render_text_on_canvas(
|
|||||||
blur: Option<&ImageFilter>,
|
blur: Option<&ImageFilter>,
|
||||||
fill_inset: Option<f32>,
|
fill_inset: Option<f32>,
|
||||||
layer_opacity: Option<f32>,
|
layer_opacity: Option<f32>,
|
||||||
|
overlay_emoji: bool,
|
||||||
) {
|
) {
|
||||||
if let Some(blur_filter) = blur {
|
if let Some(blur_filter) = blur {
|
||||||
let mut blur_paint = Paint::default();
|
let mut blur_paint = Paint::default();
|
||||||
@ -260,7 +344,13 @@ fn render_text_on_canvas(
|
|||||||
if let Some(shadow_paint) = shadow {
|
if let Some(shadow_paint) = shadow {
|
||||||
let layer_rec = SaveLayerRec::default().paint(shadow_paint);
|
let layer_rec = SaveLayerRec::default().paint(shadow_paint);
|
||||||
canvas.save_layer(&layer_rec);
|
canvas.save_layer(&layer_rec);
|
||||||
draw_text(canvas, shape, paragraph_builders, layer_opacity);
|
draw_text(
|
||||||
|
canvas,
|
||||||
|
shape,
|
||||||
|
paragraph_builders,
|
||||||
|
layer_opacity,
|
||||||
|
overlay_emoji,
|
||||||
|
);
|
||||||
canvas.restore();
|
canvas.restore();
|
||||||
} else if let Some(eps) = fill_inset.filter(|&e| e > 0.0) {
|
} else if let Some(eps) = fill_inset.filter(|&e| e > 0.0) {
|
||||||
if let Some(erode) = skia_safe::image_filters::erode((eps, eps), None, None) {
|
if let Some(erode) = skia_safe::image_filters::erode((eps, eps), None, None) {
|
||||||
@ -268,13 +358,31 @@ fn render_text_on_canvas(
|
|||||||
layer_paint.set_image_filter(erode);
|
layer_paint.set_image_filter(erode);
|
||||||
let layer_rec = SaveLayerRec::default().paint(&layer_paint);
|
let layer_rec = SaveLayerRec::default().paint(&layer_paint);
|
||||||
canvas.save_layer(&layer_rec);
|
canvas.save_layer(&layer_rec);
|
||||||
draw_text(canvas, shape, paragraph_builders, layer_opacity);
|
draw_text(
|
||||||
|
canvas,
|
||||||
|
shape,
|
||||||
|
paragraph_builders,
|
||||||
|
layer_opacity,
|
||||||
|
overlay_emoji,
|
||||||
|
);
|
||||||
canvas.restore();
|
canvas.restore();
|
||||||
} else {
|
} else {
|
||||||
draw_text(canvas, shape, paragraph_builders, layer_opacity);
|
draw_text(
|
||||||
|
canvas,
|
||||||
|
shape,
|
||||||
|
paragraph_builders,
|
||||||
|
layer_opacity,
|
||||||
|
overlay_emoji,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
draw_text(canvas, shape, paragraph_builders, layer_opacity);
|
draw_text(
|
||||||
|
canvas,
|
||||||
|
shape,
|
||||||
|
paragraph_builders,
|
||||||
|
layer_opacity,
|
||||||
|
overlay_emoji,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if blur.is_some() {
|
if blur.is_some() {
|
||||||
@ -289,6 +397,15 @@ fn paint_text(
|
|||||||
canvas: &Canvas,
|
canvas: &Canvas,
|
||||||
shape: &Shape,
|
shape: &Shape,
|
||||||
paragraph_builder_groups: &mut [Vec<ParagraphBuilder>],
|
paragraph_builder_groups: &mut [Vec<ParagraphBuilder>],
|
||||||
|
) {
|
||||||
|
paint_text_with_emoji_overlay(canvas, shape, paragraph_builder_groups, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn paint_text_with_emoji_overlay(
|
||||||
|
canvas: &Canvas,
|
||||||
|
shape: &Shape,
|
||||||
|
paragraph_builder_groups: &mut [Vec<ParagraphBuilder>],
|
||||||
|
overlay_emoji: bool,
|
||||||
) {
|
) {
|
||||||
let text_content = shape.get_text_content();
|
let text_content = shape.get_text_content();
|
||||||
let layout_info =
|
let layout_info =
|
||||||
@ -296,6 +413,11 @@ fn paint_text(
|
|||||||
|
|
||||||
for para in &layout_info.paragraphs {
|
for para in &layout_info.paragraphs {
|
||||||
para.paragraph.paint(canvas, (para.x, para.y));
|
para.paragraph.paint(canvas, (para.x, para.y));
|
||||||
|
|
||||||
|
if overlay_emoji {
|
||||||
|
paint_emoji_overlay(canvas, para);
|
||||||
|
}
|
||||||
|
|
||||||
for deco in ¶.decorations {
|
for deco in ¶.decorations {
|
||||||
draw_text_decorations(
|
draw_text_decorations(
|
||||||
canvas,
|
canvas,
|
||||||
@ -309,11 +431,123 @@ fn paint_text(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Rasterizes color emoji runs as bitmap overlays. Skia's PDF backend can't
|
||||||
|
/// embed COLR/CBDT color glyphs, so each emoji is drawn to a raster surface and
|
||||||
|
/// blitted; `paragraph.paint()` already wrote placeholder glyphs (keeps text
|
||||||
|
/// selectable).
|
||||||
|
fn paint_emoji_overlay(canvas: &Canvas, para: &ParagraphLayout) {
|
||||||
|
let line_metrics = para.paragraph.get_line_metrics();
|
||||||
|
|
||||||
|
// Rasterize at TARGET_DPI relative to the emoji's on-page size (72 user
|
||||||
|
// units = 1 inch), capped at MAX_RASTER_PX so a huge font can't allocate
|
||||||
|
// an unbounded surface.
|
||||||
|
const TARGET_DPI: f32 = 600.0;
|
||||||
|
const PDF_POINTS_PER_INCH: f32 = 72.0;
|
||||||
|
const MAX_RASTER_PX: f32 = 2048.0;
|
||||||
|
|
||||||
|
let ctm = canvas.local_to_device_as_3x3();
|
||||||
|
let sx = (ctm.scale_x().powi(2) + ctm.skew_y().powi(2)).sqrt();
|
||||||
|
let sy = (ctm.skew_x().powi(2) + ctm.scale_y().powi(2)).sqrt();
|
||||||
|
let output_scale = sx.max(sy).max(1.0);
|
||||||
|
|
||||||
|
for line in &line_metrics {
|
||||||
|
let style_runs = line.get_style_metrics(line.start_index..line.end_index);
|
||||||
|
|
||||||
|
// Build a list of (start, end, is_emoji) for each style run.
|
||||||
|
let mut run_info: Vec<(usize, usize, bool)> = Vec::new();
|
||||||
|
for (i, (start_idx, _style_metric)) in style_runs.iter().enumerate() {
|
||||||
|
let end_idx = style_runs.get(i + 1).map_or(line.end_index, |next| next.0);
|
||||||
|
if *start_idx >= end_idx {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let font = para.paragraph.get_font_at(*start_idx);
|
||||||
|
let family_name = font.typeface().family_name();
|
||||||
|
|
||||||
|
let normalized = family_name.to_lowercase().replace(' ', "-");
|
||||||
|
let is_emoji = normalized.contains(DEFAULT_EMOJI_FONT);
|
||||||
|
run_info.push((*start_idx, end_idx, is_emoji));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge consecutive emoji runs: Skia splits ZWJ sequences (e.g. 👩🏿🚀)
|
||||||
|
// per codepoint, but `get_rects_for_range` needs the full cluster range.
|
||||||
|
let mut merged_emoji_ranges: Vec<(usize, usize)> = Vec::new();
|
||||||
|
for &(start, end, is_emoji) in &run_info {
|
||||||
|
if is_emoji {
|
||||||
|
if let Some(last) = merged_emoji_ranges.last_mut() {
|
||||||
|
if last.1 == start {
|
||||||
|
// Extend the previous range
|
||||||
|
last.1 = end;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
merged_emoji_ranges.push((start, end));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (range_start, range_end) in &merged_emoji_ranges {
|
||||||
|
// Get the bounding rects for this (possibly merged) emoji run
|
||||||
|
let rects = para.paragraph.get_rects_for_range(
|
||||||
|
*range_start..*range_end,
|
||||||
|
skia::textlayout::RectHeightStyle::Tight,
|
||||||
|
skia::textlayout::RectWidthStyle::Tight,
|
||||||
|
);
|
||||||
|
|
||||||
|
for text_box in &rects {
|
||||||
|
let r = &text_box.rect;
|
||||||
|
let w = r.width();
|
||||||
|
let h = r.height();
|
||||||
|
if w <= 0.0 || h <= 0.0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render at TARGET_DPI relative to the emoji's final on-page
|
||||||
|
// size, clamped so the surface stays within MAX_RASTER_PX.
|
||||||
|
let mut raster_scale = output_scale * (TARGET_DPI / PDF_POINTS_PER_INCH);
|
||||||
|
let max_dim = w.max(h) * raster_scale;
|
||||||
|
if max_dim > MAX_RASTER_PX {
|
||||||
|
raster_scale *= MAX_RASTER_PX / max_dim;
|
||||||
|
}
|
||||||
|
let raster_w = (w * raster_scale).ceil() as i32;
|
||||||
|
let raster_h = (h * raster_scale).ceil() as i32;
|
||||||
|
|
||||||
|
let info = skia::ImageInfo::new_n32_premul((raster_w, raster_h), None);
|
||||||
|
let Some(mut raster) = skia::surfaces::raster(&info, None, None) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let rc = raster.canvas();
|
||||||
|
rc.clear(skia::Color::TRANSPARENT);
|
||||||
|
rc.scale((raster_scale, raster_scale));
|
||||||
|
// Translate so the emoji rect origin maps to (0,0)
|
||||||
|
rc.translate((-r.left, -r.top));
|
||||||
|
para.paragraph.paint(rc, (0.0, 0.0));
|
||||||
|
|
||||||
|
let image = raster.image_snapshot();
|
||||||
|
|
||||||
|
// Draw the rasterized emoji onto the PDF canvas at the
|
||||||
|
// correct position (paragraph offset + emoji rect origin).
|
||||||
|
let dest = skia::Rect::from_xywh(para.x + r.left, para.y + r.top, w, h);
|
||||||
|
|
||||||
|
let sampling = skia::SamplingOptions::from(skia::CubicResampler::mitchell());
|
||||||
|
canvas.draw_image_rect_with_sampling_options(
|
||||||
|
&image,
|
||||||
|
None,
|
||||||
|
dest,
|
||||||
|
sampling,
|
||||||
|
&Paint::default(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn draw_text(
|
fn draw_text(
|
||||||
canvas: &Canvas,
|
canvas: &Canvas,
|
||||||
shape: &Shape,
|
shape: &Shape,
|
||||||
paragraph_builder_groups: &mut [Vec<ParagraphBuilder>],
|
paragraph_builder_groups: &mut [Vec<ParagraphBuilder>],
|
||||||
layer_opacity: Option<f32>,
|
layer_opacity: Option<f32>,
|
||||||
|
overlay_emoji: bool,
|
||||||
) {
|
) {
|
||||||
if let Some(opacity) = layer_opacity {
|
if let Some(opacity) = layer_opacity {
|
||||||
let mut opacity_paint = Paint::default();
|
let mut opacity_paint = Paint::default();
|
||||||
@ -324,7 +558,7 @@ fn draw_text(
|
|||||||
canvas.save_layer(&SaveLayerRec::default());
|
canvas.save_layer(&SaveLayerRec::default());
|
||||||
}
|
}
|
||||||
|
|
||||||
paint_text(canvas, shape, paragraph_builder_groups);
|
paint_text_with_emoji_overlay(canvas, shape, paragraph_builder_groups, overlay_emoji);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Renders an inner stroke using mask + SrcIn + DstOver layer structure.
|
/// Renders an inner stroke using mask + SrcIn + DstOver layer structure.
|
||||||
|
|||||||
957
render-wasm/src/render/vector.rs
Normal file
957
render-wasm/src/render/vector.rs
Normal file
@ -0,0 +1,957 @@
|
|||||||
|
use skia_safe::{self as skia, Canvas, Paint, RRect};
|
||||||
|
|
||||||
|
use crate::error::Result;
|
||||||
|
use crate::shapes::{
|
||||||
|
merge_fills, radius_to_sigma, BlurType, Fill, Frame, Rect, Shape, Stroke, StrokeKind, Type,
|
||||||
|
};
|
||||||
|
use crate::state::ShapesPoolRef;
|
||||||
|
use crate::uuid::Uuid;
|
||||||
|
|
||||||
|
use super::shape_renderer::ShapeRenderer;
|
||||||
|
use super::text;
|
||||||
|
use super::RenderState;
|
||||||
|
use super::{get_dest_rect, get_source_rect};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// VectorTarget — vector export backend selector
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Vector export backend selector (PDF today; SVG could be added as a variant).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub(super) enum VectorTarget {
|
||||||
|
Pdf,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// VectorRenderer — implements ShapeRenderer for canvas-based vector export
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Canvas-based vector render backend (CPU Skia canvas, no GPU surfaces).
|
||||||
|
pub(super) struct VectorRenderer<'a> {
|
||||||
|
canvas: &'a Canvas,
|
||||||
|
shared: &'a mut RenderState,
|
||||||
|
scale: f32,
|
||||||
|
_target: VectorTarget,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> VectorRenderer<'a> {
|
||||||
|
pub fn new(
|
||||||
|
canvas: &'a Canvas,
|
||||||
|
shared: &'a mut RenderState,
|
||||||
|
scale: f32,
|
||||||
|
target: VectorTarget,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
canvas,
|
||||||
|
shared,
|
||||||
|
scale,
|
||||||
|
_target: target,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ShapeRenderer for VectorRenderer<'_> {
|
||||||
|
fn draw_fills(&mut self, shape: &Shape, fills: &[Fill]) -> Result<()> {
|
||||||
|
if fills.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle image fills individually
|
||||||
|
let has_image_fills = fills.iter().any(|f| matches!(f, Fill::Image(_)));
|
||||||
|
if has_image_fills {
|
||||||
|
for fill in fills.iter().rev() {
|
||||||
|
match fill {
|
||||||
|
Fill::Image(image_fill) => {
|
||||||
|
draw_image_fill(self.shared, self.canvas, shape, image_fill)?;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let mut paint = fill.to_paint(&shape.selrect, true);
|
||||||
|
if let Some(filter) = shape.image_filter(1.) {
|
||||||
|
paint.set_image_filter(filter);
|
||||||
|
}
|
||||||
|
draw_shape_geometry(self.canvas, shape, &paint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut paint = merge_fills(fills, shape.selrect);
|
||||||
|
paint.set_anti_alias(true);
|
||||||
|
|
||||||
|
if let Some(filter) = shape.image_filter(1.) {
|
||||||
|
paint.set_image_filter(filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
draw_shape_geometry(self.canvas, shape, &paint);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_strokes(&mut self, shape: &Shape, strokes: &[&Stroke]) -> Result<()> {
|
||||||
|
for stroke in strokes.iter().rev() {
|
||||||
|
draw_single_stroke(self.canvas, self.shared, self.scale, shape, stroke)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_drop_shadows(&mut self, shape: &Shape) -> Result<()> {
|
||||||
|
for shadow in shape.drop_shadows_visible() {
|
||||||
|
if let Some(filter) = shadow.get_drop_shadow_filter() {
|
||||||
|
let mut paint = Paint::default();
|
||||||
|
paint.set_image_filter(filter);
|
||||||
|
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint);
|
||||||
|
self.canvas.save_layer(&layer_rec);
|
||||||
|
let mut fill_paint = Paint::default();
|
||||||
|
fill_paint.set_anti_alias(true);
|
||||||
|
fill_paint.set_color(skia::Color::BLACK);
|
||||||
|
draw_shape_geometry(self.canvas, shape, &fill_paint);
|
||||||
|
self.canvas.restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_fill_inner_shadows(&mut self, shape: &Shape) -> Result<()> {
|
||||||
|
if !shape.has_fills() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
for shadow in shape.inner_shadows_visible() {
|
||||||
|
let paint = shadow.get_inner_shadow_paint(true, shape.image_filter(1.).as_ref());
|
||||||
|
self.canvas
|
||||||
|
.save_layer(&skia::canvas::SaveLayerRec::default().paint(&paint));
|
||||||
|
let mut fill_paint = Paint::default();
|
||||||
|
fill_paint.set_anti_alias(true);
|
||||||
|
fill_paint.set_color(skia::Color::BLACK);
|
||||||
|
draw_shape_geometry(self.canvas, shape, &fill_paint);
|
||||||
|
self.canvas.restore();
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_stroke_inner_shadows(&mut self, shape: &Shape, stroke: &Stroke) -> Result<()> {
|
||||||
|
let is_open = shape.is_open();
|
||||||
|
for shadow in shape.inner_shadows_visible() {
|
||||||
|
if let Some(filter) = shadow.get_inner_shadow_filter() {
|
||||||
|
let mut paint = stroke.to_stroked_paint(
|
||||||
|
is_open,
|
||||||
|
&shape.selrect,
|
||||||
|
shape.svg_attrs.as_ref(),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
paint.set_image_filter(filter);
|
||||||
|
draw_shape_geometry(self.canvas, shape, &paint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_text(&mut self, shape: &Shape) -> Result<()> {
|
||||||
|
let Type::Text(text_content) = &shape.shape_type else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
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.);
|
||||||
|
|
||||||
|
// Text drop shadows: one filter layer per shadow over fill + stroke
|
||||||
|
// silhouettes (mirrors GPU `render_text_shadows`).
|
||||||
|
let drop_shadows = shape.drop_shadow_paints();
|
||||||
|
if !drop_shadows.is_empty() {
|
||||||
|
let shadow_stroke_outset = Stroke::max_bounds_width(shape.visible_strokes(), false);
|
||||||
|
let mut shadow_paragraphs = text_content.paragraph_builder_group_from_text(Some(true));
|
||||||
|
let mut stroke_shadow_groups: Vec<(StrokeKind, _)> = shape
|
||||||
|
.visible_strokes()
|
||||||
|
.rev()
|
||||||
|
.map(|stroke| {
|
||||||
|
(
|
||||||
|
stroke.render_kind(false),
|
||||||
|
text::stroke_paragraph_builder_group_from_text(
|
||||||
|
&text_content,
|
||||||
|
stroke,
|
||||||
|
&shape.selrect(),
|
||||||
|
Some(true),
|
||||||
|
)
|
||||||
|
.0,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for shadow_paint in &drop_shadows {
|
||||||
|
self.canvas
|
||||||
|
.save_layer(&skia::canvas::SaveLayerRec::default().paint(shadow_paint));
|
||||||
|
|
||||||
|
text::render_overlay_emoji(
|
||||||
|
self.canvas,
|
||||||
|
shape,
|
||||||
|
&mut shadow_paragraphs,
|
||||||
|
None,
|
||||||
|
blur_filter.as_ref(),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
for (kind, stroke_paragraphs) in &mut stroke_shadow_groups {
|
||||||
|
if *kind == StrokeKind::Inner {
|
||||||
|
// Inner stroke masked by the glyph fill (outset 0 here).
|
||||||
|
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(self.canvas),
|
||||||
|
shape,
|
||||||
|
&mut mask_builders,
|
||||||
|
stroke_paragraphs,
|
||||||
|
&mut fill_builders,
|
||||||
|
None,
|
||||||
|
blur_filter.as_ref(),
|
||||||
|
0.0,
|
||||||
|
None,
|
||||||
|
)?;
|
||||||
|
} else {
|
||||||
|
text::render_with_bounds_outset_overlay_emoji(
|
||||||
|
self.canvas,
|
||||||
|
shape,
|
||||||
|
stroke_paragraphs,
|
||||||
|
None,
|
||||||
|
blur_filter.as_ref(),
|
||||||
|
shadow_stroke_outset,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.canvas.restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
text::render_overlay_emoji(
|
||||||
|
self.canvas,
|
||||||
|
shape,
|
||||||
|
&mut paragraph_builders,
|
||||||
|
None,
|
||||||
|
blur_filter.as_ref(),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// Strokes for text
|
||||||
|
let stroke_blur_outset = Stroke::max_bounds_width(shape.visible_strokes(), false);
|
||||||
|
|
||||||
|
for stroke in shape.visible_strokes().rev() {
|
||||||
|
let (mut stroke_paragraphs, layer_opacity) =
|
||||||
|
text::stroke_paragraph_builder_group_from_text(
|
||||||
|
&text_content,
|
||||||
|
stroke,
|
||||||
|
&shape.selrect(),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
if stroke.render_kind(false) == StrokeKind::Inner {
|
||||||
|
// Inner text stroke: clip to the glyph fill, else it bleeds out.
|
||||||
|
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(
|
||||||
|
None,
|
||||||
|
Some(self.canvas),
|
||||||
|
shape,
|
||||||
|
&mut mask_builders,
|
||||||
|
&mut stroke_paragraphs,
|
||||||
|
&mut fill_builders,
|
||||||
|
None,
|
||||||
|
blur_filter.as_ref(),
|
||||||
|
stroke_blur_outset,
|
||||||
|
layer_opacity,
|
||||||
|
)?;
|
||||||
|
} else {
|
||||||
|
text::render_with_bounds_outset_overlay_emoji(
|
||||||
|
self.canvas,
|
||||||
|
shape,
|
||||||
|
&mut stroke_paragraphs,
|
||||||
|
None,
|
||||||
|
blur_filter.as_ref(),
|
||||||
|
stroke_blur_outset,
|
||||||
|
None,
|
||||||
|
layer_opacity,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inner shadows for text
|
||||||
|
let inner_shadows: Vec<_> = shape.inner_shadows_visible().collect();
|
||||||
|
if !inner_shadows.is_empty() {
|
||||||
|
let mut shadow_paragraphs = text_content.paragraph_builder_group_from_text(Some(true));
|
||||||
|
for shadow in &inner_shadows {
|
||||||
|
let shadow_paint = shadow.get_inner_shadow_paint(true, blur_filter.as_ref());
|
||||||
|
text::render_overlay_emoji(
|
||||||
|
self.canvas,
|
||||||
|
shape,
|
||||||
|
&mut shadow_paragraphs,
|
||||||
|
Some(&shadow_paint),
|
||||||
|
blur_filter.as_ref(),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_svg(&mut self, shape: &Shape) -> Result<()> {
|
||||||
|
let Type::SVGRaw(sr) = &shape.shape_type else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(svg_transform) = shape.svg_transform() {
|
||||||
|
self.canvas.concat(&svg_transform);
|
||||||
|
}
|
||||||
|
if let Some(svg) = shape.svg.as_ref() {
|
||||||
|
svg.render(self.canvas);
|
||||||
|
} else {
|
||||||
|
let font_manager = skia::FontMgr::from(self.shared.fonts.font_provider().clone());
|
||||||
|
if let Ok(dom) = skia::svg::Dom::from_str(&sr.content, font_manager) {
|
||||||
|
dom.render(self.canvas);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_blur_layer(&mut self, shape: &Shape) -> bool {
|
||||||
|
let blur = match shape.blur {
|
||||||
|
Some(b) if !b.hidden && b.blur_type == BlurType::LayerBlur && b.value > 0.0 => b,
|
||||||
|
_ => return false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let sigma = radius_to_sigma(blur.value * self.scale);
|
||||||
|
if let Some(filter) = skia::image_filters::blur((sigma, sigma), None, None, None) {
|
||||||
|
let mut paint = Paint::default();
|
||||||
|
paint.set_image_filter(filter);
|
||||||
|
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint);
|
||||||
|
self.canvas.save_layer(&layer_rec);
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn restore_blur_layer(&mut self) {
|
||||||
|
self.canvas.restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tree traversal
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Depth-first render of the shape tree rooted at `id`.
|
||||||
|
pub(super) fn render_tree(
|
||||||
|
shared: &mut RenderState,
|
||||||
|
canvas: &Canvas,
|
||||||
|
id: &Uuid,
|
||||||
|
tree: ShapesPoolRef,
|
||||||
|
scale: f32,
|
||||||
|
target: VectorTarget,
|
||||||
|
) -> Result<()> {
|
||||||
|
let Some(element) = tree.get(id) else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
if element.hidden {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
match &element.shape_type {
|
||||||
|
Type::Group(group) => {
|
||||||
|
render_group(shared, canvas, element, group.masked, tree, scale, target)?;
|
||||||
|
}
|
||||||
|
Type::Frame(_) => {
|
||||||
|
render_frame(shared, canvas, element, tree, scale, target)?;
|
||||||
|
}
|
||||||
|
// Leaf types listed explicitly (no `_`) so a new Type must be handled.
|
||||||
|
Type::Rect(_)
|
||||||
|
| Type::Circle
|
||||||
|
| Type::Path(_)
|
||||||
|
| Type::Bool(_)
|
||||||
|
| Type::Text(_)
|
||||||
|
| Type::SVGRaw(_) => {
|
||||||
|
render_leaf(shared, canvas, element, scale, target)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Groups
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn render_group(
|
||||||
|
shared: &mut RenderState,
|
||||||
|
canvas: &Canvas,
|
||||||
|
element: &Shape,
|
||||||
|
masked: bool,
|
||||||
|
tree: ShapesPoolRef,
|
||||||
|
scale: f32,
|
||||||
|
target: VectorTarget,
|
||||||
|
) -> Result<()> {
|
||||||
|
// A group has no geometry of its own and does NOT propagate a transform to
|
||||||
|
// its children: child shapes are stored in absolute coordinates and each
|
||||||
|
// applies its own `centered_transform`. (Concatenating the group transform
|
||||||
|
// here would double-apply it to children — visible on rotated/nested groups.)
|
||||||
|
canvas.save();
|
||||||
|
|
||||||
|
// Group drop shadow: subtree silhouette, below the opacity/clip layer.
|
||||||
|
render_container_drop_shadows(shared, canvas, element, tree, scale, target, false)?;
|
||||||
|
|
||||||
|
// Layer for opacity / blend mode (and group-level layer blur)
|
||||||
|
let needs_layer = element.needs_layer();
|
||||||
|
if needs_layer {
|
||||||
|
let mut paint = Paint::default();
|
||||||
|
paint.set_blend_mode(element.blend_mode().into());
|
||||||
|
paint.set_alpha_f(element.opacity());
|
||||||
|
|
||||||
|
if let Some(blur) = element
|
||||||
|
.blur
|
||||||
|
.filter(|b| !b.hidden && b.blur_type == BlurType::LayerBlur && b.value > 0.0)
|
||||||
|
{
|
||||||
|
let sigma = radius_to_sigma(blur.value * scale);
|
||||||
|
if let Some(filter) = skia::image_filters::blur((sigma, sigma), None, None, None) {
|
||||||
|
paint.set_image_filter(filter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint);
|
||||||
|
canvas.save_layer(&layer_rec);
|
||||||
|
}
|
||||||
|
|
||||||
|
let children: Vec<Uuid> = element.children_ids_iter_forward(false).copied().collect();
|
||||||
|
|
||||||
|
if masked {
|
||||||
|
// Mirror the GPU mask: render all children (including the mask shape)
|
||||||
|
// as content, then re-draw the mask silhouette (the group's first child)
|
||||||
|
// with DstIn to clip everything to it.
|
||||||
|
let paint = Paint::default();
|
||||||
|
canvas.save_layer(&skia::canvas::SaveLayerRec::default().paint(&paint));
|
||||||
|
|
||||||
|
for child_id in &children {
|
||||||
|
render_tree(shared, canvas, child_id, tree, scale, target)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(mask_id) = element.mask_id() {
|
||||||
|
let mut mask_paint = Paint::default();
|
||||||
|
mask_paint.set_blend_mode(skia::BlendMode::DstIn);
|
||||||
|
canvas.save_layer(&skia::canvas::SaveLayerRec::default().paint(&mask_paint));
|
||||||
|
render_tree(shared, canvas, mask_id, tree, scale, target)?;
|
||||||
|
canvas.restore(); // mask layer
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.restore(); // composition layer
|
||||||
|
} else {
|
||||||
|
for child_id in &children {
|
||||||
|
render_tree(shared, canvas, child_id, tree, scale, target)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if needs_layer {
|
||||||
|
canvas.restore(); // opacity/blend layer
|
||||||
|
}
|
||||||
|
canvas.restore();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Frames
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn render_frame(
|
||||||
|
shared: &mut RenderState,
|
||||||
|
canvas: &Canvas,
|
||||||
|
element: &Shape,
|
||||||
|
tree: ShapesPoolRef,
|
||||||
|
scale: f32,
|
||||||
|
target: VectorTarget,
|
||||||
|
) -> Result<()> {
|
||||||
|
// A frame's own geometry (background, clip, strokes) is placed by its
|
||||||
|
// `centered_transform`, but — like groups — it does NOT propagate that
|
||||||
|
// transform to its children, which are stored in absolute coordinates. So
|
||||||
|
// the transform is applied only around the frame's own draws; children are
|
||||||
|
// rendered untransformed.
|
||||||
|
let matrix = element.centered_transform();
|
||||||
|
|
||||||
|
canvas.save();
|
||||||
|
|
||||||
|
// Frame drop shadow: background + subtree silhouette, below the clip layer
|
||||||
|
// so it extends outside the frame bounds.
|
||||||
|
render_container_drop_shadows(shared, canvas, element, tree, scale, target, true)?;
|
||||||
|
|
||||||
|
let needs_layer = element.needs_layer();
|
||||||
|
|
||||||
|
if needs_layer {
|
||||||
|
let mut paint = Paint::default();
|
||||||
|
paint.set_blend_mode(element.blend_mode().into());
|
||||||
|
paint.set_alpha_f(element.opacity());
|
||||||
|
|
||||||
|
// Frame-level layer blur
|
||||||
|
if let Some(blur) = element
|
||||||
|
.blur
|
||||||
|
.filter(|b| !b.hidden && b.blur_type == BlurType::LayerBlur && b.value > 0.0)
|
||||||
|
{
|
||||||
|
let sigma = radius_to_sigma(blur.value * scale);
|
||||||
|
if let Some(filter) = skia::image_filters::blur((sigma, sigma), None, None, None) {
|
||||||
|
paint.set_image_filter(filter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint);
|
||||||
|
canvas.save_layer(&layer_rec);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clip to frame bounds in the frame's own space, then undo the transform so
|
||||||
|
// children draw at their absolute coords while staying clipped (mirrors the
|
||||||
|
// GPU clip). Outset ~0.5px like the GPU clip to avoid an AA seam.
|
||||||
|
if element.clip_content {
|
||||||
|
canvas.concat(&matrix);
|
||||||
|
clip_to_frame_content(canvas, element, scale);
|
||||||
|
if let Some(inverse) = matrix.invert() {
|
||||||
|
canvas.concat(&inverse);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Frame's own fills (background) + inner shadows, in the frame's space.
|
||||||
|
if !element.fills.is_empty() {
|
||||||
|
canvas.save();
|
||||||
|
canvas.concat(&matrix);
|
||||||
|
let mut renderer = VectorRenderer::new(canvas, shared, scale, target);
|
||||||
|
renderer.draw_fills(element, &element.fills)?;
|
||||||
|
renderer.draw_fill_inner_shadows(element)?;
|
||||||
|
canvas.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Children (absolute coords, no frame transform).
|
||||||
|
let children: Vec<Uuid> = element.children_ids_iter_forward(false).copied().collect();
|
||||||
|
for child_id in &children {
|
||||||
|
render_tree(shared, canvas, child_id, tree, scale, target)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strokes over children (clipped frames), in the frame's space.
|
||||||
|
let visible_strokes: Vec<&Stroke> = element.visible_strokes().collect();
|
||||||
|
if !visible_strokes.is_empty() {
|
||||||
|
canvas.save();
|
||||||
|
canvas.concat(&matrix);
|
||||||
|
let mut renderer = VectorRenderer::new(canvas, shared, scale, target);
|
||||||
|
renderer.draw_strokes(element, &visible_strokes)?;
|
||||||
|
canvas.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
if needs_layer {
|
||||||
|
canvas.restore(); // opacity/blend layer
|
||||||
|
}
|
||||||
|
canvas.restore();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop shadows for a container: render the subtree into a drop-shadow filter
|
||||||
|
/// layer (its alpha becomes the shadow). `draw_fills` includes the frame
|
||||||
|
/// background in the silhouette.
|
||||||
|
fn render_container_drop_shadows(
|
||||||
|
shared: &mut RenderState,
|
||||||
|
canvas: &Canvas,
|
||||||
|
element: &Shape,
|
||||||
|
tree: ShapesPoolRef,
|
||||||
|
scale: f32,
|
||||||
|
target: VectorTarget,
|
||||||
|
draw_fills: bool,
|
||||||
|
) -> Result<()> {
|
||||||
|
for shadow in element.drop_shadows_visible() {
|
||||||
|
let Some(filter) = shadow.get_drop_shadow_filter() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let mut paint = Paint::default();
|
||||||
|
paint.set_image_filter(filter);
|
||||||
|
canvas.save_layer(&skia::canvas::SaveLayerRec::default().paint(&paint));
|
||||||
|
|
||||||
|
if draw_fills && !element.fills.is_empty() {
|
||||||
|
let mut renderer = VectorRenderer::new(canvas, shared, scale, target);
|
||||||
|
renderer.draw_fills(element, &element.fills)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let children: Vec<Uuid> = element.children_ids_iter_forward(false).copied().collect();
|
||||||
|
for child_id in &children {
|
||||||
|
render_tree(shared, canvas, child_id, tree, scale, target)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.restore();
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Leaf shapes (Rect, Circle, Path, Bool, Text, SVGRaw)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn render_leaf(
|
||||||
|
shared: &mut RenderState,
|
||||||
|
canvas: &Canvas,
|
||||||
|
element: &Shape,
|
||||||
|
scale: f32,
|
||||||
|
target: VectorTarget,
|
||||||
|
) -> Result<()> {
|
||||||
|
let needs_layer = element.needs_layer();
|
||||||
|
|
||||||
|
let matrix = element.centered_transform();
|
||||||
|
|
||||||
|
canvas.save();
|
||||||
|
canvas.concat(&matrix);
|
||||||
|
|
||||||
|
// Layer for opacity/blend
|
||||||
|
if needs_layer {
|
||||||
|
let mut paint = Paint::default();
|
||||||
|
paint.set_blend_mode(element.blend_mode().into());
|
||||||
|
paint.set_alpha_f(element.opacity());
|
||||||
|
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint);
|
||||||
|
canvas.save_layer(&layer_rec);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut renderer = VectorRenderer::new(canvas, shared, scale, target);
|
||||||
|
|
||||||
|
// Layer blur (non-text shapes)
|
||||||
|
let blur_layer = if !matches!(element.shape_type, Type::Text(_)) {
|
||||||
|
renderer.apply_blur_layer(element)
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
};
|
||||||
|
|
||||||
|
renderer.draw_drop_shadows(element)?;
|
||||||
|
render_leaf_content(&mut renderer, element)?;
|
||||||
|
|
||||||
|
if blur_layer {
|
||||||
|
renderer.restore_blur_layer();
|
||||||
|
}
|
||||||
|
|
||||||
|
if needs_layer {
|
||||||
|
canvas.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.restore();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Single source of truth for leaf content draw order/gating (fills, inner
|
||||||
|
/// shadows, strokes), generic over [`ShapeRenderer`]. Drop shadows and layer
|
||||||
|
/// blur are excluded — they wrap the content and are sequenced per backend.
|
||||||
|
fn render_leaf_content<R: ShapeRenderer + ?Sized>(renderer: &mut R, shape: &Shape) -> Result<()> {
|
||||||
|
match &shape.shape_type {
|
||||||
|
Type::Text(_) => renderer.draw_text(shape)?,
|
||||||
|
Type::SVGRaw(_) => renderer.draw_svg(shape)?,
|
||||||
|
// Group/Frame never reach here; listed so a new Type must be handled.
|
||||||
|
Type::Rect(_)
|
||||||
|
| Type::Circle
|
||||||
|
| Type::Path(_)
|
||||||
|
| Type::Bool(_)
|
||||||
|
| Type::Group(_)
|
||||||
|
| Type::Frame(_) => {
|
||||||
|
renderer.draw_fills(shape, &shape.fills)?;
|
||||||
|
renderer.draw_fill_inner_shadows(shape)?;
|
||||||
|
|
||||||
|
let visible_strokes: Vec<&Stroke> = shape.visible_strokes().collect();
|
||||||
|
if !visible_strokes.is_empty() {
|
||||||
|
renderer.draw_strokes(shape, &visible_strokes)?;
|
||||||
|
|
||||||
|
// Stroke inner shadows only when there are no fills (matches GPU).
|
||||||
|
if !shape.has_fills() {
|
||||||
|
for stroke in &visible_strokes {
|
||||||
|
renderer.draw_stroke_inner_shadows(shape, stroke)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Private helpers (canvas-only)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn draw_image_fill(
|
||||||
|
shared: &mut RenderState,
|
||||||
|
canvas: &Canvas,
|
||||||
|
shape: &Shape,
|
||||||
|
image_fill: &crate::shapes::ImageFill,
|
||||||
|
) -> Result<()> {
|
||||||
|
// Use a CPU-backed image copy — GPU-backed images can't be drawn
|
||||||
|
// on the PDF canvas which has no GPU context.
|
||||||
|
let Some(image) = shared.images.get_cpu_image(&image_fill.id()) else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
let size = image.dimensions();
|
||||||
|
let container = &shape.selrect;
|
||||||
|
|
||||||
|
let src_rect = get_source_rect(size, container, image_fill);
|
||||||
|
let dest_rect = container;
|
||||||
|
|
||||||
|
canvas.save();
|
||||||
|
|
||||||
|
// Clip to shape
|
||||||
|
clip_to_shape(canvas, shape, true);
|
||||||
|
|
||||||
|
let mut paint = Paint::default();
|
||||||
|
paint.set_anti_alias(true);
|
||||||
|
if let Some(filter) = shape.image_filter(1.) {
|
||||||
|
paint.set_image_filter(filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.draw_image_rect_with_sampling_options(
|
||||||
|
&image,
|
||||||
|
Some((&src_rect, skia::canvas::SrcRectConstraint::Strict)),
|
||||||
|
dest_rect,
|
||||||
|
shared.sampling_options,
|
||||||
|
&paint,
|
||||||
|
);
|
||||||
|
|
||||||
|
canvas.restore();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_single_stroke(
|
||||||
|
canvas: &Canvas,
|
||||||
|
shared: &mut RenderState,
|
||||||
|
scale: f32,
|
||||||
|
shape: &Shape,
|
||||||
|
stroke: &Stroke,
|
||||||
|
) -> Result<()> {
|
||||||
|
// Image-fill strokes: the stroke masks the visible area of the image.
|
||||||
|
if let Fill::Image(image_fill) = &stroke.fill {
|
||||||
|
return draw_image_stroke(canvas, shared, scale, shape, stroke, image_fill);
|
||||||
|
}
|
||||||
|
|
||||||
|
draw_stroke_geometry(canvas, scale, shape, stroke, false);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
fn draw_stroke_geometry(canvas: &Canvas, scale: f32, shape: &Shape, stroke: &Stroke, opaque: bool) {
|
||||||
|
let svg_attrs = shape.svg_attrs.as_ref();
|
||||||
|
let is_open = shape.is_open();
|
||||||
|
|
||||||
|
match &shape.shape_type {
|
||||||
|
shape_type @ (Type::Rect(_) | Type::Frame(_)) => {
|
||||||
|
let corners = shape_type.corners();
|
||||||
|
let mut paint = stroke.to_paint(&shape.selrect, svg_attrs, true);
|
||||||
|
if opaque {
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Type::Circle => {
|
||||||
|
let mut paint = stroke.to_paint(&shape.selrect, svg_attrs, true);
|
||||||
|
if opaque {
|
||||||
|
paint.set_shader(None);
|
||||||
|
paint.set_color(skia::Color::BLACK);
|
||||||
|
}
|
||||||
|
super::strokes::draw_stroke_on_circle(
|
||||||
|
canvas,
|
||||||
|
stroke,
|
||||||
|
&shape.selrect,
|
||||||
|
&paint,
|
||||||
|
scale,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Type::Path(_) | Type::Bool(_) => {
|
||||||
|
let mut paint = stroke.to_stroked_paint(is_open, &shape.selrect, svg_attrs, true);
|
||||||
|
if opaque {
|
||||||
|
paint.set_shader(None);
|
||||||
|
paint.set_color(skia::Color::BLACK);
|
||||||
|
}
|
||||||
|
draw_stroke_kind_aware(canvas, shape, stroke, &paint);
|
||||||
|
|
||||||
|
if is_open {
|
||||||
|
if let Some(cap_path) = transformed_skia_path(shape) {
|
||||||
|
super::strokes::handle_stroke_caps(
|
||||||
|
&cap_path, stroke, canvas, is_open, &paint, None, true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Text strokes go through draw_text; groups/svg never carry strokes.
|
||||||
|
Type::Text(_) | Type::SVGRaw(_) | Type::Group(_) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draws a stroked `paint` honoring the stroke kind (inner clip / outer
|
||||||
|
/// layer+clear / center).
|
||||||
|
fn draw_stroke_kind_aware(canvas: &Canvas, shape: &Shape, stroke: &Stroke, paint: &Paint) {
|
||||||
|
match stroke.render_kind(shape.is_open()) {
|
||||||
|
StrokeKind::Inner => {
|
||||||
|
canvas.save();
|
||||||
|
clip_to_shape(canvas, shape, true);
|
||||||
|
draw_shape_geometry(canvas, shape, paint);
|
||||||
|
canvas.restore();
|
||||||
|
}
|
||||||
|
StrokeKind::Outer => {
|
||||||
|
canvas.save();
|
||||||
|
canvas.save_layer(&skia::canvas::SaveLayerRec::default());
|
||||||
|
draw_shape_geometry(canvas, shape, paint);
|
||||||
|
let mut clear_paint = Paint::default();
|
||||||
|
clear_paint.set_blend_mode(skia::BlendMode::Clear);
|
||||||
|
clear_paint.set_anti_alias(true);
|
||||||
|
clear_paint.set_style(skia::PaintStyle::Fill);
|
||||||
|
draw_shape_geometry(canvas, shape, &clear_paint);
|
||||||
|
canvas.restore(); // layer
|
||||||
|
canvas.restore();
|
||||||
|
}
|
||||||
|
StrokeKind::Center => {
|
||||||
|
draw_shape_geometry(canvas, shape, paint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Image-filled stroke: draw the stroke silhouette in a layer, then paint the
|
||||||
|
/// CPU image over it with `SrcIn` so only the stroke area shows the image.
|
||||||
|
fn draw_image_stroke(
|
||||||
|
canvas: &Canvas,
|
||||||
|
shared: &mut RenderState,
|
||||||
|
scale: f32,
|
||||||
|
shape: &Shape,
|
||||||
|
stroke: &Stroke,
|
||||||
|
image_fill: &crate::shapes::ImageFill,
|
||||||
|
) -> Result<()> {
|
||||||
|
let Some(image) = shared.images.get_cpu_image(&image_fill.id()) else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let size = image.dimensions();
|
||||||
|
let container = shape.selrect;
|
||||||
|
|
||||||
|
canvas.save();
|
||||||
|
canvas.save_layer(&skia::canvas::SaveLayerRec::default());
|
||||||
|
|
||||||
|
// Opaque stroke silhouette; the SrcIn image draw below fills it.
|
||||||
|
draw_stroke_geometry(canvas, scale, shape, stroke, true);
|
||||||
|
|
||||||
|
let mut image_paint = Paint::default();
|
||||||
|
image_paint.set_blend_mode(skia::BlendMode::SrcIn);
|
||||||
|
image_paint.set_anti_alias(true);
|
||||||
|
if let Some(filter) = shape.image_filter(1.) {
|
||||||
|
image_paint.set_image_filter(filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
let src_rect = get_source_rect(size, &container, image_fill);
|
||||||
|
let dest_rect = get_dest_rect(&container, stroke.delta());
|
||||||
|
canvas.draw_image_rect_with_sampling_options(
|
||||||
|
&image,
|
||||||
|
Some((&src_rect, skia::canvas::SrcRectConstraint::Strict)),
|
||||||
|
dest_rect,
|
||||||
|
shared.sampling_options,
|
||||||
|
&image_paint,
|
||||||
|
);
|
||||||
|
|
||||||
|
canvas.restore(); // layer
|
||||||
|
canvas.restore();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn transformed_skia_path(shape: &Shape) -> Option<skia::Path> {
|
||||||
|
if !matches!(shape.shape_type, Type::Path(_) | Type::Bool(_)) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
shape.get_skia_path()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Geometry helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Draws the shape's geometry (rect/rrect/oval/path) with the given paint.
|
||||||
|
fn draw_shape_geometry(canvas: &Canvas, shape: &Shape, paint: &Paint) {
|
||||||
|
match &shape.shape_type {
|
||||||
|
Type::Rect(_) | Type::Frame(_) => {
|
||||||
|
if let Some(corners) = shape.shape_type.corners() {
|
||||||
|
let rrect = RRect::new_rect_radii(shape.selrect, &corners);
|
||||||
|
canvas.draw_rrect(rrect, paint);
|
||||||
|
} else {
|
||||||
|
canvas.draw_rect(shape.selrect, paint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Type::Circle => {
|
||||||
|
canvas.draw_oval(shape.selrect, paint);
|
||||||
|
}
|
||||||
|
Type::Path(_) | Type::Bool(_) => {
|
||||||
|
if let Some(path) = shape.get_skia_path() {
|
||||||
|
canvas.draw_path(&path, paint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Not plain geometry (drawn via draw_text / draw_svg / traversal).
|
||||||
|
Type::Text(_) | Type::SVGRaw(_) | Type::Group(_) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clips the canvas to a frame's content bounds, outset by ~0.5 device px so
|
||||||
|
/// the hard (non-AA) clip edge doesn't shave off edge pixels and leave a seam.
|
||||||
|
fn clip_to_frame_content(canvas: &Canvas, shape: &Shape, scale: f32) {
|
||||||
|
let outset = 0.5 / scale.max(1e-6);
|
||||||
|
let mut rect = shape.selrect;
|
||||||
|
rect.outset((outset, outset));
|
||||||
|
match shape.shape_type.corners() {
|
||||||
|
Some(corners) => {
|
||||||
|
let rrect = RRect::new_rect_radii(rect, &corners);
|
||||||
|
canvas.clip_rrect(rrect, skia::ClipOp::Intersect, false);
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
canvas.clip_rect(rect, skia::ClipOp::Intersect, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clips the canvas to the shape's geometry.
|
||||||
|
fn clip_to_shape(canvas: &Canvas, shape: &Shape, antialias: bool) {
|
||||||
|
let container = &shape.selrect;
|
||||||
|
match &shape.shape_type {
|
||||||
|
Type::Rect(Rect {
|
||||||
|
corners: Some(corners),
|
||||||
|
})
|
||||||
|
| Type::Frame(Frame {
|
||||||
|
corners: Some(corners),
|
||||||
|
..
|
||||||
|
}) => {
|
||||||
|
let rrect = RRect::new_rect_radii(*container, corners);
|
||||||
|
canvas.clip_rrect(rrect, skia::ClipOp::Intersect, antialias);
|
||||||
|
}
|
||||||
|
Type::Rect(_) | Type::Frame(_) => {
|
||||||
|
canvas.clip_rect(*container, skia::ClipOp::Intersect, antialias);
|
||||||
|
}
|
||||||
|
Type::Circle => {
|
||||||
|
let mut pb = skia::PathBuilder::new();
|
||||||
|
pb.add_oval(*container, None, None);
|
||||||
|
canvas.clip_path(&pb.detach(), skia::ClipOp::Intersect, antialias);
|
||||||
|
}
|
||||||
|
Type::Path(_) | Type::Bool(_) => {
|
||||||
|
if let Some(path) = shape.get_skia_path() {
|
||||||
|
canvas.clip_path(&path, skia::ClipOp::Intersect, antialias);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback to the bounding rect.
|
||||||
|
Type::Text(_) | Type::SVGRaw(_) | Type::Group(_) => {
|
||||||
|
canvas.clip_rect(*container, skia::ClipOp::Intersect, antialias);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1077,6 +1077,15 @@ impl Shape {
|
|||||||
self.selrect.center()
|
self.selrect.center()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: This can be used in more places
|
||||||
|
pub fn centered_transform(&self) -> Matrix {
|
||||||
|
let center = self.center();
|
||||||
|
let mut matrix = self.transform;
|
||||||
|
matrix.post_translate(center);
|
||||||
|
matrix.pre_translate(-center);
|
||||||
|
matrix
|
||||||
|
}
|
||||||
|
|
||||||
pub fn clip(&self) -> bool {
|
pub fn clip(&self) -> bool {
|
||||||
self.clip_content
|
self.clip_content
|
||||||
}
|
}
|
||||||
|
|||||||
@ -92,6 +92,10 @@ impl State {
|
|||||||
get_render_state().render_shape_pixels(id, &self.shapes, scale, timestamp)
|
get_render_state().render_shape_pixels(id, &self.shapes, scale, timestamp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn render_shape_pdf(&mut self, id: &Uuid, scale: f32) -> Result<Vec<u8>> {
|
||||||
|
crate::render::pdf::render_to_pdf(get_render_state(), id, &self.shapes, scale)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn start_render_loop(&mut self, timestamp: i32) -> Result<FrameType> {
|
pub fn start_render_loop(&mut self, timestamp: i32) -> Result<FrameType> {
|
||||||
let render_state = get_render_state();
|
let render_state = get_render_state();
|
||||||
// If zoom changed (e.g. interrupted zoom render followed by pan), the
|
// If zoom changed (e.g. interrupted zoom render followed by pan), the
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user