🔧 Support background blur on PDF render for strokes and text shapes

This commit is contained in:
Elena Torro 2026-07-22 11:56:05 +02:00
parent 318d3d1120
commit d634c6b44e

View File

@ -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();