mirror of
https://github.com/penpot/penpot.git
synced 2026-06-16 04:12:03 +00:00
✨ Support skia pdf rendering
This commit is contained in:
parent
fc3a95765d
commit
5ab8abe37f
@ -166,11 +166,13 @@
|
||||
(defn request-simple-export
|
||||
[{:keys [export]}]
|
||||
(if (and (contains? cf/flags :wasm-export)
|
||||
(contains? #{:jpeg :webp :png} (:type export)))
|
||||
(contains? #{:jpeg :webp :png :pdf} (:type export)))
|
||||
(ptk/reify ::request-simple-export-wasm
|
||||
ptk/EffectEvent
|
||||
(effect [_ _ _]
|
||||
(wasm.exports/export-image export)))
|
||||
(case (:type export)
|
||||
:pdf (wasm.exports/export-pdf export)
|
||||
(wasm.exports/export-image export))))
|
||||
|
||||
(ptk/reify ::request-simple-export
|
||||
ptk/UpdateEvent
|
||||
|
||||
@ -26,3 +26,18 @@
|
||||
(dom/trigger-download-uri filename mtype url)
|
||||
(wapi/revoke-uri url)
|
||||
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)
|
||||
(wapi/revoke-uri url)
|
||||
nil))
|
||||
|
||||
|
||||
@ -2267,6 +2267,25 @@
|
||||
(mem/free)
|
||||
{: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
|
||||
[module]
|
||||
(let [default-fn (unchecked-get module "default")
|
||||
|
||||
@ -32,6 +32,7 @@ skia-safe = { version = "0.93.1", default-features = false, features = [
|
||||
"textlayout",
|
||||
"binary-cache",
|
||||
"webp",
|
||||
"pdf",
|
||||
] }
|
||||
thiserror = "2.0.18"
|
||||
uuid = { version = "1.11.0", features = ["v4", "js"] }
|
||||
|
||||
@ -52,6 +52,7 @@ cd penpot/render-wasm
|
||||
|
||||
## Technical documentation
|
||||
|
||||
- [Rendering Architecture (Live vs Vector/PDF)](./docs/rendering_architecture.md)
|
||||
- [Serialization](./docs/serialization.md)
|
||||
- [Tile Rendering](./docs/tile_rendering.md)
|
||||
- [Texts](./docs/texts.md)
|
||||
|
||||
133
render-wasm/docs/rendering_architecture.md
Normal file
133
render-wasm/docs/rendering_architecture.md
Normal file
@ -0,0 +1,133 @@
|
||||
# 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) |
|
||||
|
||||
## Transforms (important)
|
||||
|
||||
Both paths place a shape at the same world coordinates, but reach it
|
||||
differently. For Path/Bool shapes:
|
||||
|
||||
- `centered_transform` (**C**) and `to_path_transform` (**P**) are exact
|
||||
inverses: `C · P = I`.
|
||||
- `get_skia_path()` already bakes **P** into the geometry (`P · raw`).
|
||||
- Live: canvas carries **C**, draws `get_skia_path()` → `C · P · raw = raw`.
|
||||
- Vector: same — `render_leaf` concats **C**, `draw_shape_geometry` draws
|
||||
`get_skia_path()` directly. **Do not re-apply `to_path_transform`** — that
|
||||
double-counts and mis-positions/rotates the shape.
|
||||
|
||||
## 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 can 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` (a
|
||||
`GpuShapeRenderer` delegating to `fills::render` / `strokes::render` /
|
||||
`shadows::*`) 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 golden PNG-vs-PDF-raster parity tests** — 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` |
|
||||
@ -928,6 +928,22 @@ pub fn 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() {
|
||||
// Why an empty main?
|
||||
// Right now with the target `wasm32-unknown-emscripten` it is not possible
|
||||
|
||||
@ -6,12 +6,15 @@ pub mod gpu_state;
|
||||
pub mod grid_layout;
|
||||
mod images;
|
||||
mod options;
|
||||
pub mod pdf;
|
||||
mod shadows;
|
||||
pub mod shape_renderer;
|
||||
mod strokes;
|
||||
mod surfaces;
|
||||
pub mod text;
|
||||
pub mod text_editor;
|
||||
mod ui;
|
||||
mod vector;
|
||||
|
||||
use skia_safe::{self as skia, Matrix, RRect, Rect};
|
||||
use std::borrow::Cow;
|
||||
|
||||
@ -213,6 +213,13 @@ impl ImageStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a CPU-backed (non-texture) copy of the image, suitable for
|
||||
/// drawing on Skia's PDF canvas which has no GPU context.
|
||||
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> {
|
||||
let key = (*id, is_thumbnail);
|
||||
// Use entry API to mutate the HashMap in-place if needed
|
||||
|
||||
54
render-wasm/src/render/pdf.rs
Normal file
54
render-wasm/src/render/pdf.rs
Normal file
@ -0,0 +1,54 @@
|
||||
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)
|
||||
}
|
||||
35
render-wasm/src/render/shape_renderer.rs
Normal file
35
render-wasm/src/render/shape_renderer.rs
Normal file
@ -0,0 +1,35 @@
|
||||
use crate::error::Result;
|
||||
use crate::shapes::{Fill, Shape, Stroke};
|
||||
|
||||
/// Trait implemented by canvas-based vector export backends (PDF, and
|
||||
/// future SVG).
|
||||
///
|
||||
/// # Parity contract
|
||||
///
|
||||
/// This trait is the single declaration of the rendering *capabilities* a leaf
|
||||
/// shape can have. Two rules keep vector export from drifting away from the
|
||||
/// live (GPU) render:
|
||||
///
|
||||
/// 1. **New capability → new trait method.** Any new per-shape rendering
|
||||
/// feature (a new effect, a new fill/stroke mode, etc.) MUST be added as a
|
||||
/// method here, not inline in the GPU `render_shape`. Adding a method
|
||||
/// produces a compile error in every backend until handled, so the feature
|
||||
/// can never be silently missing from vector export.
|
||||
/// 2. **Order lives in one place.** The draw order/gating of these methods is
|
||||
/// encoded once in `vector::render_leaf_content` (generic over this trait),
|
||||
/// so call-order can't diverge between backends that reuse it.
|
||||
///
|
||||
/// Shape-*type* coverage is enforced separately by exhaustive `match`es (no
|
||||
/// `_ =>` arms) in `vector.rs`, so a new `Type` variant also fails to compile
|
||||
/// until handled.
|
||||
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<()>;
|
||||
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};
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn draw_stroke_on_rect(
|
||||
pub(super) fn draw_stroke_on_rect(
|
||||
canvas: &skia::Canvas,
|
||||
stroke: &Stroke,
|
||||
rect: &Rect,
|
||||
@ -97,7 +97,7 @@ fn draw_stroke_on_rect(
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn draw_stroke_on_circle(
|
||||
pub(super) fn draw_stroke_on_circle(
|
||||
canvas: &skia::Canvas,
|
||||
stroke: &Stroke,
|
||||
rect: &Rect,
|
||||
@ -288,7 +288,7 @@ fn handle_stroke_cap(
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn handle_stroke_caps(
|
||||
pub(super) fn handle_stroke_caps(
|
||||
path: &skia::Path,
|
||||
stroke: &Stroke,
|
||||
canvas: &skia::Canvas,
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
use super::{filters, RenderState, Shape, SurfaceId};
|
||||
use super::{filters, RenderState, Shape, SurfaceId, DEFAULT_EMOJI_FONT};
|
||||
use crate::{
|
||||
error::Result,
|
||||
math::Rect,
|
||||
shapes::{
|
||||
calculate_text_layout_data, set_paint_fill, ParagraphBuilderGroup, Stroke, StrokeKind,
|
||||
TextContent,
|
||||
calculate_text_layout_data, set_paint_fill, ParagraphBuilderGroup, ParagraphLayout, Stroke,
|
||||
StrokeKind, TextContent,
|
||||
},
|
||||
utils::{get_fallback_fonts, get_font_collection},
|
||||
};
|
||||
@ -150,6 +150,62 @@ pub fn render_with_bounds_outset(
|
||||
stroke_bounds_outset: f32,
|
||||
fill_inset: 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<()> {
|
||||
if let Some(render_state) = render_state {
|
||||
let target_surface = surface_id.unwrap_or(SurfaceId::Fills);
|
||||
@ -179,6 +235,7 @@ pub fn render_with_bounds_outset(
|
||||
Some(&blur_filter_clone),
|
||||
fill_inset,
|
||||
layer_opacity,
|
||||
false,
|
||||
);
|
||||
Ok(())
|
||||
},
|
||||
@ -197,6 +254,7 @@ pub fn render_with_bounds_outset(
|
||||
blur,
|
||||
fill_inset,
|
||||
layer_opacity,
|
||||
false,
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
@ -210,6 +268,7 @@ pub fn render_with_bounds_outset(
|
||||
blur,
|
||||
fill_inset,
|
||||
layer_opacity,
|
||||
overlay_emoji,
|
||||
);
|
||||
}
|
||||
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(
|
||||
canvas: &Canvas,
|
||||
shape: &Shape,
|
||||
@ -249,6 +332,7 @@ fn render_text_on_canvas(
|
||||
blur: Option<&ImageFilter>,
|
||||
fill_inset: Option<f32>,
|
||||
layer_opacity: Option<f32>,
|
||||
overlay_emoji: bool,
|
||||
) {
|
||||
if let Some(blur_filter) = blur {
|
||||
let mut blur_paint = Paint::default();
|
||||
@ -260,7 +344,13 @@ fn render_text_on_canvas(
|
||||
if let Some(shadow_paint) = shadow {
|
||||
let layer_rec = SaveLayerRec::default().paint(shadow_paint);
|
||||
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();
|
||||
} 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) {
|
||||
@ -268,13 +358,31 @@ fn render_text_on_canvas(
|
||||
layer_paint.set_image_filter(erode);
|
||||
let layer_rec = SaveLayerRec::default().paint(&layer_paint);
|
||||
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();
|
||||
} else {
|
||||
draw_text(canvas, shape, paragraph_builders, layer_opacity);
|
||||
draw_text(
|
||||
canvas,
|
||||
shape,
|
||||
paragraph_builders,
|
||||
layer_opacity,
|
||||
overlay_emoji,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
draw_text(canvas, shape, paragraph_builders, layer_opacity);
|
||||
draw_text(
|
||||
canvas,
|
||||
shape,
|
||||
paragraph_builders,
|
||||
layer_opacity,
|
||||
overlay_emoji,
|
||||
);
|
||||
}
|
||||
|
||||
if blur.is_some() {
|
||||
@ -289,6 +397,15 @@ fn paint_text(
|
||||
canvas: &Canvas,
|
||||
shape: &Shape,
|
||||
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 layout_info =
|
||||
@ -296,6 +413,11 @@ fn paint_text(
|
||||
|
||||
for para in &layout_info.paragraphs {
|
||||
para.paragraph.paint(canvas, (para.x, para.y));
|
||||
|
||||
if overlay_emoji {
|
||||
paint_emoji_overlay(canvas, para);
|
||||
}
|
||||
|
||||
for deco in ¶.decorations {
|
||||
draw_text_decorations(
|
||||
canvas,
|
||||
@ -309,11 +431,138 @@ fn paint_text(
|
||||
}
|
||||
}
|
||||
|
||||
/// Detects emoji runs in a laid-out paragraph and rasterizes them as bitmap
|
||||
/// overlays on the canvas. Skia's PDF backend cannot render color emoji
|
||||
/// glyphs (COLR/CBDT), so we render them to a small raster surface and draw
|
||||
/// the resulting image at the correct position. The regular `paragraph.paint()`
|
||||
/// already wrote invisible/placeholder glyphs, keeping the text selectable
|
||||
/// in the PDF.
|
||||
fn paint_emoji_overlay(canvas: &Canvas, para: &ParagraphLayout) {
|
||||
let line_metrics = para.paragraph.get_line_metrics();
|
||||
|
||||
// The bundled Noto Color Emoji is a vector color font (COLR/CPAL), so emoji
|
||||
// rasterize crisply at any resolution. Skia's PDF backend can't embed those
|
||||
// color tables, so we rasterize each emoji to an image overlay — rendered at
|
||||
// a high effective DPI relative to its final on-page size so it stays sharp
|
||||
// when the PDF is viewed or printed.
|
||||
//
|
||||
// PDF user space is 72 units per inch, so the design→raster scale that yields
|
||||
// TARGET_DPI is output_scale * TARGET_DPI / PDF_POINTS_PER_INCH, where
|
||||
// output_scale is the canvas CTM scale (export scale + any shape transform).
|
||||
// The shape's font size is already folded in via the emoji rect (w/h) below.
|
||||
// Capped by MAX_RASTER_PX so a huge font size 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 into contiguous ranges.
|
||||
// ZWJ emoji sequences (e.g. 👩🏿🚀) may be split into multiple
|
||||
// style runs by Skia — one per codepoint — but the composed glyph
|
||||
// is a single indivisible cluster. Querying `get_rects_for_range`
|
||||
// on a partial cluster returns empty results, so we must use 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(
|
||||
canvas: &Canvas,
|
||||
shape: &Shape,
|
||||
paragraph_builder_groups: &mut [Vec<ParagraphBuilder>],
|
||||
layer_opacity: Option<f32>,
|
||||
overlay_emoji: bool,
|
||||
) {
|
||||
if let Some(opacity) = layer_opacity {
|
||||
let mut opacity_paint = Paint::default();
|
||||
@ -324,7 +573,7 @@ fn draw_text(
|
||||
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.
|
||||
|
||||
1000
render-wasm/src/render/vector.rs
Normal file
1000
render-wasm/src/render/vector.rs
Normal file
File diff suppressed because it is too large
Load Diff
@ -1077,6 +1077,17 @@ impl Shape {
|
||||
self.selrect.center()
|
||||
}
|
||||
|
||||
/// Returns the shape's transform matrix centered around its selrect center,
|
||||
/// so that rotations and scales happen around the shape's visual center
|
||||
/// rather than the origin.
|
||||
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 {
|
||||
self.clip_content
|
||||
}
|
||||
|
||||
@ -82,6 +82,10 @@ impl State {
|
||||
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> {
|
||||
let render_state = get_render_state();
|
||||
// If zoom changed (e.g. interrupted zoom render followed by pan), the
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user