mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-13 02:34:33 +00:00
M10: Export: a background blur or glass exported on its own keeps the content behind it (#2213)
An export of a layer draws only that layer, so a background blur or glass in it read an empty backdrop. The export now renders the page's layers below the first exported layer, with the margin the effects reach, as the backdrop (render_area_over), clipped to the export area; only the exported layers are drawn on top. Layers without those effects, and every other export path, are unchanged. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Model-Effort: Opus 5.5 drove; Opus 5.5 briefed; Sonnet 5.5 wrote at medium
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
a2e87433d8
commit
020388cf1f
@@ -26,8 +26,8 @@ pub(crate) use export::FORBIDDEN_FILE_NAME_CHARS;
|
||||
use export::add_member;
|
||||
pub(super) use export::export_setting;
|
||||
pub use export::{
|
||||
ExportJob, copy_png, drawn_layers, export_area, export_bytes, export_bytes_with, export_jobs, export_scale, exportable_layers, fig_bytes,
|
||||
frames_pdf, jobs_for, penpot_bytes, safe_file_name,
|
||||
ExportJob, backdrop_layers, copy_png, drawn_layers, export_area, export_bytes, export_bytes_with, export_jobs, export_scale, exportable_layers,
|
||||
fig_bytes, frames_pdf, jobs_for, penpot_bytes, safe_file_name,
|
||||
};
|
||||
use export::{export, export_frames_pdf, export_layers, pdf_bytes};
|
||||
use load::font_report;
|
||||
@@ -408,7 +408,10 @@ pub fn export_file(doc: &Document, ids: &[NodeId], setting: &ExportSetting, scal
|
||||
Ok((pdf_bytes(doc, &groups, &setting.print, (!own_area).then_some(area))?, Vec::new()))
|
||||
}
|
||||
_ => {
|
||||
let mut pix = figcraft_render::render_area(doc, &drawn, area, scale)
|
||||
// Layers exported on their own still read the page beneath them for a background blur
|
||||
// or glass (#2213); a "contents only" off export already draws the whole page.
|
||||
let page = if setting.contents_only { backdrop_layers(doc, ids) } else { Vec::new() };
|
||||
let mut pix = figcraft_render::render_area_over(doc, &drawn, &page, area, scale)
|
||||
.ok_or_else(|| EngineError::Other("render failed (empty or too large)".into()))?;
|
||||
// The render is in the document's profile; the setting's profile converts the pixels
|
||||
// (out-of-gamut colors clip) and tags the file.
|
||||
|
||||
@@ -55,6 +55,12 @@ pub fn drawn_layers(doc: &Document, ids: &[NodeId], setting: &ExportSetting) ->
|
||||
crate::cmd::edit::z_sorted(doc, &crate::cmd::edit::roots(doc, &own))
|
||||
}
|
||||
|
||||
/// The top-level layers of the page of `ids`, bottom to top: what a background blur or glass in an
|
||||
/// export of `ids` reads from (empty when `ids` are on no page).
|
||||
pub fn backdrop_layers(doc: &Document, ids: &[NodeId]) -> Vec<NodeId> {
|
||||
ids.first().and_then(|i| doc.page_of(*i)).and_then(|p| doc.node(p)).map(|p| p.children.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The frame or group a slice sits in: None on the page or in a section.
|
||||
fn slice_container(doc: &Document, slice: NodeId) -> Option<NodeId> {
|
||||
let parent = doc.node(slice)?.parent?;
|
||||
|
||||
@@ -384,3 +384,75 @@ fn copy_as_png_svg_and_code() {
|
||||
let css = crate::codegen::code_for(doc, a, crate::codegen::CodeLang::Css);
|
||||
assert!(css.contains("background: #FF0000;"), "Copy as CSS is the layer's CSS from Inspect");
|
||||
}
|
||||
|
||||
/// A red square with a fill-less pane over its right part, holding `effect` (#2213).
|
||||
fn pane_scene(effect: EffectKind) -> (figcraft_doc::Document, NodeId, NodeId) {
|
||||
use figcraft_doc::{Document, NodeKind};
|
||||
let mut d = Document::new("blur-export");
|
||||
let page = d.first_page().unwrap();
|
||||
let under = d.create(page, NodeKind::Rectangle, "Under", 0.0, 0.0, 120.0, 120.0).unwrap();
|
||||
d.get_mut(under).unwrap().fills = vec![figcraft_color::Paint::solid(figcraft_color::Color::from_hex("E03030").unwrap())];
|
||||
let pane = d.create(page, NodeKind::Rectangle, "Glass", 60.0, 20.0, 120.0, 80.0).unwrap();
|
||||
d.get_mut(pane).unwrap().fills = vec![];
|
||||
d.get_mut(pane).unwrap().effects = vec![Effect { kind: effect, visible: true }];
|
||||
(d, under, pane)
|
||||
}
|
||||
|
||||
/// The PNG export of `ids` as the Export command makes it, and what the canvas draws there.
|
||||
fn export_vs_canvas(d: &figcraft_doc::Document, ids: &[NodeId]) -> (image::RgbaImage, image::RgbaImage) {
|
||||
let setting = figcraft_doc::ExportSetting::default();
|
||||
let page = d.first_page().unwrap();
|
||||
let area = crate::cmd::file::export_area(d, ids, &setting).unwrap();
|
||||
let canvas = figcraft_render::render_area(d, &d.get(page).unwrap().children.clone(), area, 1.0).unwrap();
|
||||
let canvas = image::RgbaImage::from_raw(canvas.width(), canvas.height(), figcraft_render::to_rgba8(&canvas)).unwrap();
|
||||
let (bytes, _) = crate::cmd::file::export_file(d, ids, &setting, 1.0).unwrap();
|
||||
(pixels(&bytes), canvas)
|
||||
}
|
||||
|
||||
/// #2213: a layer with a background blur exported on its own shows the same pixels as the canvas
|
||||
/// shows for that area: the blur reads what lies under it, so the export must draw the layers
|
||||
/// beneath it as the backdrop (clipped to the layer) rather than blurring nothing.
|
||||
#[test]
|
||||
fn exporting_a_background_blur_layer_keeps_the_blurred_backdrop() {
|
||||
let (d, _, pane) = pane_scene(EffectKind::BackgroundBlur(figcraft_doc::Blur::uniform(12.0)));
|
||||
let (exported, canvas) = export_vs_canvas(&d, &[pane]);
|
||||
// The pane's left half sits over the red square, which ends at x = 120 (60 px into the pane).
|
||||
assert_ne!(canvas.get_pixel(60, 40).0, [0, 0, 0, 0], "the canvas draws the blurred backdrop");
|
||||
assert_eq!(exported.get_pixel(60, 40), canvas.get_pixel(60, 40), "export lost the backdrop the blur reads");
|
||||
assert_eq!(exported.dimensions(), (120, 80), "the pane's own bounds");
|
||||
}
|
||||
|
||||
/// #2213: the same for Glass.
|
||||
#[test]
|
||||
fn exporting_a_glass_layer_keeps_the_backdrop_it_bends() {
|
||||
let (d, _, pane) = pane_scene(EffectKind::Glass(figcraft_doc::Glass::default()));
|
||||
let (exported, canvas) = export_vs_canvas(&d, &[pane]);
|
||||
assert_ne!(canvas.get_pixel(30, 40).0, [0, 0, 0, 0]);
|
||||
assert_eq!(exported.get_pixel(30, 40), canvas.get_pixel(30, 40), "export lost the backdrop the glass reads");
|
||||
assert_eq!(exported.get_pixel(60, 40), canvas.get_pixel(60, 40));
|
||||
}
|
||||
|
||||
/// Measured in Figma 2026-11-10: inside the pane's bounds the export is opaque where the backdrop
|
||||
/// is (the blurred red), and the layers over the pane stay out.
|
||||
#[test]
|
||||
fn a_blur_export_is_opaque_inside_the_pane_and_leaves_layers_above_out() {
|
||||
let (mut d, _, pane) = pane_scene(EffectKind::BackgroundBlur(figcraft_doc::Blur::uniform(12.0)));
|
||||
let page = d.first_page().unwrap();
|
||||
let above = d.create(page, figcraft_doc::NodeKind::Rectangle, "Above", 0.0, 0.0, 200.0, 200.0).unwrap();
|
||||
d.get_mut(above).unwrap().fills = vec![figcraft_color::Paint::solid(figcraft_color::Color::from_hex("0000FF").unwrap())];
|
||||
let (exported, _) = export_vs_canvas(&d, &[pane]);
|
||||
let left = exported.get_pixel(10, 40).0;
|
||||
assert_eq!(left[3], 255, "red under the pane: opaque");
|
||||
assert!(left[0] > 150 && left[2] < 60, "red, not the blue layer above: {left:?}");
|
||||
// The far right of the pane has no square under it: nothing to read, nothing drawn.
|
||||
assert_eq!(exported.get_pixel(110, 40).0[3], 0);
|
||||
}
|
||||
|
||||
/// Guard: a layer without backdrop effects over another still exports alone ("contents only").
|
||||
#[test]
|
||||
fn a_layer_without_backdrop_effects_still_leaves_the_layers_below_out() {
|
||||
let (mut d, _, pane) = pane_scene(EffectKind::BackgroundBlur(figcraft_doc::Blur::uniform(0.0)));
|
||||
d.get_mut(pane).unwrap().effects.clear();
|
||||
let (exported, _) = export_vs_canvas(&d, &[pane]);
|
||||
assert!(exported.pixels().all(|p| p.0 == [0, 0, 0, 0]), "a fill-less pane alone exports clear");
|
||||
}
|
||||
|
||||
@@ -266,11 +266,11 @@ fn render_one(doc: &Document, page: NodeId, view: &View, opts: &Options) -> Opti
|
||||
|
||||
/// Render several nodes (in z-order) into an existing pixmap through `view`.
|
||||
pub fn render_nodes_into(doc: &Document, ids: &[NodeId], view: &View, pix: &mut Pixmap) {
|
||||
nodes_into(doc, ids, view, pix, 0.0);
|
||||
nodes_into(doc, ids, view, pix, 0.0, None);
|
||||
}
|
||||
|
||||
/// [`render_nodes_into`] `time` seconds into animated GIFs.
|
||||
fn nodes_into(doc: &Document, ids: &[NodeId], view: &View, pix: &mut Pixmap, time: f64) {
|
||||
fn nodes_into(doc: &Document, ids: &[NodeId], view: &View, pix: &mut Pixmap, time: f64, under: Option<&backdrop::Under>) {
|
||||
let bounds = draw::Bounds::default();
|
||||
let ctx = draw::Ctx {
|
||||
doc,
|
||||
@@ -282,7 +282,7 @@ fn nodes_into(doc: &Document, ids: &[NodeId], view: &View, pix: &mut Pixmap, tim
|
||||
lod: false,
|
||||
preview: false,
|
||||
magnified_image_pixels: false,
|
||||
under: None,
|
||||
under,
|
||||
time,
|
||||
bounds: &bounds,
|
||||
};
|
||||
@@ -353,16 +353,24 @@ pub fn render_node(doc: &Document, id: NodeId, scale: f64) -> Option<Pixmap> {
|
||||
|
||||
/// [`render_node`] `time` seconds into animated GIFs (the presenter plays them).
|
||||
pub fn render_node_at(doc: &Document, id: NodeId, scale: f64, time: f64) -> Option<Pixmap> {
|
||||
area_at(doc, &[id], export_bounds(doc, id)?, scale, time)
|
||||
area_at(doc, &[id], None, export_bounds(doc, id)?, scale, time)
|
||||
}
|
||||
|
||||
/// Rasterise `ids` (bottom to top) over page area `b` at `scale` (export): the image starts at the
|
||||
/// area's top-left and its sides round up to whole pixels. `None` past 16384 pixels a side.
|
||||
pub fn render_area(doc: &Document, ids: &[NodeId], b: Rect, scale: f64) -> Option<Pixmap> {
|
||||
area_at(doc, ids, b, scale, 0.0)
|
||||
area_at(doc, ids, None, b, scale, 0.0)
|
||||
}
|
||||
|
||||
fn area_at(doc: &Document, ids: &[NodeId], b: Rect, scale: f64, time: f64) -> Option<Pixmap> {
|
||||
/// [`render_area`] for an export of layers that sit among `page_layers` (the page's top-level
|
||||
/// layers, in z-order): a background blur or glass in `ids` reads what lies under the first of
|
||||
/// them on that page, clipped to the area (#2213). Only `ids` are drawn; the layers above stay
|
||||
/// out. Without such an effect it is [`render_area`].
|
||||
pub fn render_area_over(doc: &Document, ids: &[NodeId], page_layers: &[NodeId], b: Rect, scale: f64) -> Option<Pixmap> {
|
||||
area_at(doc, ids, Some(page_layers), b, scale, 0.0)
|
||||
}
|
||||
|
||||
fn area_at(doc: &Document, ids: &[NodeId], page_layers: Option<&[NodeId]>, b: Rect, scale: f64, time: f64) -> Option<Pixmap> {
|
||||
let scale = if scale.is_finite() && scale > 0.0 { scale.min(64.0) } else { 1.0 };
|
||||
let w = (b.width() * scale - 1e-6).ceil().max(1.0);
|
||||
let h = (b.height() * scale - 1e-6).ceil().max(1.0);
|
||||
@@ -371,7 +379,31 @@ fn area_at(doc: &Document, ids: &[NodeId], b: Rect, scale: f64, time: f64) -> Op
|
||||
}
|
||||
let view = View { origin: (b.x0, b.y0), scale, width: w as u32, height: h as u32 };
|
||||
let mut pix = new_pixmap(view.width, view.height)?;
|
||||
nodes_into(doc, ids, &view, &mut pix, time);
|
||||
// What the backdrop effects read: the page under the first layer, with the margin they reach.
|
||||
let reach = page_layers.and_then(|_| {
|
||||
ids.iter()
|
||||
.flat_map(|i| doc.subtree(*i))
|
||||
.filter_map(|i| doc.node(i))
|
||||
.filter(|n| n.visible && backdrop::has_effects(n))
|
||||
.map(|n| backdrop::pad(n, scale))
|
||||
.max()
|
||||
});
|
||||
let beneath = match (page_layers, ids.first(), reach) {
|
||||
(Some(layers), Some(first), Some(m)) => {
|
||||
let m = i32::try_from(m).unwrap_or(0);
|
||||
let p = f64::from(m) / scale;
|
||||
let around = View {
|
||||
origin: (b.x0 - p, b.y0 - p),
|
||||
scale,
|
||||
width: view.width.saturating_add(m.unsigned_abs() * 2),
|
||||
height: view.height.saturating_add(m.unsigned_abs() * 2),
|
||||
};
|
||||
backdrop::render_beneath(doc, layers, *first, &around).map(|back| (back, m))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let under = beneath.as_ref().map(|(back, m)| backdrop::Under::new(back, (*m, *m), None));
|
||||
nodes_into(doc, ids, &view, &mut pix, time, under.as_ref());
|
||||
Some(pix)
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user