Files
vectorcraft/crates/svg/tests/css_rules.rs
T
vangel76andClaude Opus 5.5 8875b3fbca Live Corners: a corner rounds until its cut reaches its neighbours (#2332)
A corner's radius was capped at half its shorter side whatever its neighbours did, so one corner of a 100 pt square rounded alone stopped at 50 pt. Each corner now rounds until its cut reaches the cuts its neighbours ask for: the whole side when the neighbour is square, halfway when both ask for more. The corner widget drag, the red limit outline and CSS border-radius export follow the same limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 13:20:26 +07:00

262 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! CSS for objects (the CSS Properties panel): shapes give backgrounds, borders and corner radii,
//! type its font, gradients `linear-gradient()`/`radial-gradient()`, and every property both write
//! reads as SVG export writes it.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use vectorcraft_color::{BlendMode, Color, Gradient, GradientGeom, GradientKind, GradientPaint, Paint};
use vectorcraft_doc::{Appearance, CharStyle, Document, Effect, Justify, LiveShape, Node, NodeId, NodeKind, TextObject};
use vectorcraft_geom::shapes::{self, CornerKind};
use vectorcraft_geom::{Affine, Point, Rect};
use vectorcraft_svg::{CssOptions, CssRule, CssUnits, ExportOptions, Styling, css_rules, export};
fn doc() -> Document {
Document::new(400.0, 300.0)
}
fn add(d: &mut Document, n: Node) -> NodeId {
let (l, id) = (d.layers[0].id, n.id);
d.insert(Some(l), usize::MAX, n).unwrap();
id
}
/// A live rectangle at (x, y), `w` × `h`, with corner `radius`, filled red and stroked 2 pt blue.
fn rect(d: &mut Document, (x, y, w, h): (f64, f64, f64, f64), radius: f64) -> Node {
let fill = Paint::solid(Color::rgb8(255, 0, 0));
let r = Rect::new(0.0, 0.0, w, h);
let mut n = Node::path(
d.alloc_id(),
shapes::rounded_rectangle(r, radius).transformed(Affine::translate((x, y))),
Appearance::basic(fill, Paint::solid(Color::rgb8(0, 0, 255)), 2.0),
);
if let NodeKind::Path { live, .. } = &mut n.kind {
*live = Some(Box::new(LiveShape::Rectangle { w, h, radii: [radius; 4], kinds: Default::default(), xf: Affine::translate((x, y)) }));
}
n
}
fn text(d: &mut Document, s: &str, st: CharStyle) -> Node {
Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(20.0, 40.0), s, st))))
}
fn prop<'a>(r: &'a CssRule, k: &str) -> Option<&'a str> {
r.props.iter().find(|(p, _)| *p == k).map(|(_, v)| v.as_str())
}
fn one(d: &Document, id: NodeId, opts: &CssOptions) -> CssRule {
let sheet = css_rules(d, &[id], opts);
assert_eq!(sheet.rules.len(), 1, "{sheet:?}");
sheet.rules.into_iter().next().unwrap()
}
#[test]
fn rectangle_gives_background_border_and_radius() {
let mut d = doc();
let mut n = rect(&mut d, (10.0, 20.0, 100.0, 50.0), 8.0);
n.name = Some("Hero Card".into());
let id = add(&mut d, n);
let opts = CssOptions { position: true, ..CssOptions::default() };
let r = one(&d, id, &opts);
assert_eq!(r.selector, ".Hero_Card");
assert_eq!(prop(&r, "background-color"), Some("#ff0000"));
assert_eq!(prop(&r, "border"), Some("2px solid #0000ff"));
assert_eq!(prop(&r, "border-radius"), Some("8px"));
assert_eq!((prop(&r, "position"), prop(&r, "left"), prop(&r, "top")), (Some("absolute"), Some("10px"), Some("20px")));
assert_eq!((prop(&r, "width"), prop(&r, "height")), (Some("100px"), Some("50px")));
assert!(r.unsupported.is_none() && r.image.is_none());
assert_eq!(
r.text(),
".Hero_Card {\n position: absolute;\n left: 10px;\n top: 20px;\n width: 100px;\n height: 50px;\n background-color: #ff0000;\n border: 2px solid #0000ff;\n border-radius: 8px;\n}"
);
}
#[test]
fn live_corners_give_per_corner_radii_and_only_round_ones() {
let mut d = doc();
let mut n = rect(&mut d, (0.0, 0.0, 100.0, 50.0), 0.0);
if let Some(LiveShape::Rectangle { radii, kinds, .. }) = n.live_shape_mut() {
*radii = [0.0, 8.0, 0.0, 4.0];
kinds[0] = CornerKind::Chamfer;
}
let id = add(&mut d, n);
assert_eq!(prop(&one(&d, id, &CssOptions::default()), "border-radius"), Some("0 8px 0 4px"), "a square corner's kind doesn't matter");
if let Some(LiveShape::Rectangle { kinds, .. }) = d.node_mut(id).and_then(|n| n.live_shape_mut()) {
kinds[1] = CornerKind::InvertedRound;
}
assert!(prop(&one(&d, id, &CssOptions::default()), "border-radius").is_none(), "CSS can't cut a corner in");
// A radius is drawn no larger than its neighbours leave room for, past half the shorter
// side when they are small (#2332), and exported as drawn: 50 less the 4 below it.
if let Some(LiveShape::Rectangle { radii, kinds, .. }) = d.node_mut(id).and_then(|n| n.live_shape_mut()) {
*radii = [60.0, 8.0, 0.0, 4.0];
*kinds = Default::default();
}
assert_eq!(prop(&one(&d, id, &CssOptions::default()), "border-radius"), Some("46px 8px 0 4px"));
}
#[test]
fn ellipses_round_fully_and_options_change_units_and_names() {
let mut d = doc();
let mut e =
Node::path(d.alloc_id(), shapes::ellipse(Rect::new(0.0, 0.0, 72.0, 36.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0));
if let NodeKind::Path { live, .. } = &mut e.kind {
*live = Some(Box::new(LiveShape::Ellipse { w: 72.0, h: 36.0, pie: (0.0, 360.0), xf: Affine::IDENTITY }));
}
let e = add(&mut d, e);
let a = rect(&mut d, (0.0, 0.0, 10.0, 10.0), 0.0);
let a = add(&mut d, a);
let b = rect(&mut d, (20.0, 0.0, 10.0, 10.0), 0.0);
let b = add(&mut d, b);
let opts = CssOptions { units: CssUnits::In, ..CssOptions::default() };
let sheet = css_rules(&d, &[e, a, b], &opts);
let sel: Vec<&str> = sheet.rules.iter().map(|r| r.selector.as_str()).collect();
assert_eq!(sel, [".ellipse", ".rectangle", ".rectangle-2"], "unnamed objects are named after their kind, uniquely");
assert_eq!(prop(&sheet.rules[0], "border-radius"), Some("50%"));
assert_eq!((prop(&sheet.rules[0], "width"), prop(&sheet.rules[0], "height")), (Some("1in"), Some("0.5in")));
assert!(prop(&sheet.rules[1], "border-radius").is_none(), "square corners");
// Named objects only: the unnamed ones are counted, not written.
let named = css_rules(&d, &[e, a, b], &CssOptions { unnamed: false, ..CssOptions::default() });
assert!(named.rules.is_empty());
assert_eq!(named.skipped, 3);
// No dimensions: no width or height.
let r = one(&d, a, &CssOptions { dimensions: false, ..CssOptions::default() });
assert!(prop(&r, "width").is_none() && prop(&r, "height").is_none());
}
#[test]
fn type_gives_font_properties() {
let mut d = doc();
let st = CharStyle {
font_family: "Source Sans 3".into(),
font_style: "Bold Italic".into(),
size: 18.0,
tracking: 50.0,
leading: Some(24.0),
underline: true,
fill: Paint::solid(Color::rgb8(0x33, 0x66, 0x99)),
..CharStyle::default()
};
let mut n = text(&mut d, "Hello", st);
if let NodeKind::Text(t) = &mut n.kind {
t.para.justify = Justify::Center;
}
let id = add(&mut d, n);
let r = one(&d, id, &CssOptions::default());
assert_eq!(r.selector, ".type");
assert_eq!(prop(&r, "font-family"), Some("'Source Sans 3'"));
assert_eq!(prop(&r, "font-size"), Some("18px"));
assert_eq!(prop(&r, "font-weight"), Some("bold"));
assert_eq!(prop(&r, "font-style"), Some("italic"));
assert_eq!(prop(&r, "color"), Some("#336699"));
assert_eq!(prop(&r, "letter-spacing"), Some("0.9px"));
assert_eq!(prop(&r, "line-height"), Some("24px"));
assert_eq!(prop(&r, "text-decoration"), Some("underline"));
assert_eq!(prop(&r, "text-align"), Some("center"));
assert!(prop(&r, "background-color").is_none() && r.unsupported.is_none());
}
#[test]
fn shared_properties_read_as_svg_export_writes_them() {
let mut d = doc();
let st = CharStyle {
font_family: "Source Sans 3".into(),
font_style: "Bold".into(),
size: 18.0,
kerning: Some(20.0),
features: vec!["dlig".into(), "-liga".into()],
strikethrough: true,
fill: Paint::solid(Color::BLACK),
..CharStyle::default()
};
let mut n = text(&mut d, "Hello", st);
n.opacity = 0.5;
n.blend = BlendMode::Multiply;
let id = add(&mut d, n);
let r = one(&d, id, &CssOptions::default());
let svg = export(&d, &ExportOptions { styling: Styling::InternalCss, ..ExportOptions::default() });
let style = &svg[svg.find("<style>").unwrap()..svg.find("</style>").unwrap()];
for k in ["font-family", "font-weight", "font-kerning", "font-feature-settings", "text-decoration", "opacity", "mix-blend-mode"] {
let v = prop(&r, k).unwrap_or_else(|| panic!("{k} is written: {r:?}"));
// The SVG sheet escapes quotes as XML.
let decl = format!("{k}:{v}").replace('"', "&quot;").replace('\'', "&apos;");
assert!(style.contains(&decl), "{decl} as in SVG export: {style}");
}
// Lengths are the same numbers, with units on the web.
assert!(style.contains("font-size:18") && prop(&r, "font-size") == Some("18px"), "{style}");
}
#[test]
fn gradients_give_css_gradients() {
let mut d = doc();
let mut n = rect(&mut d, (0.0, 0.0, 200.0, 100.0), 0.0);
n.appearance = Appearance::basic(Paint::Gradient(Box::new(GradientPaint::new(Gradient::default()))), Paint::None, 0.0).into();
let id = add(&mut d, n);
let r = one(&d, id, &CssOptions::default());
assert_eq!(prop(&r, "background-image"), Some("linear-gradient(90deg, #ffffff 0%, #000000 100%)"));
// Placed top to bottom over the upper half: the stops sit on the vertical gradient line.
let mut g = GradientPaint::new(Gradient::default());
g.geom = Some(GradientGeom { start: Point::new(100.0, 0.0), end: Point::new(100.0, 50.0), aspect: 1.0, focal: None });
let mut n = rect(&mut d, (0.0, 0.0, 200.0, 100.0), 0.0);
n.appearance = Appearance::basic(Paint::Gradient(Box::new(g)), Paint::None, 0.0).into();
let id = add(&mut d, n);
assert_eq!(prop(&one(&d, id, &CssOptions::default()), "background-image"), Some("linear-gradient(180deg, #ffffff 0%, #000000 50%)"));
// Radial: a circle at the box's centre.
let radial = GradientPaint::new(Gradient { kind: GradientKind::Radial, ..Gradient::default() });
let mut n = rect(&mut d, (0.0, 0.0, 100.0, 100.0), 0.0);
n.appearance = Appearance::basic(Paint::Gradient(Box::new(radial)), Paint::None, 0.0).into();
let id = add(&mut d, n);
let bg = one(&d, id, &CssOptions::default()).props.into_iter().find(|(k, _)| *k == "background-image").unwrap().1;
assert!(bg.starts_with("radial-gradient(circle ") && bg.contains(" at 50px 50px, #ffffff 0%, #000000 100%)"), "{bg}");
}
#[test]
fn shadows_and_unsupported_art() {
let mut d = doc();
let mut n = rect(&mut d, (0.0, 0.0, 50.0, 50.0), 0.0);
n.appearance.effects.push(Effect {
id: "stylize.dropShadow".into(),
params: serde_json::json!({"x": 2, "y": 3, "blur": 4, "opacity": 50}),
visible: true,
});
let id = add(&mut d, n);
let r = one(&d, id, &CssOptions::default());
assert_eq!(prop(&r, "box-shadow"), Some("2px 3px 4px rgba(0, 0, 0, 0.5)"));
// A shadow with Darkness takes the object's own colors, which a CSS shadow can't.
let mut n = rect(&mut d, (0.0, 0.0, 50.0, 50.0), 0.0);
n.appearance.effects.push(Effect {
id: "stylize.dropShadow".into(),
params: serde_json::json!({"x": 2, "y": 3, "blur": 4, "opacity": 50, "shade": "darkness"}),
visible: true,
});
let shaded = add(&mut d, n);
let r = one(&d, shaded, &CssOptions::default());
assert!(prop(&r, "box-shadow").is_none() && r.unsupported.is_some(), "{r:?}");
// A star is no box: described as its box, or with rasterize as its picture.
let star = Node::path(
d.alloc_id(),
shapes::polygon(Point::new(50.0, 50.0), 30.0, 5, 0.0),
Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0),
);
let star = add(&mut d, star);
let boxed = css_rules(&d, &[star], &CssOptions::default());
let r = &boxed.rules[0];
assert!(r.unsupported.is_some() && r.image.is_none() && prop(r, "background-color") == Some("#000000"));
assert!(!boxed.warnings.is_empty());
let r = one(&d, star, &CssOptions { rasterize: true, ..CssOptions::default() });
assert_eq!(r.image.as_deref(), Some("path.png"));
assert_eq!(prop(&r, "background-image"), Some("url(path.png)"));
assert!(prop(&r, "background-color").is_none());
}
#[test]
fn groups_stand_for_their_objects_and_hidden_ones_are_left_out() {
let mut d = doc();
let a = rect(&mut d, (0.0, 0.0, 10.0, 10.0), 0.0);
let mut b = rect(&mut d, (20.0, 0.0, 10.0, 10.0), 0.0);
b.visible = false;
let g = Node::group(d.alloc_id(), vec![std::sync::Arc::new(a), std::sync::Arc::new(b)]);
let g = add(&mut d, g);
let sheet = css_rules(&d, &[g, d.layers[0].id], &CssOptions::default());
assert_eq!(sheet.rules.len(), 1, "one visible rectangle, once: {sheet:?}");
assert_eq!(sheet.text(), sheet.rules[0].text());
}