mirror of
https://github.com/penpot/penpot.git
synced 2026-11-05 01:20:38 +00:00
Lay out horizontal and vertical Japanese text with kinsoku, ruby, emphasis, warichu, tate-chu-yoko and proportional font features. Carry the layout through glyph paths, SVG export and text selection. Update the Rust and ClojureScript binary formats together, including the editor bridge and headless exporter. Resolve CJK fallback fonts and add renderer tests and font fixtures.
1682 lines
59 KiB
Rust
1682 lines
59 KiB
Rust
use std::borrow::Cow;
|
|
|
|
use skia_safe::{self as skia, Canvas, Paint, RRect};
|
|
|
|
use crate::error::Result;
|
|
use crate::shapes::{
|
|
circle_segments_local, merge_fills, radius_to_sigma, rect_segments_local, stroke_to_path,
|
|
BlurType, Fill, Frame, Path, Rect, Shape, SolidColor, Stroke, StrokeKind, StrokeStyle,
|
|
TextContent, Type,
|
|
};
|
|
use crate::state::ShapesPoolRef;
|
|
use crate::uuid::Uuid;
|
|
|
|
use super::children_paint_order;
|
|
use super::shape_renderer::ShapeRenderer;
|
|
use super::text;
|
|
use super::RenderResources;
|
|
use super::RenderState;
|
|
use super::{get_dest_rect, get_image_dest_rect, get_source_rect};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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 RenderResources,
|
|
scale: f32,
|
|
/// When `true`, use PDF/GPU-friendly compositing (`merge_fills`,
|
|
/// `save_layer` for outer strokes). When `false` (SVG export), avoid
|
|
/// techniques that `SkSVGDevice` drops: draw fills individually and emit
|
|
/// solid Inner/Outer strokes as filled outlines.
|
|
compose_fills: bool,
|
|
/// Set when an enclosing layer already applies the shape's layer blur.
|
|
blur_in_layer: bool,
|
|
/// Set inside a container's drop-shadow pass: text glyphs paint opaque.
|
|
silhouette: bool,
|
|
}
|
|
|
|
impl<'a> VectorRenderer<'a> {
|
|
pub fn new(
|
|
canvas: &'a Canvas,
|
|
shared: &'a mut RenderResources,
|
|
scale: f32,
|
|
compose_fills: bool,
|
|
) -> Self {
|
|
Self {
|
|
canvas,
|
|
shared,
|
|
scale,
|
|
compose_fills,
|
|
blur_in_layer: false,
|
|
silhouette: false,
|
|
}
|
|
}
|
|
|
|
/// Layer-blur paint filter for this backend.
|
|
///
|
|
/// SVG export (`compose_fills == false`) returns `None`: `SkSVGDevice` drops
|
|
/// paint image-filters (the shape would vanish). Layer blur is re-emitted as
|
|
/// a native SVG `<filter>` wrapper instead. Also `None` inside a layer
|
|
/// that already blurs, or the shape would blur twice.
|
|
fn layer_blur_filter(&self, shape: &Shape) -> Option<skia::ImageFilter> {
|
|
if !self.compose_fills || self.blur_in_layer {
|
|
return None;
|
|
}
|
|
shape.image_filter(1.)
|
|
}
|
|
|
|
/// Vertical text: shadows, fill and strokes painted from one layout.
|
|
fn draw_vertical_text(
|
|
&mut self,
|
|
shape: &Shape,
|
|
text_content: &TextContent,
|
|
blur_filter: Option<&skia::ImageFilter>,
|
|
) {
|
|
use crate::shapes::text_vertical;
|
|
|
|
let bounds = text_content.bounds();
|
|
let vertical_align = shape.vertical_align();
|
|
let layout = text_content.vertical_layout(&bounds);
|
|
let output = crate::render::svg::vertical_glyph_output();
|
|
|
|
for shadow in &shape.drop_shadow_paints() {
|
|
text_vertical::paint_drop_shadow(
|
|
self.canvas,
|
|
&layout,
|
|
&bounds,
|
|
vertical_align,
|
|
shadow,
|
|
output,
|
|
);
|
|
}
|
|
text::paint_vertical_fill(self.canvas, shape, &layout, &bounds, blur_filter, output);
|
|
let selrect = shape.selrect();
|
|
for stroke in shape.visible_strokes().rev() {
|
|
text_vertical::paint_stroke(
|
|
self.canvas,
|
|
&layout,
|
|
&bounds,
|
|
vertical_align,
|
|
stroke,
|
|
&selrect,
|
|
blur_filter,
|
|
output,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
impl ShapeRenderer for VectorRenderer<'_> {
|
|
fn draw_fills(&mut self, shape: &Shape, fills: &[Fill]) -> Result<()> {
|
|
if fills.is_empty() {
|
|
return Ok(());
|
|
}
|
|
|
|
let blur_filter = self.layer_blur_filter(shape);
|
|
let has_image_fills = fills.iter().any(|f| matches!(f, Fill::Image(_)));
|
|
if !self.compose_fills || has_image_fills {
|
|
// fills[0] is the topmost layer; draw bottom → top (matches GPU + classic SVG).
|
|
for fill in fills.iter().rev() {
|
|
match fill {
|
|
Fill::Image(image_fill) => {
|
|
draw_image_fill(
|
|
self.shared,
|
|
self.canvas,
|
|
shape,
|
|
image_fill,
|
|
blur_filter.as_ref(),
|
|
)?;
|
|
}
|
|
_ => {
|
|
let mut paint = fill.to_paint(&shape.selrect, true);
|
|
if let Some(filter) = blur_filter.clone() {
|
|
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) = blur_filter {
|
|
paint.set_image_filter(filter);
|
|
}
|
|
|
|
draw_shape_geometry(self.canvas, shape, &paint);
|
|
Ok(())
|
|
}
|
|
|
|
fn draw_strokes(&mut self, shape: &Shape, strokes: &[&Stroke]) -> Result<()> {
|
|
let svg_export = !self.compose_fills;
|
|
for stroke in strokes.iter().rev() {
|
|
draw_single_stroke(
|
|
self.canvas,
|
|
self.shared,
|
|
self.scale,
|
|
shape,
|
|
stroke,
|
|
svg_export,
|
|
)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn draw_drop_shadows(&mut self, shape: &Shape) -> Result<()> {
|
|
let layer_bounds = shape.layer_bounds();
|
|
for shadow in shape.drop_shadows_visible() {
|
|
if let Some(filter) = shadow.get_drop_shadow_blur_filter() {
|
|
let mut paint = Paint::default();
|
|
paint.set_image_filter(filter);
|
|
let layer_rec = skia::canvas::SaveLayerRec::default()
|
|
.bounds(&layer_bounds)
|
|
.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);
|
|
// Spread as a mitered outline: Skia's dilate filter runs as
|
|
// SkSL per pixel on CPU canvases, which takes minutes.
|
|
if shadow.spread > 0.0 {
|
|
fill_paint.set_style(skia::PaintStyle::StrokeAndFill);
|
|
fill_paint.set_stroke_width(2.0 * shadow.spread);
|
|
}
|
|
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(());
|
|
}
|
|
let layer_bounds = shape.layer_bounds();
|
|
for shadow in shape.inner_shadows_visible() {
|
|
let paint = shadow.get_inner_shadow_paint(true, self.layer_blur_filter(shape).as_ref());
|
|
self.canvas.save_layer(
|
|
&skia::canvas::SaveLayerRec::default()
|
|
.bounds(&layer_bounds)
|
|
.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 blur_filter = self.layer_blur_filter(shape);
|
|
|
|
if text_content.is_vertical() {
|
|
self.draw_vertical_text(shape, &text_content, blur_filter.as_ref());
|
|
return Ok(());
|
|
}
|
|
|
|
let mut paragraph_builders =
|
|
text_content.paragraph_builder_group_from_text(Some(self.silhouette));
|
|
|
|
// Text drop shadows: one filter layer per shadow over fill + stroke
|
|
// silhouettes (mirrors GPU `render_text_shadows`). Spread grows the
|
|
// glyphs through strokes (`spread_shape`).
|
|
let layer_bounds = shape.layer_bounds();
|
|
for shadow in shape.drop_shadows_visible() {
|
|
let spread = spread_shape(shape, shadow.spread);
|
|
let shape: &Shape = &spread;
|
|
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();
|
|
|
|
let mut shadow_paint = Paint::default();
|
|
shadow_paint.set_image_filter(shadow.get_drop_shadow_blur_filter());
|
|
self.canvas.save_layer(
|
|
&skia::canvas::SaveLayerRec::default()
|
|
.bounds(&layer_bounds)
|
|
.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 fill_builders =
|
|
text_content.paragraph_builder_group_from_text(Some(true));
|
|
text::render_inner_stroke(
|
|
None,
|
|
Some(self.canvas),
|
|
shape,
|
|
stroke_paragraphs,
|
|
&mut fill_builders,
|
|
None,
|
|
blur_filter.as_ref(),
|
|
0.0,
|
|
None,
|
|
)?;
|
|
} else if *kind == StrokeKind::Outer {
|
|
text::render_outer_stroke(
|
|
None,
|
|
Some(self.canvas),
|
|
shape,
|
|
stroke_paragraphs,
|
|
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 fill_builders = text_content.paragraph_builder_group_from_text(None);
|
|
text::render_inner_stroke(
|
|
None,
|
|
Some(self.canvas),
|
|
shape,
|
|
&mut stroke_paragraphs,
|
|
&mut fill_builders,
|
|
None,
|
|
blur_filter.as_ref(),
|
|
stroke_blur_outset,
|
|
layer_opacity,
|
|
)?;
|
|
} else if stroke.render_kind(false) == StrokeKind::Outer {
|
|
text::render_outer_stroke(
|
|
None,
|
|
Some(self.canvas),
|
|
shape,
|
|
&mut stroke_paragraphs,
|
|
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 {
|
|
if !self.compose_fills {
|
|
return false;
|
|
}
|
|
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_bounds = shape.layer_bounds();
|
|
let layer_rec = skia::canvas::SaveLayerRec::default()
|
|
.bounds(&layer_bounds)
|
|
.paint(&paint);
|
|
self.canvas.save_layer(&layer_rec);
|
|
true
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
|
|
fn restore_blur_layer(&mut self) {
|
|
self.canvas.restore();
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tree traversal
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Options threaded through the whole traversal. Carries the export root/page
|
|
/// (needed to re-render the backdrop for background blur) and the background
|
|
/// blur strategy for the active canvas.
|
|
struct TreeOpts<'a> {
|
|
/// The exported root shape id — the backdrop for any shape is re-rendered
|
|
/// from here.
|
|
root: &'a Uuid,
|
|
/// Root's export bounds (shape space); sizes/positions the offscreen
|
|
/// backdrop surface to match the page.
|
|
page: skia::Rect,
|
|
/// `true` on a PDF canvas (which ignores Skia backdrop filters): background
|
|
/// blur is produced by rendering the backdrop onto an offscreen raster
|
|
/// surface and embedding the blurred result as an image. `false` on a raster
|
|
/// canvas, where a plain Skia backdrop filter works directly.
|
|
embed_bg_blur: bool,
|
|
/// When rendering a backdrop, the shape whose own subtree must be omitted
|
|
/// (so the blur samples only what is *behind* it).
|
|
skip: Option<&'a Uuid>,
|
|
/// Fills the root inherits from its ancestors; backdrops restart from it.
|
|
root_fills: &'a [Fill],
|
|
/// Nearest group's fills, painted by fill-less leaves (GPU `nested_fills`).
|
|
inherited_fills: &'a [Fill],
|
|
/// Set inside a container's drop-shadow pass: the spread to grow every
|
|
/// shape by.
|
|
silhouette: Option<f32>,
|
|
}
|
|
|
|
impl<'a> TreeOpts<'a> {
|
|
/// Options for `container`'s children: a group hands its fills down, a
|
|
/// frame breaks the inheritance.
|
|
fn for_children_of<'b>(&self, container: &'b Shape) -> TreeOpts<'b>
|
|
where
|
|
'a: 'b,
|
|
{
|
|
let inherited_fills: &'b [Fill] = match container.shape_type {
|
|
Type::Group(_) => &container.fills,
|
|
_ => &[],
|
|
};
|
|
TreeOpts {
|
|
root: self.root,
|
|
page: self.page,
|
|
embed_bg_blur: self.embed_bg_blur,
|
|
skip: self.skip,
|
|
root_fills: self.root_fills,
|
|
inherited_fills,
|
|
silhouette: self.silhouette,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Depth-first render of the shape tree rooted at `id`. Used for raster export
|
|
/// (PNG/webp), where background blur is applied with a direct Skia backdrop
|
|
/// filter (raster surfaces support it). `page` must be the same bounds the
|
|
/// caller used to size/translate its surface — the embedded background-blur
|
|
/// backdrop is drawn device-aligned against it.
|
|
pub(super) fn render_tree(
|
|
shared: &mut RenderResources,
|
|
canvas: &Canvas,
|
|
id: &Uuid,
|
|
tree: ShapesPoolRef,
|
|
scale: f32,
|
|
page: skia::Rect,
|
|
) -> Result<()> {
|
|
render_tree_dispatch(shared, canvas, id, tree, scale, page, false)
|
|
}
|
|
|
|
/// Like [`render_tree`], but for a PDF canvas: background blur is embedded as a
|
|
/// rasterised image (the PDF backend ignores backdrop filters).
|
|
pub(super) fn render_tree_pdf(
|
|
shared: &mut RenderResources,
|
|
canvas: &Canvas,
|
|
id: &Uuid,
|
|
tree: ShapesPoolRef,
|
|
page: skia::Rect,
|
|
) -> Result<()> {
|
|
render_tree_dispatch(shared, canvas, id, tree, 1.0, page, true)
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn render_tree_dispatch(
|
|
shared: &mut RenderResources,
|
|
canvas: &Canvas,
|
|
id: &Uuid,
|
|
tree: ShapesPoolRef,
|
|
scale: f32,
|
|
page: skia::Rect,
|
|
embed_bg_blur: bool,
|
|
) -> Result<()> {
|
|
let root_fills = tree
|
|
.get(id)
|
|
.map(|shape| shape.inherited_fills(tree))
|
|
.unwrap_or_default();
|
|
let opts = TreeOpts {
|
|
root: id,
|
|
page,
|
|
embed_bg_blur,
|
|
skip: None,
|
|
root_fills: &root_fills,
|
|
inherited_fills: &root_fills,
|
|
silhouette: None,
|
|
};
|
|
render_tree_inner(shared, canvas, id, tree, scale, &opts)
|
|
}
|
|
|
|
fn render_tree_inner(
|
|
shared: &mut RenderResources,
|
|
canvas: &Canvas,
|
|
id: &Uuid,
|
|
tree: ShapesPoolRef,
|
|
scale: f32,
|
|
opts: &TreeOpts,
|
|
) -> Result<()> {
|
|
// When re-rendering a backdrop, omit the blur shape's own subtree so it only
|
|
// samples what is behind it.
|
|
if opts.skip == Some(id) {
|
|
return Ok(());
|
|
}
|
|
|
|
let Some(element) = tree.get(id) else {
|
|
return Ok(());
|
|
};
|
|
|
|
if element.hidden {
|
|
return Ok(());
|
|
}
|
|
let silhouette = opts
|
|
.silhouette
|
|
.map(|spread| silhouette_shape(element, spread));
|
|
let element: &Shape = silhouette.as_deref().unwrap_or(element);
|
|
|
|
// Background blur samples already-drawn content behind the shape, so it must
|
|
// 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 {
|
|
render_background_blur_image(shared, canvas, element, tree, scale, opts)?;
|
|
} else {
|
|
render_background_blur_backdrop(canvas, element, blur.value * scale);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
match &element.shape_type {
|
|
Type::Group(group) => {
|
|
render_group(shared, canvas, element, group.masked, tree, scale, opts)?;
|
|
}
|
|
Type::Frame(_) => {
|
|
render_frame(shared, canvas, element, tree, scale, opts)?;
|
|
}
|
|
// Leaf types listed explicitly (no `_`) so a new Type must be handled.
|
|
Type::Rect(_)
|
|
| Type::Circle
|
|
| Type::Path(_)
|
|
| Type::Bool(_)
|
|
| Type::Text(_)
|
|
| Type::SVGRaw(_) => {
|
|
let fills = if element.inherits_fills() {
|
|
opts.inherited_fills
|
|
} else {
|
|
element.fills.as_slice()
|
|
};
|
|
let silhouette = opts.silhouette.is_some();
|
|
render_leaf(shared, canvas, element, fills, scale, silhouette)?;
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Background blur
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// 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` — 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) =
|
|
skia::image_filters::blur((sigma, sigma), skia::TileMode::Clamp, None, None)
|
|
else {
|
|
return;
|
|
};
|
|
|
|
let matrix = shape.centered_transform();
|
|
canvas.save();
|
|
canvas.concat(&matrix);
|
|
|
|
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();
|
|
|
|
let mut paint = Paint::default();
|
|
paint.set_blend_mode(skia::BlendMode::Src);
|
|
let layer_rec = skia::canvas::SaveLayerRec::default()
|
|
.backdrop(&blur_filter)
|
|
.backdrop_tile_mode(skia::TileMode::Clamp)
|
|
.paint(&paint);
|
|
canvas.save_layer(&layer_rec);
|
|
canvas.restore(); // composite the blurred-backdrop layer
|
|
canvas.restore(); // pop the clip + transform
|
|
}
|
|
|
|
/// Background blur for a PDF canvas (backdrop filters unsupported): render the
|
|
/// backdrop — the whole page minus this shape's own subtree — onto an offscreen
|
|
/// raster surface, blur it, and embed the result as an image clipped to the
|
|
/// shape. The rest of the page stays vector.
|
|
///
|
|
/// LIMITATION: the backdrop omits only this shape's subtree, not shapes painted
|
|
/// *after* it. For content stacked on top of the blur shape the foreground would
|
|
/// bleed into the blur; correct for the common case (nothing above the panel).
|
|
fn render_background_blur_image(
|
|
shared: &mut RenderResources,
|
|
canvas: &Canvas,
|
|
shape: &Shape,
|
|
tree: ShapesPoolRef,
|
|
scale: f32,
|
|
opts: &TreeOpts,
|
|
) -> Result<()> {
|
|
let bounds = opts.page;
|
|
let width = (bounds.width() * scale).ceil() as i32;
|
|
let height = (bounds.height() * scale).ceil() as i32;
|
|
if width <= 0 || height <= 0 {
|
|
return Ok(());
|
|
}
|
|
|
|
// Render the backdrop into an offscreen raster surface, in the same
|
|
// coordinate space as the PDF page (see `render/pdf.rs`).
|
|
let Some(mut surface) = skia::surfaces::raster_n32_premul((width, height)) else {
|
|
return Ok(());
|
|
};
|
|
{
|
|
let oc = surface.canvas();
|
|
oc.clear(skia::Color::TRANSPARENT);
|
|
oc.scale((scale, scale));
|
|
oc.translate((-bounds.left(), -bounds.top()));
|
|
let sub = TreeOpts {
|
|
root: opts.root,
|
|
page: opts.page,
|
|
embed_bg_blur: false,
|
|
skip: Some(&shape.id),
|
|
root_fills: opts.root_fills,
|
|
inherited_fills: opts.root_fills,
|
|
silhouette: None,
|
|
};
|
|
render_tree_inner(shared, oc, opts.root, tree, scale, &sub)?;
|
|
}
|
|
let image = surface.image_snapshot();
|
|
|
|
// 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 {
|
|
return Ok(());
|
|
};
|
|
let bc = blur_surface.canvas();
|
|
bc.clear(skia::Color::TRANSPARENT);
|
|
let mut paint = Paint::default();
|
|
if let Some(filter) =
|
|
skia::image_filters::blur((sigma, sigma), skia::TileMode::Clamp, None, None)
|
|
{
|
|
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);
|
|
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();
|
|
canvas.draw_image(&blurred, (0.0, 0.0), None);
|
|
canvas.restore();
|
|
Ok(())
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Groups
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn render_group(
|
|
shared: &mut RenderResources,
|
|
canvas: &Canvas,
|
|
element: &Shape,
|
|
masked: bool,
|
|
tree: ShapesPoolRef,
|
|
scale: f32,
|
|
opts: &TreeOpts,
|
|
) -> 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, false, opts)?;
|
|
|
|
// 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_bounds = element.extrect(tree, scale);
|
|
let layer_rec = skia::canvas::SaveLayerRec::default()
|
|
.bounds(&layer_bounds)
|
|
.paint(&paint);
|
|
canvas.save_layer(&layer_rec);
|
|
}
|
|
|
|
let children: Vec<Uuid> = element.children_ids_iter_forward(false).copied().collect();
|
|
let child_opts = opts.for_children_of(element);
|
|
|
|
if masked {
|
|
// Inner shadows filter the masked pixels, so they follow the mask's
|
|
// outline. The canvas matrix already carries `scale`.
|
|
let inner_shadows = element.masked_group_inner_shadow_filter();
|
|
if let Some(filter) = &inner_shadows {
|
|
let mut paint = Paint::default();
|
|
paint.set_image_filter(filter.clone());
|
|
canvas.save_layer(
|
|
&skia::canvas::SaveLayerRec::default()
|
|
.bounds(&element.extrect(tree, scale))
|
|
.paint(&paint),
|
|
);
|
|
}
|
|
render_masked_children(shared, canvas, element, &children, tree, scale, &child_opts)?;
|
|
if inner_shadows.is_some() {
|
|
canvas.restore(); // inner shadow layer
|
|
}
|
|
} else {
|
|
for child_id in &children {
|
|
render_tree_inner(shared, canvas, child_id, tree, scale, &child_opts)?;
|
|
}
|
|
}
|
|
|
|
if needs_layer {
|
|
canvas.restore(); // opacity/blend layer
|
|
}
|
|
canvas.restore();
|
|
Ok(())
|
|
}
|
|
|
|
fn render_masked_children(
|
|
shared: &mut RenderResources,
|
|
canvas: &Canvas,
|
|
element: &Shape,
|
|
children: &[Uuid],
|
|
tree: ShapesPoolRef,
|
|
scale: f32,
|
|
child_opts: &TreeOpts,
|
|
) -> Result<()> {
|
|
let subtree_bounds = element.extrect(tree, scale);
|
|
canvas.save_layer(
|
|
&skia::canvas::SaveLayerRec::default()
|
|
.bounds(&subtree_bounds)
|
|
.paint(&Paint::default()),
|
|
);
|
|
|
|
for child_id in children {
|
|
render_tree_inner(shared, canvas, child_id, tree, scale, child_opts)?;
|
|
}
|
|
|
|
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()
|
|
.bounds(&subtree_bounds)
|
|
.paint(&mask_paint),
|
|
);
|
|
render_tree_inner(shared, canvas, mask_id, tree, scale, child_opts)?;
|
|
canvas.restore(); // mask layer
|
|
}
|
|
|
|
canvas.restore(); // composition layer
|
|
Ok(())
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Frames
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn render_frame(
|
|
shared: &mut RenderResources,
|
|
canvas: &Canvas,
|
|
element: &Shape,
|
|
tree: ShapesPoolRef,
|
|
scale: f32,
|
|
opts: &TreeOpts,
|
|
) -> 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, true, opts)?;
|
|
|
|
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_bounds = element.extrect(tree, scale);
|
|
let layer_rec = skia::canvas::SaveLayerRec::default()
|
|
.bounds(&layer_bounds)
|
|
.paint(&paint);
|
|
canvas.save_layer(&layer_rec);
|
|
}
|
|
|
|
// Clip fills + children only. Strokes render outside the content clip so
|
|
// outer/center strokes are not trimmed (same as GPU render_shape_exit).
|
|
canvas.save();
|
|
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, true);
|
|
// The opacity/blend layer above carries the frame's layer blur.
|
|
renderer.blur_in_layer = needs_layer;
|
|
renderer.draw_fills(element, &element.fills)?;
|
|
renderer.draw_fill_inner_shadows(element)?;
|
|
canvas.restore();
|
|
}
|
|
|
|
// Children (absolute coords, no frame transform).
|
|
let children = children_paint_order(tree, element);
|
|
let child_opts = opts.for_children_of(element);
|
|
for child_id in &children {
|
|
render_tree_inner(shared, canvas, child_id, tree, scale, &child_opts)?;
|
|
}
|
|
canvas.restore(); // content clip
|
|
|
|
// Strokes over children, outside the frame content clip.
|
|
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, true);
|
|
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 RenderResources,
|
|
canvas: &Canvas,
|
|
element: &Shape,
|
|
tree: ShapesPoolRef,
|
|
scale: f32,
|
|
draw_fills: bool,
|
|
opts: &TreeOpts,
|
|
) -> Result<()> {
|
|
let subtree_bounds = element.extrect(tree, scale);
|
|
for shadow in element.drop_shadows_visible() {
|
|
let Some(filter) = shadow.get_drop_shadow_blur_filter() else {
|
|
continue;
|
|
};
|
|
let mut paint = Paint::default();
|
|
paint.set_image_filter(filter);
|
|
canvas.save_layer(
|
|
&skia::canvas::SaveLayerRec::default()
|
|
.bounds(&subtree_bounds)
|
|
.paint(&paint),
|
|
);
|
|
|
|
// Spread grows every silhouette shape instead of dilating the layer.
|
|
let spread = opts.silhouette.unwrap_or(0.0) + shadow.spread.max(0.0);
|
|
if draw_fills && !element.fills.is_empty() {
|
|
let mut background = Cow::Borrowed(element);
|
|
if spread > 0.0 {
|
|
background.to_mut().selrect.outset((spread, spread));
|
|
}
|
|
let mut renderer = VectorRenderer::new(canvas, shared, scale, true);
|
|
renderer.draw_fills(&background, &[Fill::Solid(SolidColor(skia::Color::BLACK))])?;
|
|
}
|
|
|
|
let children = children_paint_order(tree, element);
|
|
let child_opts = TreeOpts {
|
|
silhouette: Some(spread),
|
|
..opts.for_children_of(element)
|
|
};
|
|
if element.is_masked_group() {
|
|
render_masked_children(shared, canvas, element, &children, tree, scale, &child_opts)?;
|
|
} else {
|
|
for child_id in &children {
|
|
render_tree_inner(shared, canvas, child_id, tree, scale, &child_opts)?;
|
|
}
|
|
}
|
|
|
|
canvas.restore();
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// `shape` grown by a drop-shadow `spread` for its silhouette, through its own
|
|
/// strokes and joins (as the GPU does for paths). Empty frames and containers
|
|
/// have no outline of their own to grow.
|
|
fn spread_shape(shape: &Shape, spread: f32) -> Cow<'_, Shape> {
|
|
let outline = match shape.shape_type {
|
|
Type::Group(_) | Type::SVGRaw(_) => false,
|
|
Type::Frame(_) => !shape.fills.is_empty() || !shape.strokes.is_empty(),
|
|
_ => true,
|
|
};
|
|
if spread <= 0.0 || !outline {
|
|
return Cow::Borrowed(shape);
|
|
}
|
|
let mut shape = shape.clone();
|
|
shape.apply_shadow_spread(spread);
|
|
Cow::Owned(shape)
|
|
}
|
|
|
|
/// `shape` as drawn in a container's drop-shadow pass: grown by `spread` and
|
|
/// solid black, since fill and stroke opacity don't affect the shadow (GPU).
|
|
fn silhouette_shape(shape: &Shape, spread: f32) -> Cow<'_, Shape> {
|
|
let mut shape = spread_shape(shape, spread);
|
|
if shape.has_fills() || !shape.strokes.is_empty() {
|
|
let black = Fill::Solid(SolidColor(skia::Color::BLACK));
|
|
let shape = shape.to_mut();
|
|
if shape.has_fills() {
|
|
shape.clear_fills();
|
|
shape.add_fill(black.clone());
|
|
}
|
|
for stroke in shape.strokes.iter_mut() {
|
|
stroke.fill = black.clone();
|
|
}
|
|
}
|
|
shape
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Leaf shapes (Rect, Circle, Path, Bool, Text, SVGRaw)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn render_leaf(
|
|
shared: &mut RenderResources,
|
|
canvas: &Canvas,
|
|
element: &Shape,
|
|
fills: &[Fill],
|
|
scale: f32,
|
|
silhouette: bool,
|
|
) -> 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_bounds = element.layer_bounds();
|
|
let layer_rec = skia::canvas::SaveLayerRec::default()
|
|
.bounds(&layer_bounds)
|
|
.paint(&paint);
|
|
canvas.save_layer(&layer_rec);
|
|
}
|
|
|
|
let mut renderer = VectorRenderer::new(canvas, shared, scale, true);
|
|
|
|
// Layer blur (non-text shapes)
|
|
let blur_layer = if !matches!(element.shape_type, Type::Text(_)) {
|
|
renderer.apply_blur_layer(element)
|
|
} else {
|
|
false
|
|
};
|
|
renderer.blur_in_layer = blur_layer;
|
|
renderer.silhouette = silhouette;
|
|
|
|
renderer.draw_drop_shadows(element)?;
|
|
render_leaf_content(&mut renderer, element, fills)?;
|
|
|
|
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.
|
|
pub(super) fn render_leaf_content<R: ShapeRenderer + ?Sized>(
|
|
renderer: &mut R,
|
|
shape: &Shape,
|
|
fills: &[Fill],
|
|
) -> 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, 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 RenderResources,
|
|
canvas: &Canvas,
|
|
shape: &Shape,
|
|
image_fill: &crate::shapes::ImageFill,
|
|
blur_filter: Option<&skia::ImageFilter>,
|
|
) -> 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 dest_rect = get_image_dest_rect(container, image_fill);
|
|
let src_rect = get_source_rect(size, &dest_rect, image_fill);
|
|
|
|
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) = blur_filter {
|
|
paint.set_image_filter(filter.clone());
|
|
}
|
|
|
|
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 RenderResources,
|
|
scale: f32,
|
|
shape: &Shape,
|
|
stroke: &Stroke,
|
|
svg_export: bool,
|
|
) -> 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);
|
|
}
|
|
|
|
// Techniques SkSVGDevice cannot keep (save_layer+Clear/clip for Outer,
|
|
// PathEffect stamps for dots/dashes): expand to a filled outline instead.
|
|
// Solid Center stays on the shared stroke path.
|
|
if svg_export && draw_svg_stroke_as_fill(canvas, shape, stroke) {
|
|
return Ok(());
|
|
}
|
|
|
|
draw_stroke_geometry(canvas, scale, shape, stroke, false);
|
|
Ok(())
|
|
}
|
|
|
|
/// Shape path in local coords for SVG stroke outline expansion.
|
|
fn svg_stroke_shape_path(shape: &Shape) -> Option<Path> {
|
|
match &shape.shape_type {
|
|
Type::Rect(r) => Some(Path::new(rect_segments_local(shape, r.corners))),
|
|
Type::Frame(f) => Some(Path::new(rect_segments_local(shape, f.corners))),
|
|
Type::Circle => Some(Path::new(circle_segments_local(shape))),
|
|
Type::Path(_) | Type::Bool(_) => {
|
|
let path = shape.shape_type.path()?;
|
|
let mut local = path.clone();
|
|
if let Some(t) = shape.to_path_transform() {
|
|
local.transform(&t);
|
|
}
|
|
Some(local)
|
|
}
|
|
Type::Text(_) | Type::SVGRaw(_) | Type::Group(_) => None,
|
|
}
|
|
}
|
|
|
|
fn svg_stroke_solid_outline(stroke: &Stroke, is_open: bool) -> Option<bool> {
|
|
let kind = stroke.render_kind(is_open);
|
|
match stroke.style {
|
|
StrokeStyle::Solid => match kind {
|
|
// Solid Center already serializes as a native SVG stroke.
|
|
StrokeKind::Center => None,
|
|
StrokeKind::Inner | StrokeKind::Outer => {
|
|
if is_open {
|
|
None
|
|
} else {
|
|
Some(true)
|
|
}
|
|
}
|
|
},
|
|
// PathEffects (path_1d / dash) do not survive SkSVGDevice; expand them.
|
|
StrokeStyle::Dotted | StrokeStyle::Dashed | StrokeStyle::Mixed => Some(false),
|
|
}
|
|
}
|
|
|
|
/// Draws a stroke as a filled path outline for SVG export.
|
|
///
|
|
/// Handles solid Inner/Outer and all dotted/dashed/mixed alignments (including
|
|
/// Center and open paths, which force Center). Returns `true` when handled.
|
|
fn draw_svg_stroke_as_fill(canvas: &Canvas, shape: &Shape, stroke: &Stroke) -> bool {
|
|
let is_open = shape.is_open();
|
|
|
|
// Per-side rect/frame strokes already expand to an evenodd band in
|
|
// `draw_stroke_on_rect`. `stroke_to_path` only knows a uniform width.
|
|
if stroke.per_side_widths().is_some()
|
|
&& matches!(shape.shape_type, Type::Rect(_) | Type::Frame(_))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
let Some(solid_outline) = svg_stroke_solid_outline(stroke, is_open) else {
|
|
return false;
|
|
};
|
|
|
|
let Some(shape_path) = svg_stroke_shape_path(shape) else {
|
|
return false;
|
|
};
|
|
|
|
let Some(outline) = stroke_to_path(
|
|
stroke,
|
|
&shape_path,
|
|
None,
|
|
&shape.selrect,
|
|
shape.svg_attrs.as_ref(),
|
|
solid_outline,
|
|
) else {
|
|
return false;
|
|
};
|
|
|
|
let mut paint = stroke.fill.to_paint(&shape.selrect, true);
|
|
paint.set_style(skia::PaintStyle::Fill);
|
|
paint.set_anti_alias(true);
|
|
canvas.draw_path(&outline.to_skia_path(shape.svg_attrs.as_ref()), &paint);
|
|
|
|
// Caps are already part of the outline (`stroke_to_path` unions them in),
|
|
// so they must not be overlaid again: a second draw would double the
|
|
// alpha of translucent strokes.
|
|
|
|
true
|
|
}
|
|
|
|
/// Opaque stroke region for SVG clipPath silhouettes.
|
|
///
|
|
/// Expands every alignment (including solid Center) to a filled outline so we
|
|
/// do not rely on save_layer + SrcIn. Returns false when there is nothing to draw.
|
|
pub(super) fn paint_svg_stroke_silhouette(
|
|
canvas: &Canvas,
|
|
shape: &Shape,
|
|
stroke: &Stroke,
|
|
scale: f32,
|
|
) -> bool {
|
|
if stroke.per_side_widths().is_some()
|
|
&& matches!(shape.shape_type, Type::Rect(_) | Type::Frame(_))
|
|
{
|
|
let corners = shape.shape_type.corners();
|
|
let mut paint = stroke.to_paint(&shape.selrect, shape.svg_attrs.as_ref(), true);
|
|
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,
|
|
Stroke::per_side_profile(shape.visible_strokes()),
|
|
true,
|
|
);
|
|
return true;
|
|
}
|
|
|
|
let Some(shape_path) = svg_stroke_shape_path(shape) else {
|
|
return false;
|
|
};
|
|
|
|
// Expand Center too: a native stroke attribute cannot clip an image.
|
|
let solid_outline = matches!(stroke.style, StrokeStyle::Solid);
|
|
let Some(outline) = stroke_to_path(
|
|
stroke,
|
|
&shape_path,
|
|
None,
|
|
&shape.selrect,
|
|
shape.svg_attrs.as_ref(),
|
|
solid_outline,
|
|
) else {
|
|
return false;
|
|
};
|
|
|
|
let mut paint = Paint::default();
|
|
paint.set_style(skia::PaintStyle::Fill);
|
|
paint.set_anti_alias(true);
|
|
paint.set_color(skia::Color::BLACK);
|
|
canvas.draw_path(&outline.to_skia_path(shape.svg_attrs.as_ref()), &paint);
|
|
|
|
true
|
|
}
|
|
|
|
/// 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,
|
|
Stroke::per_side_profile(shape.visible_strokes()),
|
|
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();
|
|
let layer_bounds = shape.layer_bounds();
|
|
canvas.save_layer(&skia::canvas::SaveLayerRec::default().bounds(&layer_bounds));
|
|
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 RenderResources,
|
|
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();
|
|
let layer_bounds = shape.layer_bounds();
|
|
canvas.save_layer(&skia::canvas::SaveLayerRec::default().bounds(&layer_bounds));
|
|
|
|
// 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.
|
|
pub(super) 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);
|
|
}
|
|
}
|
|
}
|