mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-26 19:36:46 -06:00
- Inner shadows paint over the fills and under the children and strokes, as documented (canvas, PNG, PDF and SVG alike; text fills every run before the inner shadow and the strokes). - Layer and background blur gain a progressive type: the radius ramps linearly from a start radius at a start point to the end radius at an end point (unit-box points, like gradient handles). Rendered by blending a few geometric blur levels, each blurred only around the pixels that use it; bands and partial renders match one piece. Design panel type menu and Start/End fields, on-canvas handles while the settings are open, effect.add/effect.set params, startRadius variable bindings, scale tool, REST import (blurType, startRadius, startOffset, endOffset), Inspect row; CSS leaves progressive blurs out. - SVG export embeds renders for what filters can't match: noise grains over the vector layer (crisp, blended), texture and progressive layer blur whole, background blur and glass from what lies under the layer among the exported layers. PDF and SVG share the renderer's helpers. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
302 lines
14 KiB
Rust
302 lines
14 KiB
Rust
use super::*;
|
|
use figcraft_color::{Color, ColorStop, Gradient, GradientKind, Paint};
|
|
use figcraft_doc::{Effect, EffectKind, NodeKind, Shadow, StrokeAlign, TextData};
|
|
|
|
fn px(p: &Pixmap, x: u32, y: u32) -> [u8; 4] {
|
|
let i = ((y * p.width() + x) * 4) as usize;
|
|
let d = &p.data()[i..i + 4];
|
|
[d[0], d[1], d[2], d[3]]
|
|
}
|
|
|
|
fn doc_with_rect(fill: Color) -> (figcraft_doc::Document, figcraft_doc::NodeId, figcraft_doc::NodeId) {
|
|
let mut d = figcraft_doc::Document::new("t");
|
|
let p = d.first_page().unwrap();
|
|
let r = d.create(p, NodeKind::Rectangle, "R", 10.0, 10.0, 50.0, 30.0).unwrap();
|
|
d.get_mut(r).unwrap().fills = vec![Paint::solid(fill)];
|
|
(d, p, r)
|
|
}
|
|
|
|
fn view(w: u32, h: u32) -> View {
|
|
View { origin: (0.0, 0.0), scale: 1.0, width: w, height: h }
|
|
}
|
|
|
|
#[test]
|
|
fn solid_rect_and_page_background() {
|
|
let (d, p, _) = doc_with_rect(Color::from_hex("FF0000").unwrap());
|
|
let pix = render_view(&d, p, &view(100, 100), &Options::default()).unwrap();
|
|
assert_eq!(px(&pix, 30, 20), [255, 0, 0, 255]);
|
|
assert_eq!(px(&pix, 5, 5), [0xF5, 0xF5, 0xF5, 255]);
|
|
}
|
|
|
|
#[test]
|
|
fn zoom_scales() {
|
|
let (d, p, _) = doc_with_rect(Color::BLACK);
|
|
let v = View { origin: (0.0, 0.0), scale: 2.0, width: 200, height: 200 };
|
|
let pix = render_view(&d, p, &v, &Options::default()).unwrap();
|
|
assert_eq!(px(&pix, 100, 60)[3], 255);
|
|
assert_eq!(px(&pix, 118, 78), [0, 0, 0, 255]);
|
|
assert_eq!(px(&pix, 125, 60)[0], 0xF5);
|
|
}
|
|
|
|
#[test]
|
|
fn opacity_and_layer() {
|
|
let (mut d, p, r) = doc_with_rect(Color::BLACK);
|
|
d.get_mut(r).unwrap().opacity = 0.5;
|
|
let pix = render_view(&d, p, &view(100, 100), &Options { background: Some(Color::WHITE), ..Default::default() }).unwrap();
|
|
let v = px(&pix, 30, 20)[0];
|
|
assert!((120..=135).contains(&v), "{v}");
|
|
}
|
|
|
|
#[test]
|
|
fn drop_shadow_paints_outside() {
|
|
let (mut d, p, r) = doc_with_rect(Color::WHITE);
|
|
d.get_mut(r).unwrap().effects.push(Effect {
|
|
kind: EffectKind::DropShadow(Shadow { color: Color::BLACK, offset: (0.0, 10.0), radius: 0.0, ..Default::default() }),
|
|
visible: true,
|
|
});
|
|
let pix = render_view(&d, p, &view(100, 100), &Options { background: Some(Color::WHITE), ..Default::default() }).unwrap();
|
|
// Below the rect (y 40..50) is shadow.
|
|
assert!(px(&pix, 30, 45)[0] < 30, "{:?}", px(&pix, 30, 45));
|
|
// The rect itself stays white.
|
|
assert_eq!(px(&pix, 30, 20)[0], 255);
|
|
}
|
|
|
|
#[test]
|
|
fn inner_shadow_and_blur_dont_crash() {
|
|
let (mut d, p, r) = doc_with_rect(Color::WHITE);
|
|
let n = d.get_mut(r).unwrap();
|
|
n.effects.push(Effect::inner_shadow());
|
|
n.effects.push(Effect::layer_blur());
|
|
n.effects.push(Effect::background_blur());
|
|
n.effects.push(Effect {
|
|
kind: EffectKind::Noise(figcraft_doc::Noise { size: 1.0, density: 0.5, color: Color::BLACK, ..Default::default() }),
|
|
visible: true,
|
|
});
|
|
let _ = render_view(&d, p, &view(100, 100), &Options::default()).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn gradients_render() {
|
|
for kind in [GradientKind::Linear, GradientKind::Radial, GradientKind::Angular, GradientKind::Diamond] {
|
|
let (mut d, p, r) = doc_with_rect(Color::BLACK);
|
|
let g = Gradient {
|
|
kind,
|
|
stops: vec![
|
|
ColorStop { position: 0.0, color: Color::from_hex("FF0000").unwrap() },
|
|
ColorStop { position: 1.0, color: Color::from_hex("0000FF").unwrap() },
|
|
],
|
|
from: (0.0, 0.5),
|
|
to: (1.0, 0.5),
|
|
width: (0.0, 1.0),
|
|
};
|
|
let g = if kind == GradientKind::Linear { g } else { Gradient { from: (0.5, 0.5), to: (1.0, 0.5), width: (0.5, 1.0), ..g } };
|
|
d.get_mut(r).unwrap().fills = vec![Paint::gradient(g)];
|
|
let pix = render_view(&d, p, &view(100, 100), &Options::default()).unwrap();
|
|
let left = px(&pix, if kind == GradientKind::Linear { 11 } else { 35 }, 25);
|
|
let right = px(&pix, 58, 25);
|
|
assert_ne!(left, right, "{kind:?}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn frame_clips_children() {
|
|
let mut d = figcraft_doc::Document::new("t");
|
|
let p = d.first_page().unwrap();
|
|
let f = d.create(p, NodeKind::Frame, "F", 0.0, 0.0, 50.0, 50.0).unwrap();
|
|
let r = d.create(f, NodeKind::Rectangle, "R", 25.0, 25.0, 50.0, 50.0).unwrap();
|
|
d.get_mut(r).unwrap().fills = vec![Paint::solid(Color::BLACK)];
|
|
let pix = render_view(&d, p, &view(100, 100), &Options::default()).unwrap();
|
|
assert_eq!(px(&pix, 40, 40), [0, 0, 0, 255]);
|
|
assert_eq!(px(&pix, 60, 60)[0], 0xF5);
|
|
d.get_mut(f).unwrap().clip = false;
|
|
let pix = render_view(&d, p, &view(100, 100), &Options::default()).unwrap();
|
|
assert_eq!(px(&pix, 60, 60), [0, 0, 0, 255]);
|
|
}
|
|
|
|
#[test]
|
|
fn strokes_align() {
|
|
let (mut d, p, r) = doc_with_rect(Color::WHITE);
|
|
let n = d.get_mut(r).unwrap();
|
|
n.strokes = vec![Paint::solid(Color::BLACK)];
|
|
n.stroke.weight = 4.0;
|
|
n.stroke.align = StrokeAlign::Outside;
|
|
let pix = render_view(&d, p, &view(100, 100), &Options::default()).unwrap();
|
|
assert_eq!(px(&pix, 30, 8), [0, 0, 0, 255]);
|
|
assert_eq!(px(&pix, 30, 12), [255, 255, 255, 255]);
|
|
d.get_mut(r).unwrap().stroke.align = StrokeAlign::Inside;
|
|
let pix = render_view(&d, p, &view(100, 100), &Options::default()).unwrap();
|
|
assert_eq!(px(&pix, 30, 12), [0, 0, 0, 255]);
|
|
assert_eq!(px(&pix, 30, 8)[0], 0xF5);
|
|
}
|
|
|
|
#[test]
|
|
fn mask_clips_siblings() {
|
|
let mut d = figcraft_doc::Document::new("t");
|
|
let p = d.first_page().unwrap();
|
|
let g = d.create(p, NodeKind::Group, "G", 0.0, 0.0, 100.0, 100.0).unwrap();
|
|
let m = d.create(g, NodeKind::Ellipse { start: 0.0, sweep: std::f64::consts::TAU, inner: 0.0 }, "M", 0.0, 0.0, 50.0, 50.0).unwrap();
|
|
d.get_mut(m).unwrap().is_mask = true;
|
|
let r = d.create(g, NodeKind::Rectangle, "R", 0.0, 0.0, 100.0, 100.0).unwrap();
|
|
d.get_mut(r).unwrap().fills = vec![Paint::solid(Color::BLACK)];
|
|
let pix = render_view(&d, p, &view(100, 100), &Options::default()).unwrap();
|
|
assert_eq!(px(&pix, 25, 25), [0, 0, 0, 255]);
|
|
assert_eq!(px(&pix, 2, 2)[0], 0xF5);
|
|
assert_eq!(px(&pix, 80, 80)[0], 0xF5);
|
|
}
|
|
|
|
#[test]
|
|
fn luminance_and_vector_masks() {
|
|
let mut d = figcraft_doc::Document::new("t");
|
|
let p = d.first_page().unwrap();
|
|
let g = d.create(p, NodeKind::Group, "G", 0.0, 0.0, 100.0, 100.0).unwrap();
|
|
// The mask: white on the left half, black on the right.
|
|
let m = d.create(g, NodeKind::Rectangle, "M", 0.0, 0.0, 100.0, 100.0).unwrap();
|
|
let grad = figcraft_color::Gradient {
|
|
kind: figcraft_color::GradientKind::Linear,
|
|
stops: vec![
|
|
figcraft_color::ColorStop { position: 0.0, color: Color::WHITE },
|
|
figcraft_color::ColorStop { position: 0.49, color: Color::WHITE },
|
|
figcraft_color::ColorStop { position: 0.51, color: Color::BLACK },
|
|
figcraft_color::ColorStop { position: 1.0, color: Color::BLACK },
|
|
],
|
|
from: (0.0, 0.5),
|
|
to: (1.0, 0.5),
|
|
width: (0.0, 1.0),
|
|
};
|
|
d.get_mut(m).unwrap().fills = vec![Paint::gradient(grad)];
|
|
d.get_mut(m).unwrap().is_mask = true;
|
|
let r = d.create(g, NodeKind::Rectangle, "R", 0.0, 0.0, 100.0, 100.0).unwrap();
|
|
d.get_mut(r).unwrap().fills = vec![Paint::solid(Color::BLACK)];
|
|
// Alpha: the mask is opaque everywhere, so everything shows.
|
|
let pix = render_view(&d, p, &view(100, 100), &Options::default()).unwrap();
|
|
assert_eq!(px(&pix, 80, 50), [0, 0, 0, 255]);
|
|
// Luminance: only the white half shows.
|
|
d.get_mut(m).unwrap().mask_type = figcraft_doc::MaskType::Luminance;
|
|
let pix = render_view(&d, p, &view(100, 100), &Options::default()).unwrap();
|
|
assert_eq!(px(&pix, 20, 50), [0, 0, 0, 255]);
|
|
assert_eq!(px(&pix, 80, 50)[0], 0xF5, "black hides");
|
|
// Vector: a fully transparent ellipse still masks by its outline.
|
|
let e = d.create(g, NodeKind::Ellipse { start: 0.0, sweep: std::f64::consts::TAU, inner: 0.0 }, "E", 0.0, 0.0, 50.0, 50.0).unwrap();
|
|
d.get_mut(e).unwrap().fills = vec![Paint::solid(Color::new(0.0, 0.0, 0.0, 0.0))];
|
|
d.get_mut(e).unwrap().is_mask = true;
|
|
d.get_mut(e).unwrap().mask_type = figcraft_doc::MaskType::Vector;
|
|
d.get_mut(g).unwrap().children = vec![e, r];
|
|
let pix = render_view(&d, p, &view(100, 100), &Options::default()).unwrap();
|
|
assert_eq!(px(&pix, 25, 25), [0, 0, 0, 255], "inside the outline");
|
|
assert_eq!(px(&pix, 80, 80)[0], 0xF5, "outside");
|
|
// As an alpha mask the transparent ellipse hides everything.
|
|
d.get_mut(e).unwrap().mask_type = figcraft_doc::MaskType::Alpha;
|
|
let pix = render_view(&d, p, &view(100, 100), &Options::default()).unwrap();
|
|
assert_eq!(px(&pix, 25, 25)[0], 0xF5);
|
|
}
|
|
|
|
#[test]
|
|
fn boolean_subtract_renders_hole() {
|
|
let mut d = figcraft_doc::Document::new("t");
|
|
let p = d.first_page().unwrap();
|
|
let b = d.create(p, NodeKind::BooleanOp { op: figcraft_doc::BoolOp::Subtract }, "B", 0.0, 0.0, 100.0, 100.0).unwrap();
|
|
d.get_mut(b).unwrap().fills = vec![Paint::solid(Color::BLACK)];
|
|
d.create(b, NodeKind::Rectangle, "A", 0.0, 0.0, 100.0, 100.0).unwrap();
|
|
d.create(b, NodeKind::Rectangle, "Hole", 25.0, 25.0, 50.0, 50.0).unwrap();
|
|
let pix = render_view(&d, p, &view(100, 100), &Options::default()).unwrap();
|
|
assert_eq!(px(&pix, 10, 10), [0, 0, 0, 255]);
|
|
assert_eq!(px(&pix, 50, 50)[0], 0xF5);
|
|
}
|
|
|
|
#[test]
|
|
fn text_renders_something() {
|
|
let mut d = figcraft_doc::Document::new("t");
|
|
let p = d.first_page().unwrap();
|
|
let mut td = TextData::new("Hello");
|
|
td.style.font_size = 32.0;
|
|
d.create(p, NodeKind::Text(td), "T", 0.0, 0.0, 200.0, 50.0).unwrap();
|
|
let pix = render_view(&d, p, &view(200, 60), &Options { background: Some(Color::WHITE), ..Default::default() }).unwrap();
|
|
let dark = pix.data().as_chunks::<4>().0.iter().filter(|c| c[0] < 100).count();
|
|
if figcraft_text::find_face("Inter", "Regular").is_some() {
|
|
assert!(dark > 50, "{dark}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn export_png_roundtrip() {
|
|
let (d, _, r) = doc_with_rect(Color::from_hex("00FF00").unwrap());
|
|
let pix = render_node(&d, r, 2.0).unwrap();
|
|
assert_eq!((pix.width(), pix.height()), (100, 60));
|
|
let png = encode_png(&pix).unwrap();
|
|
let back = image::load_from_memory(&png).unwrap().to_rgba8();
|
|
assert_eq!(back.get_pixel(50, 30).0, [0, 255, 0, 255]);
|
|
let _ = encode_jpeg(&pix, Color::WHITE, 90).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn exports_carry_the_colour_profile() {
|
|
use figcraft_color::ColorProfile;
|
|
use figcraft_doc::ExportFormat;
|
|
use image::ImageDecoder;
|
|
let (d, _, r) = doc_with_rect(Color::from_hex("00FF00").unwrap());
|
|
let pix = render_node(&d, r, 1.0).unwrap();
|
|
for profile in ColorProfile::ALL {
|
|
let png = encode_export(&pix, ExportFormat::Png, profile).unwrap();
|
|
// The inserted chunk has a valid CRC: the decoder accepts the file and keeps the pixels.
|
|
let mut dec = image::codecs::png::PngDecoder::new(std::io::Cursor::new(&png)).unwrap();
|
|
let icc = dec.icc_profile().unwrap();
|
|
assert_eq!(icc.is_some(), !profile.is_srgb(), "{profile:?}");
|
|
assert_eq!(image::load_from_memory(&png).unwrap().to_rgba8().get_pixel(5, 5).0, [0, 255, 0, 255]);
|
|
for f in [ExportFormat::Jpg, ExportFormat::Webp] {
|
|
assert!(!encode_export(&pix, f, profile).unwrap().is_empty());
|
|
}
|
|
assert!(encode_export(&pix, ExportFormat::Svg, profile).is_err());
|
|
}
|
|
assert_eq!(png_insert_chunk(b"not a png".to_vec(), b"sRGB", &[0]), b"not a png");
|
|
}
|
|
|
|
#[test]
|
|
fn images_are_managed_into_the_file_profile() {
|
|
use figcraft_color::{ColorProfile, ImageScaleMode, Paint};
|
|
let (mut d, _, r) = doc_with_rect(Color::WHITE);
|
|
let mut red = Pixmap::new(4, 4).unwrap();
|
|
red.fill(tiny_skia::Color::from_rgba8(255, 0, 0, 255));
|
|
d.images.insert("red".into(), figcraft_doc::ImageData { bytes: std::sync::Arc::new(encode_png(&red).unwrap()), width: 4, height: 4 });
|
|
d.get_mut(r).unwrap().fills = vec![Paint::image("red", ImageScaleMode::Fill)];
|
|
let center = |d: &Document| {
|
|
let pix = render_node(d, r, 1.0).unwrap();
|
|
let (x, y) = (pix.width() / 2, pix.height() / 2);
|
|
let px = pix.pixel(x, y).unwrap();
|
|
(pix, [px.red(), px.green(), px.blue()])
|
|
};
|
|
assert_eq!(center(&d).1, [255, 0, 0]);
|
|
// In a Display P3 file the (sRGB) image is converted into P3 values…
|
|
d.color_profile = ColorProfile::DisplayP3;
|
|
let (mut pix, rgb) = center(&d);
|
|
assert!(rgb[0].abs_diff(234) <= 1 && rgb[1].abs_diff(51) <= 1 && rgb[2].abs_diff(35) <= 1, "{rgb:?}");
|
|
// …and comes back to the screen unchanged.
|
|
to_srgb(&mut pix, d.color_profile);
|
|
let px = pix.pixel(pix.width() / 2, pix.height() / 2).unwrap();
|
|
assert!(px.red() >= 254 && px.green() <= 1 && px.blue() <= 1, "{px:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn hostile_sizes() {
|
|
let (mut d, p, r) = doc_with_rect(Color::BLACK);
|
|
let n = d.get_mut(r).unwrap();
|
|
n.width = f64::NAN;
|
|
n.height = 1e12;
|
|
n.effects.push(Effect { kind: EffectKind::LayerBlur(figcraft_doc::Blur::uniform(1e9)), visible: true });
|
|
let _ = render_view(&d, p, &view(64, 64), &Options::default());
|
|
let _ = render_node(&d, r, f64::NAN);
|
|
let _ = render_view(&d, p, &View { origin: (f64::NAN, 0.0), scale: 0.0, width: 0, height: 0 }, &Options::default());
|
|
}
|
|
|
|
#[test]
|
|
fn inner_shadow_reaches_every_edge() {
|
|
let (mut d, p, r) = doc_with_rect(Color::WHITE);
|
|
d.get_mut(r).unwrap().effects.push(Effect {
|
|
kind: EffectKind::InnerShadow(Shadow { color: Color::BLACK, offset: (0.0, 4.0), radius: 8.0, ..Default::default() }),
|
|
visible: true,
|
|
});
|
|
let pix = render_view(&d, p, &view(100, 100), &Options { background: Some(Color::WHITE), ..Default::default() }).unwrap();
|
|
// Left edge (no offset that way) is still shaded; the centre is not.
|
|
assert!(px(&pix, 11, 25)[0] < 235, "{:?}", px(&pix, 11, 25));
|
|
assert!(px(&pix, 35, 30)[0] > 245);
|
|
}
|