mirror of
https://github.com/penpot/penpot.git
synced 2026-07-28 17:06:21 +00:00
🔧 Prepare wasm render for headless exporter (#10795)
* 🔧 Support background blur on PDF render for strokes and text shapes * ✨ Add LRU eviction to the wasm image store * ✨ Add RasterFormat to encode png, jpeg and webp from wasm * 🐛 Fix client-side wasm export encoding jpeg and webp as png
This commit is contained in:
parent
0c02121d5d
commit
d4d22ff1a4
@ -13,7 +13,10 @@
|
||||
|
||||
(defn export-image-uri
|
||||
[{:keys [type scale object-id]}]
|
||||
(let [bytes (wasm.api/render-shape-pixels object-id scale)
|
||||
;; The export type doubles as the encoder format, so the bytes always match
|
||||
;; the mtype we wrap them in. `:svg` never reaches here — it needs vector
|
||||
;; markup and `exports.assets/wasm-export-types` keeps it off this path.
|
||||
(let [bytes (wasm.api/render-shape-pixels object-id scale type)
|
||||
mtype (format->mtype type)
|
||||
blob (wapi/create-blob bytes mtype)]
|
||||
(wapi/create-uri blob)))
|
||||
|
||||
@ -68,7 +68,9 @@
|
||||
{sel-w :width sel-h :height} (:selrect frame)
|
||||
max-size (mth/max (or ext-w sel-w) (or ext-h sel-h))
|
||||
scale (mth/max 1 (/ target-size max-size))
|
||||
png-bytes (wasm.api/render-shape-pixels frame-id scale)]
|
||||
;; Thumbnails are always PNG: they need the alpha
|
||||
;; channel and are consumed as a `image/png` data uri.
|
||||
png-bytes (wasm.api/render-shape-pixels frame-id scale :png)]
|
||||
(if (or (nil? png-bytes) (zero? (.-length png-bytes)))
|
||||
(do
|
||||
(l/error :hint "render-shape-pixels returned empty" :frame-id (str frame-id))
|
||||
|
||||
@ -2629,7 +2629,10 @@
|
||||
(reset! transition-image* snapshot)))))
|
||||
|
||||
(defn render-shape-pixels
|
||||
[shape-id scale]
|
||||
"Renders a shape subtree to encoded image bytes. `format` is :png, :jpeg or
|
||||
:webp; jpeg is flattened onto white on the Rust side, since it has no alpha
|
||||
channel."
|
||||
[shape-id scale format]
|
||||
(let [buffer (uuid/get-u32 shape-id)
|
||||
|
||||
offset
|
||||
@ -2638,7 +2641,8 @@
|
||||
(aget buffer 1)
|
||||
(aget buffer 2)
|
||||
(aget buffer 3)
|
||||
scale)
|
||||
scale
|
||||
(sr/translate-raster-format format))
|
||||
|
||||
heap (mem/get-heap-u8)
|
||||
heapu32 (mem/get-heap-u32)
|
||||
|
||||
@ -138,6 +138,16 @@
|
||||
default (unchecked-get values "layer-blur")]
|
||||
(d/nilv (unchecked-get values (d/name blur-type)) default)))
|
||||
|
||||
(defn translate-raster-format
|
||||
"Export image format keyword (:png/:jpeg/:webp) -> `RasterFormat` code.
|
||||
Unlike the other translators this one has NO default: falling back to png
|
||||
would emit png bytes under a jpeg/webp mtype, which is exactly what the
|
||||
explicit format threading exists to prevent."
|
||||
[format]
|
||||
(let [values (unchecked-get wasm/serializers "raster-format")]
|
||||
(or (unchecked-get values (d/name format))
|
||||
(throw (ex-info "unsupported raster format" {:format format})))))
|
||||
|
||||
(defn translate-layout-flex-dir
|
||||
[flex-dir]
|
||||
(let [values (unchecked-get wasm/serializers "flex-direction")]
|
||||
|
||||
@ -46,7 +46,8 @@
|
||||
(reset! context-lost? false))
|
||||
|
||||
(defonce serializers
|
||||
#js {:blur-type shared/RawBlurType
|
||||
#js {:raster-format shared/RasterFormat
|
||||
:blur-type shared/RawBlurType
|
||||
:blend-mode shared/RawBlendMode
|
||||
:bool-type shared/RawBoolType
|
||||
:font-style shared/RawFontStyle
|
||||
|
||||
@ -20,6 +20,7 @@ use std::collections::HashMap;
|
||||
|
||||
#[allow(unused_imports)]
|
||||
use crate::error::{Error, Result};
|
||||
use crate::render::raster::RasterFormat;
|
||||
use crate::render::{FrameType, RenderFlag};
|
||||
|
||||
use globals::{get_design_state, get_gpu_state, get_render_state, get_resources, has_render_state};
|
||||
@ -741,6 +742,19 @@ pub extern "C" fn is_image_cached(
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Evicts least-recently-used images until the store retains at most
|
||||
/// `max_mb` megabytes of image data. Called by the headless exporter between
|
||||
/// requests — never mid-render, so an image can't disappear under a running
|
||||
/// export; evicted images are re-provisioned by later requests that need
|
||||
/// them. Returns the number of evicted images.
|
||||
#[no_mangle]
|
||||
#[wasm_error]
|
||||
pub extern "C" fn evict_images_to_budget(max_mb: u32) -> Result<u32> {
|
||||
let max_bytes = (max_mb as usize) * 1024 * 1024;
|
||||
let evicted = get_resources().images.evict_to_budget(max_bytes);
|
||||
Ok(evicted as u32)
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
#[wasm_error]
|
||||
pub extern "C" fn set_shape_svg_raw_content() -> Result<()> {
|
||||
@ -962,6 +976,9 @@ pub extern "C" fn get_shape_extrect(a: u32, b: u32, c: u32, d: u32) -> Result<*m
|
||||
})
|
||||
}
|
||||
|
||||
/// Raster image via the GPU surface. Returns `[len][width][height][bytes]`
|
||||
/// (LE). `format` selects the encoder: 0 = PNG, 1 = JPEG, 2 = WEBP (see
|
||||
/// `RasterFormat`).
|
||||
#[no_mangle]
|
||||
#[wasm_error]
|
||||
pub extern "C" fn render_shape_pixels(
|
||||
@ -970,6 +987,7 @@ pub extern "C" fn render_shape_pixels(
|
||||
c: u32,
|
||||
d: u32,
|
||||
scale: f32,
|
||||
format: u32,
|
||||
) -> Result<*mut u8> {
|
||||
let id = uuid_from_u32_quartet(a, b, c, d);
|
||||
|
||||
@ -977,9 +995,11 @@ pub extern "C" fn render_shape_pixels(
|
||||
return Err(Error::CriticalError("Scale is not finite".to_string()));
|
||||
}
|
||||
|
||||
let format = RasterFormat::from_u32(format)?;
|
||||
|
||||
with_state!(state, {
|
||||
let (data, width, height) =
|
||||
state.render_shape_pixels(&id, scale, performance::get_time())?;
|
||||
state.render_shape_pixels(&id, scale, performance::get_time(), format)?;
|
||||
|
||||
let len = data.len() as u32;
|
||||
let mut buf = Vec::with_capacity(4 + data.len());
|
||||
@ -1017,8 +1037,9 @@ pub extern "C" fn render_shape_pdf(a: u32, b: u32, c: u32, d: u32, scale: f32) -
|
||||
})
|
||||
}
|
||||
|
||||
/// PNG via CPU raster (no GPU/WebGL). Returns `[len][width][height][png]` (LE),
|
||||
/// same layout as `render_shape_pixels`.
|
||||
/// Raster image via CPU (no GPU/WebGL). Returns `[len][width][height][bytes]`
|
||||
/// (LE), same layout as `render_shape_pixels`. `format` selects the encoder:
|
||||
/// 0 = PNG, 1 = JPEG, 2 = WEBP (see `RasterFormat`).
|
||||
#[no_mangle]
|
||||
#[wasm_error]
|
||||
pub extern "C" fn render_shape_raster(
|
||||
@ -1027,6 +1048,7 @@ pub extern "C" fn render_shape_raster(
|
||||
c: u32,
|
||||
d: u32,
|
||||
scale: f32,
|
||||
format: u32,
|
||||
) -> Result<*mut u8> {
|
||||
let id = uuid_from_u32_quartet(a, b, c, d);
|
||||
|
||||
@ -1034,8 +1056,10 @@ pub extern "C" fn render_shape_raster(
|
||||
return Err(Error::CriticalError("Scale is not finite".to_string()));
|
||||
}
|
||||
|
||||
let format = RasterFormat::from_u32(format)?;
|
||||
|
||||
with_state!(state, {
|
||||
let (data, width, height) = state.render_shape_raster(&id, scale)?;
|
||||
let (data, width, height) = state.render_shape_raster(&id, scale, format)?;
|
||||
|
||||
let len = data.len() as u32;
|
||||
let mut buf = Vec::with_capacity(12 + data.len());
|
||||
|
||||
@ -2429,6 +2429,7 @@ impl RenderState {
|
||||
tree: ShapesPoolRef,
|
||||
scale: f32,
|
||||
timestamp: i32,
|
||||
format: raster::RasterFormat,
|
||||
) -> Result<(Vec<u8>, i32, i32)> {
|
||||
let target_surface = SurfaceId::Export;
|
||||
|
||||
@ -2459,7 +2460,7 @@ impl RenderState {
|
||||
|
||||
self.surfaces
|
||||
.canvas(target_surface)
|
||||
.clear(skia::Color::TRANSPARENT);
|
||||
.clear(format.clear_color());
|
||||
|
||||
if tree.len() != 0 {
|
||||
let Some(shape) = tree.get(id) else {
|
||||
@ -2476,6 +2477,14 @@ impl RenderState {
|
||||
self.render_area_with_margins = extrect;
|
||||
self.surfaces.update_render_context(extrect, scale);
|
||||
|
||||
// `resize_export_surface` swaps in a brand-new (zeroed, i.e.
|
||||
// transparent) surface whenever the dimensions change, discarding
|
||||
// the clear above — so an opaque backdrop has to be laid down again
|
||||
// here, after the resize.
|
||||
if let Some(background) = format.opaque_background() {
|
||||
self.surfaces.canvas(target_surface).clear(background);
|
||||
}
|
||||
|
||||
self.pending_nodes.push(NodeRenderState {
|
||||
id: *id,
|
||||
visited_children: false,
|
||||
@ -2496,10 +2505,10 @@ impl RenderState {
|
||||
let data = image
|
||||
.encode(
|
||||
Some(&mut get_gpu_state().context),
|
||||
skia::EncodedImageFormat::PNG,
|
||||
100,
|
||||
format.encoded(),
|
||||
format.quality(),
|
||||
)
|
||||
.expect("PNG encode failed");
|
||||
.unwrap_or_else(|| panic!("{format:?} encode failed"));
|
||||
let skia::ISize { width, height } = image.dimensions();
|
||||
|
||||
// Restore the workspace render state.
|
||||
|
||||
@ -6,6 +6,7 @@ use crate::error::Result;
|
||||
use crate::get_gpu_state;
|
||||
use skia_safe::gpu::{surfaces, Budgeted, DirectContext};
|
||||
use skia_safe::{self as skia, Codec, ISize, Size};
|
||||
use std::cell::Cell;
|
||||
use std::collections::HashMap;
|
||||
|
||||
pub type Image = skia::Image;
|
||||
@ -66,8 +67,19 @@ enum StoredImage {
|
||||
},
|
||||
}
|
||||
|
||||
struct StoredEntry {
|
||||
image: StoredImage,
|
||||
/// Approximate retained cost: encoded byte length (raw/svg) or the
|
||||
/// decoded RGBA size for images registered from a GL texture.
|
||||
bytes: usize,
|
||||
/// LRU tick; `Cell` so read paths can touch it without `&mut self`.
|
||||
last_used: Cell<u64>,
|
||||
}
|
||||
|
||||
pub struct ImageStore {
|
||||
images: HashMap<(Uuid, bool), StoredImage>,
|
||||
images: HashMap<(Uuid, bool), StoredEntry>,
|
||||
total_bytes: usize,
|
||||
tick: Cell<u64>,
|
||||
/// gpu-only
|
||||
context: Option<Box<DirectContext>>,
|
||||
}
|
||||
@ -212,6 +224,8 @@ impl ImageStore {
|
||||
let context = &gpu_state.context;
|
||||
Self {
|
||||
images: HashMap::with_capacity(2048),
|
||||
total_bytes: 0,
|
||||
tick: Cell::new(0),
|
||||
context: Some(Box::new(context.clone())),
|
||||
}
|
||||
}
|
||||
@ -222,10 +236,65 @@ impl ImageStore {
|
||||
pub fn new_without_gpu() -> Self {
|
||||
Self {
|
||||
images: HashMap::with_capacity(16),
|
||||
total_bytes: 0,
|
||||
tick: Cell::new(0),
|
||||
context: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Bumps the LRU clock and returns the new tick.
|
||||
fn next_tick(&self) -> u64 {
|
||||
let t = self.tick.get() + 1;
|
||||
self.tick.set(t);
|
||||
t
|
||||
}
|
||||
|
||||
fn insert_entry(&mut self, key: (Uuid, bool), image: StoredImage, bytes: usize) {
|
||||
let last_used = Cell::new(self.next_tick());
|
||||
self.total_bytes += bytes;
|
||||
self.images.insert(
|
||||
key,
|
||||
StoredEntry {
|
||||
image,
|
||||
bytes,
|
||||
last_used,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Evicts least-recently-used images until the store retains at most
|
||||
/// `max_bytes`. Meant to be called by the headless exporter *between*
|
||||
/// requests, so an image can never disappear under a running render;
|
||||
/// evicted images are simply re-provisioned by a later request that
|
||||
/// needs them (`is_image_cached` reports them as missing). Returns the
|
||||
/// number of evicted images.
|
||||
pub fn evict_to_budget(&mut self, max_bytes: usize) -> usize {
|
||||
if self.total_bytes <= max_bytes {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Order the keys once instead of rescanning the whole store for the
|
||||
// minimum on every eviction.
|
||||
let mut keys: Vec<_> = self
|
||||
.images
|
||||
.iter()
|
||||
.map(|(key, entry)| (entry.last_used.get(), *key))
|
||||
.collect();
|
||||
keys.sort_unstable_by_key(|(last_used, _)| *last_used);
|
||||
|
||||
let mut evicted = 0;
|
||||
for (_, key) in keys {
|
||||
if self.total_bytes <= max_bytes {
|
||||
break;
|
||||
}
|
||||
if let Some(entry) = self.images.remove(&key) {
|
||||
self.total_bytes -= entry.bytes;
|
||||
evicted += 1;
|
||||
}
|
||||
}
|
||||
evicted
|
||||
}
|
||||
|
||||
pub fn add(
|
||||
&mut self,
|
||||
id: Uuid,
|
||||
@ -239,40 +308,43 @@ impl ImageStore {
|
||||
}
|
||||
|
||||
let raw_data = image_data.to_vec();
|
||||
let bytes = raw_data.len();
|
||||
|
||||
match self.context.as_mut() {
|
||||
Some(context) => {
|
||||
if let Some(gpu_image) = decode_image(context, &raw_data) {
|
||||
self.images.insert(key, StoredImage::Gpu(gpu_image));
|
||||
self.insert_entry(key, StoredImage::Gpu(gpu_image), bytes);
|
||||
} else if let Some((dom, size)) = parse_svg(&raw_data) {
|
||||
self.images.insert(
|
||||
self.insert_entry(
|
||||
key,
|
||||
StoredImage::Svg {
|
||||
dom,
|
||||
size,
|
||||
raster: None,
|
||||
},
|
||||
bytes,
|
||||
);
|
||||
} else {
|
||||
// The lazy re-decode in `get_internal` only retries raster codecs,
|
||||
// so SVGs that fail to parse here stay raw.
|
||||
self.images.insert(key, StoredImage::Raw(raw_data));
|
||||
self.insert_entry(key, StoredImage::Raw(raw_data), bytes);
|
||||
}
|
||||
}
|
||||
// GPU-free: keep the encoded bytes; decoded on the CPU at draw time.
|
||||
// SVGs still get parsed up front since that needs no GPU context.
|
||||
None => {
|
||||
if let Some((dom, size)) = parse_svg(&raw_data) {
|
||||
self.images.insert(
|
||||
self.insert_entry(
|
||||
key,
|
||||
StoredImage::Svg {
|
||||
dom,
|
||||
size,
|
||||
raster: None,
|
||||
},
|
||||
bytes,
|
||||
);
|
||||
} else {
|
||||
self.images.insert(key, StoredImage::Raw(raw_data));
|
||||
self.insert_entry(key, StoredImage::Raw(raw_data), bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -303,7 +375,8 @@ impl ImageStore {
|
||||
));
|
||||
};
|
||||
let image = create_image_from_gl_texture(context, texture_id, width, height)?;
|
||||
self.images.insert(key, StoredImage::Gpu(image));
|
||||
let bytes = (width as usize) * (height as usize) * 4;
|
||||
self.insert_entry(key, StoredImage::Gpu(image), bytes);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@ -337,7 +410,9 @@ impl ImageStore {
|
||||
}
|
||||
|
||||
fn decode_raw_cpu_image(&self, id: &Uuid, is_thumbnail: bool) -> Option<Image> {
|
||||
match self.images.get(&(*id, is_thumbnail))? {
|
||||
let entry = self.images.get(&(*id, is_thumbnail))?;
|
||||
entry.last_used.set(self.next_tick());
|
||||
match &entry.image {
|
||||
StoredImage::Raw(raw_data) => {
|
||||
let data = unsafe { skia::Data::new_bytes(raw_data) };
|
||||
Image::from_encoded(&data)
|
||||
@ -361,7 +436,8 @@ impl ImageStore {
|
||||
.images
|
||||
.get(&(*id, false))
|
||||
.or_else(|| self.images.get(&(*id, true)))?;
|
||||
match entry {
|
||||
entry.last_used.set(self.next_tick());
|
||||
match &entry.image {
|
||||
StoredImage::Svg { dom, size, .. } => Some((dom, *size)),
|
||||
_ => None,
|
||||
}
|
||||
@ -369,16 +445,20 @@ impl ImageStore {
|
||||
|
||||
fn get_internal(&mut self, id: &Uuid, is_thumbnail: bool) -> Option<&Image> {
|
||||
let key = (*id, is_thumbnail);
|
||||
let tick = self.tick.get() + 1;
|
||||
self.tick.set(tick);
|
||||
// Use entry API to mutate the HashMap in-place if needed
|
||||
if let Some(entry) = self.images.get_mut(&key) {
|
||||
match entry {
|
||||
entry.last_used.set(tick);
|
||||
let image = &mut entry.image;
|
||||
match image {
|
||||
StoredImage::Gpu(ref img) => Some(img),
|
||||
StoredImage::Raw(raw_data) => {
|
||||
let context = self.context.as_mut()?;
|
||||
let gpu_image = decode_image(context, raw_data)?;
|
||||
*entry = StoredImage::Gpu(gpu_image);
|
||||
*image = StoredImage::Gpu(gpu_image);
|
||||
|
||||
if let StoredImage::Gpu(ref img) = entry {
|
||||
if let StoredImage::Gpu(ref img) = image {
|
||||
Some(img)
|
||||
} else {
|
||||
None
|
||||
|
||||
@ -3,17 +3,78 @@ use skia_safe as skia;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::state::ShapesPoolRef;
|
||||
use crate::uuid::Uuid;
|
||||
use macros::ToJs;
|
||||
|
||||
use super::vector;
|
||||
use super::RenderResources;
|
||||
|
||||
/// Renders a shape tree to PNG bytes on a CPU raster surface (no GPU/WebGL).
|
||||
/// Returns `(png_bytes, width_px, height_px)`.
|
||||
/// Encoded output format for [`render_to_raster`] and `render_shape_pixels`.
|
||||
/// `ToJs` publishes the discriminants to `api/shared.js`, so the CLJS side
|
||||
/// reads them from here instead of hardcoding the codes.
|
||||
#[repr(u8)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, ToJs)]
|
||||
pub enum RasterFormat {
|
||||
Png = 0,
|
||||
Jpeg = 1,
|
||||
Webp = 2,
|
||||
}
|
||||
|
||||
impl RasterFormat {
|
||||
/// Maps the wire code sent by the CLJS hosts.
|
||||
pub fn from_u32(value: u32) -> Result<Self> {
|
||||
match value {
|
||||
0 => Ok(Self::Png),
|
||||
1 => Ok(Self::Jpeg),
|
||||
2 => Ok(Self::Webp),
|
||||
_ => Err(Error::CriticalError(format!(
|
||||
"Unknown raster format: {value}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn encoded(self) -> skia::EncodedImageFormat {
|
||||
match self {
|
||||
Self::Png => skia::EncodedImageFormat::PNG,
|
||||
Self::Jpeg => skia::EncodedImageFormat::JPEG,
|
||||
Self::Webp => skia::EncodedImageFormat::WEBP,
|
||||
}
|
||||
}
|
||||
|
||||
/// Encoder quality. Mirrors the Playwright backend (`bitmap.cljs`): jpeg
|
||||
/// screenshots default to 95, webp is converted with `-quality 100`.
|
||||
/// Ignored by the PNG encoder.
|
||||
pub fn quality(self) -> u32 {
|
||||
match self {
|
||||
Self::Jpeg => 95,
|
||||
_ => 100,
|
||||
}
|
||||
}
|
||||
|
||||
/// Opaque backdrop this format requires, or `None` when it keeps alpha.
|
||||
/// JPEG has no alpha channel, so transparent regions must be flattened onto
|
||||
/// white — Playwright ignores `omitBackground` for jpeg and does the same.
|
||||
/// PNG/WEBP keep transparency.
|
||||
pub fn opaque_background(self) -> Option<skia::Color> {
|
||||
match self {
|
||||
Self::Jpeg => Some(skia::Color::WHITE),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Colour to clear a fresh export surface with.
|
||||
pub fn clear_color(self) -> skia::Color {
|
||||
self.opaque_background().unwrap_or(skia::Color::TRANSPARENT)
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders a shape tree to encoded image bytes on a CPU raster surface (no
|
||||
/// GPU/WebGL). Returns `(encoded_bytes, width_px, height_px)`.
|
||||
pub fn render_to_raster(
|
||||
shared: &mut RenderResources,
|
||||
id: &Uuid,
|
||||
tree: ShapesPoolRef,
|
||||
scale: f32,
|
||||
format: RasterFormat,
|
||||
) -> Result<(Vec<u8>, i32, i32)> {
|
||||
let Some(shape) = tree.get(id) else {
|
||||
return Ok((Vec::new(), 0, 0));
|
||||
@ -40,7 +101,7 @@ pub fn render_to_raster(
|
||||
|
||||
{
|
||||
let canvas = surface.canvas();
|
||||
canvas.clear(skia::Color::TRANSPARENT);
|
||||
canvas.clear(format.clear_color());
|
||||
canvas.scale((scale, scale));
|
||||
canvas.translate((-bounds.left(), -bounds.top()));
|
||||
vector::render_tree(shared, canvas, id, tree, scale, bounds)?;
|
||||
@ -50,10 +111,10 @@ pub fn render_to_raster(
|
||||
.image_snapshot()
|
||||
.encode(
|
||||
None::<&mut skia::gpu::DirectContext>,
|
||||
skia::EncodedImageFormat::PNG,
|
||||
100,
|
||||
format.encoded(),
|
||||
format.quality(),
|
||||
)
|
||||
.ok_or_else(|| Error::CriticalError("PNG encode failed".to_string()))?;
|
||||
.ok_or_else(|| Error::CriticalError(format!("{format:?} encode failed")))?;
|
||||
|
||||
Ok((data.as_bytes().to_vec(), width, height))
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@ use crate::uuid::Uuid;
|
||||
use super::shape_renderer::ShapeRenderer;
|
||||
use super::text;
|
||||
use super::RenderResources;
|
||||
use super::RenderState;
|
||||
use super::{get_dest_rect, get_source_rect};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -438,9 +439,10 @@ fn render_tree_inner(
|
||||
}
|
||||
|
||||
// Background blur samples already-drawn content behind the shape, so it must
|
||||
// run before the shape (and its subtree) paints. Text/SVGRaw are excluded,
|
||||
// matching the GPU path.
|
||||
if !matches!(element.shape_type, Type::Text(_) | Type::SVGRaw(_)) {
|
||||
// run before the shape (and its subtree) paints. SVGRaw is excluded,
|
||||
// matching the GPU path; text keeps only the glyph-alpha coverage (see the
|
||||
// text branches below), also matching the GPU path.
|
||||
if !matches!(element.shape_type, Type::SVGRaw(_)) {
|
||||
if let Some(blur) = element.visible_background_blur() {
|
||||
if blur.value > 0.0 {
|
||||
if opts.embed_bg_blur {
|
||||
@ -479,8 +481,9 @@ fn render_tree_inner(
|
||||
|
||||
/// Background blur on a canvas that supports Skia backdrop filters (raster).
|
||||
/// Blurs the current device contents within the shape silhouette and stamps the
|
||||
/// result back with `Src`. `sigma_radius` is the blur radius already multiplied
|
||||
/// by the export scale.
|
||||
/// result back with `Src` — or, for text, keeps it only under the glyph/stroke
|
||||
/// alpha via a `DstIn` mask (mirrors the GPU path). `sigma_radius` is the blur
|
||||
/// radius already multiplied by the export scale.
|
||||
fn render_background_blur_backdrop(canvas: &Canvas, shape: &Shape, sigma_radius: f32) {
|
||||
let sigma = radius_to_sigma(sigma_radius);
|
||||
let Some(blur_filter) =
|
||||
@ -492,7 +495,59 @@ fn render_background_blur_backdrop(canvas: &Canvas, shape: &Shape, sigma_radius:
|
||||
let matrix = shape.centered_transform();
|
||||
canvas.save();
|
||||
canvas.concat(&matrix);
|
||||
clip_to_shape(canvas, shape, true);
|
||||
|
||||
if matches!(shape.shape_type, Type::Text(_)) {
|
||||
// Text has no closed geometry to clip with: blur the backdrop inside
|
||||
// the shape rect (outset by the max outward stroke reach so the mask's
|
||||
// stroke coverage isn't cut off), then keep the blurred result only
|
||||
// where the opaque glyph/stroke mask is, via DstIn.
|
||||
let mut clip_rect = shape.selrect;
|
||||
let stroke_outset = Stroke::max_bounds_width(shape.visible_strokes(), false);
|
||||
if stroke_outset > 0.0 {
|
||||
clip_rect.outset((stroke_outset, stroke_outset));
|
||||
}
|
||||
canvas.clip_rect(clip_rect, skia::ClipOp::Intersect, true);
|
||||
|
||||
// Blur in device space (sigma already includes the export scale); the
|
||||
// clip survives reset_matrix. Remember the full transform to restore it
|
||||
// for painting the mask.
|
||||
let local_to_device = canvas.local_to_device();
|
||||
canvas.reset_matrix();
|
||||
|
||||
// SrcOver composite (NOT Src): Src would clear the backdrop outside
|
||||
// the glyphs within the clip rect; SrcOver + DstIn mask leaves the
|
||||
// unmasked backdrop untouched.
|
||||
let layer_rec = skia::canvas::SaveLayerRec::default()
|
||||
.backdrop(&blur_filter)
|
||||
.backdrop_tile_mode(skia::TileMode::Clamp);
|
||||
canvas.save_layer(&layer_rec);
|
||||
|
||||
canvas.set_matrix(&local_to_device);
|
||||
|
||||
// Keep the blurred backdrop only where the glyphs/strokes are.
|
||||
let mut mask_paint = Paint::default();
|
||||
mask_paint.set_blend_mode(skia::BlendMode::DstIn);
|
||||
let mask_layer_rec = skia::canvas::SaveLayerRec::default().paint(&mask_paint);
|
||||
canvas.save_layer(&mask_layer_rec);
|
||||
|
||||
text::paint_text_mask(canvas, shape);
|
||||
|
||||
canvas.restore(); // mask layer
|
||||
canvas.restore(); // blur layer
|
||||
canvas.restore(); // clip + transform
|
||||
return;
|
||||
}
|
||||
|
||||
// When strokes extend beyond the fill geometry (center/outer), expand the
|
||||
// clip with the stroke coverage so the backdrop is also blurred under the
|
||||
// stroke (mirrors the GPU path).
|
||||
let stroke_outset = Stroke::max_bounds_width(shape.visible_strokes(), shape.is_open());
|
||||
if stroke_outset > 0.0 {
|
||||
let clip_path = RenderState::background_blur_clip_path(shape, stroke_outset);
|
||||
canvas.clip_path(&clip_path, skia::ClipOp::Intersect, true);
|
||||
} else {
|
||||
clip_to_shape(canvas, shape, true);
|
||||
}
|
||||
// Apply the blur in device space (sigma already includes the export scale);
|
||||
// the clip, set with the full transform, survives reset_matrix.
|
||||
canvas.reset_matrix();
|
||||
@ -554,6 +609,7 @@ fn render_background_blur_image(
|
||||
// Bake the blur into a raster bitmap. The PDF backend ignores image filters
|
||||
// at draw time (same limitation as backdrop filters), so we must blur on a
|
||||
// raster surface — where filters work — and embed the pre-blurred result.
|
||||
let is_text = matches!(shape.shape_type, Type::Text(_));
|
||||
let sigma = radius_to_sigma(shape.visible_background_blur().map_or(0.0, |b| b.value) * scale);
|
||||
let blurred = {
|
||||
let Some(mut blur_surface) = skia::surfaces::raster_n32_premul((width, height)) else {
|
||||
@ -568,13 +624,50 @@ fn render_background_blur_image(
|
||||
paint.set_image_filter(filter);
|
||||
}
|
||||
bc.draw_image(&image, (0.0, 0.0), Some(&paint));
|
||||
|
||||
if is_text {
|
||||
// Text has no closed geometry to clip with on the PDF canvas, and
|
||||
// the PDF backend can't express DstIn either — so bake the
|
||||
// glyph/stroke alpha mask into the raster bitmap here (where blend
|
||||
// modes work) and embed the already-masked result.
|
||||
let mut mask_paint = Paint::default();
|
||||
mask_paint.set_blend_mode(skia::BlendMode::DstIn);
|
||||
bc.save_layer(&skia::canvas::SaveLayerRec::default().paint(&mask_paint));
|
||||
// Same page-space transform used to render the backdrop above.
|
||||
bc.scale((scale, scale));
|
||||
bc.translate((-bounds.left(), -bounds.top()));
|
||||
bc.concat(&shape.centered_transform());
|
||||
text::paint_text_mask(bc, shape);
|
||||
bc.restore();
|
||||
}
|
||||
|
||||
blur_surface.image_snapshot()
|
||||
};
|
||||
|
||||
let matrix = shape.centered_transform();
|
||||
canvas.save();
|
||||
canvas.concat(&matrix);
|
||||
clip_to_shape(canvas, shape, true);
|
||||
if is_text {
|
||||
// Mask is already baked into the bitmap; the clip only bounds it to
|
||||
// the shape rect (outset by the max outward stroke reach so stroke
|
||||
// coverage isn't cut off).
|
||||
let mut clip_rect = shape.selrect;
|
||||
let stroke_outset = Stroke::max_bounds_width(shape.visible_strokes(), false);
|
||||
if stroke_outset > 0.0 {
|
||||
clip_rect.outset((stroke_outset, stroke_outset));
|
||||
}
|
||||
canvas.clip_rect(clip_rect, skia::ClipOp::Intersect, true);
|
||||
} else {
|
||||
// Expand the clip with the stroke coverage when strokes reach beyond
|
||||
// the fill geometry (mirrors the GPU path).
|
||||
let stroke_outset = Stroke::max_bounds_width(shape.visible_strokes(), shape.is_open());
|
||||
if stroke_outset > 0.0 {
|
||||
let clip_path = RenderState::background_blur_clip_path(shape, stroke_outset);
|
||||
canvas.clip_path(&clip_path, skia::ClipOp::Intersect, true);
|
||||
} else {
|
||||
clip_to_shape(canvas, shape, true);
|
||||
}
|
||||
}
|
||||
// Draw the pre-blurred full-page bitmap in device space (1 image px per
|
||||
// device unit) so it aligns with the page regardless of the shape transform.
|
||||
canvas.reset_matrix();
|
||||
|
||||
@ -10,6 +10,7 @@ pub use ui::RulerState;
|
||||
pub use ui::UIState;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::render::raster::RasterFormat;
|
||||
use crate::render::FrameType;
|
||||
use crate::shapes::{grid_layout::grid_cell_data, FontFamily, Shape};
|
||||
use crate::uuid::Uuid;
|
||||
@ -95,18 +96,24 @@ impl State {
|
||||
id: &Uuid,
|
||||
scale: f32,
|
||||
timestamp: i32,
|
||||
format: RasterFormat,
|
||||
) -> Result<(Vec<u8>, i32, i32)> {
|
||||
get_render_state().render_shape_pixels(id, &self.shapes, scale, timestamp)
|
||||
get_render_state().render_shape_pixels(id, &self.shapes, scale, timestamp, format)
|
||||
}
|
||||
|
||||
pub fn render_shape_pdf(&mut self, id: &Uuid, scale: f32) -> Result<Vec<u8>> {
|
||||
crate::render::pdf::render_to_pdf(get_resources(), id, &self.shapes, scale)
|
||||
}
|
||||
|
||||
/// GPU-free counterpart of [`State::render_shape_pixels`]: PNG on a CPU
|
||||
/// raster surface, no GPU/WebGL.
|
||||
pub fn render_shape_raster(&mut self, id: &Uuid, scale: f32) -> Result<(Vec<u8>, i32, i32)> {
|
||||
crate::render::raster::render_to_raster(get_resources(), id, &self.shapes, scale)
|
||||
/// GPU-free counterpart of [`State::render_shape_pixels`]: encodes to
|
||||
/// `format` on a CPU raster surface, no GPU/WebGL.
|
||||
pub fn render_shape_raster(
|
||||
&mut self,
|
||||
id: &Uuid,
|
||||
scale: f32,
|
||||
format: RasterFormat,
|
||||
) -> Result<(Vec<u8>, i32, i32)> {
|
||||
crate::render::raster::render_to_raster(get_resources(), id, &self.shapes, scale, format)
|
||||
}
|
||||
|
||||
/// Distinct font families used by the (visible) subtree rooted at `id`, in
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user