mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
Live rectangles drew elliptical corners after an uneven scale in v0.4.0; inconsistent on a rectangle that isn't square: - doc: LiveShape::fold_scale moves an uneven scale left in `xf` (files saved before #291) into the size and radii; `transform` uses it, and `folded()`/`corner_style` give Live Corners document units. - engine: object.setLiveShape folds that scale first, so setting a radius or kind (fields, dialog, widgets, MCP) gives circular corners on such shapes instead of keeping the ellipses. - geom: max_corner_radius/fitted_corner_radius are the one limit every corner shares (half the shorter side). - tools: corner widgets sit at the centres of the corners as drawn (a radius past the limit put them off the arc, further in along the long side), drag in document units on stretched shapes, and outline the edited corners in red at the limit, as the reference app does. - svg: CSS export writes the drawn radii (CSS would draw a radius past the limit when its neighbours leave room). - docs: mcp.md Live Corners, ROADMAP. Co-Authored-By: Claude Opus 5.5 <[email protected]>
252 lines
12 KiB
Rust
252 lines
12 KiB
Rust
//! 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(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 NodeKind::Path { live: Some(LiveShape::Rectangle { radii, kinds, .. }), .. } = &mut n.kind {
|
||
*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(NodeKind::Path { live: Some(LiveShape::Rectangle { kinds, .. }), .. }) = d.node_mut(id).map(|n| &mut n.kind) {
|
||
kinds[1] = CornerKind::InvertedRound;
|
||
}
|
||
assert!(prop(&one(&d, id, &CssOptions::default()), "border-radius").is_none(), "CSS can't cut a corner in");
|
||
// A radius past half the shorter side is drawn at half of it (#442), and exported so: CSS
|
||
// would draw it larger, its neighbours leaving room.
|
||
if let Some(NodeKind::Path { live: Some(LiveShape::Rectangle { radii, kinds, .. }), .. }) = d.node_mut(id).map(|n| &mut n.kind) {
|
||
*radii = [60.0, 8.0, 0.0, 4.0];
|
||
*kinds = Default::default();
|
||
}
|
||
assert_eq!(prop(&one(&d, id, &CssOptions::default()), "border-radius"), Some("25px 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(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('"', """).replace('\'', "'");
|
||
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);
|
||
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);
|
||
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);
|
||
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 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());
|
||
}
|