Files
aiongg 8cc69ed45c CI: one test binary per crate, nextest, line tables only (#3855)
* CI: line tables only for workspace crates, no debug info for dependencies

* CI: one integration-test binary per crate

Each crate's tests/*.rs files become modules of tests/it/main.rs. Files that change
process-wide state (Color Settings, the font database, installed plug-ins, the working
directory), the fuzz sweeps and the test targets the Affinity corpus workflow names stay
their own binaries.

* Docs: name the integration tests' new paths

* CI: xtask test with nextest and doctests, one wasm check, clippy --keep-going

cargo xtask ci runs the tests with cargo nextest (cargo test when it is not installed),
builds them with --lib --bins --tests, runs the doctests as their own step, and checks
the wasm crates in one cargo check --keep-going call.
2026-10-23 21:36:10 -04:00

339 lines
16 KiB
Rust
Raw Permalink 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.
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(),
radii_relative: false,
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}"
);
}
/// An object centered on an artboard's right or bottom edge is positioned from that artboard's
/// top-left corner; on the edge two artboards share, away from its ends, from the one to the right
/// of it.
#[test]
fn position_is_from_the_artboard_whose_edge_holds_the_center() {
let mut d = doc();
// Artboard 2 is (500, 0) to (900, 300); artboard 3 starts at its right edge.
for rect in [Rect::new(500.0, 0.0, 900.0, 300.0), Rect::new(900.0, 0.0, 1000.0, 300.0)] {
let mut a = d.artboards[0].clone();
a.rect = rect;
d.artboards.push(a);
}
let opts = CssOptions { position: true, ..CssOptions::default() };
for ((x, y), want) in [((980.0, 100.0), ("80px", "100px")), ((600.0, 280.0), ("100px", "280px")), ((880.0, 100.0), ("-20px", "100px"))] {
let n = rect(&mut d, (x, y, 40.0, 40.0), 0.0);
let id = add(&mut d, n);
let r = one(&d, id, &opts);
assert_eq!((prop(&r, "left"), prop(&r, "top")), (Some(want.0), Some(want.1)), "at ({x}, {y})");
}
}
#[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', sans-serif"));
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());
}
/// Area type that Shrink Text to Fit scales down gives the size, spacing and leading it draws at.
#[test]
fn shrunk_area_type_gives_the_size_it_draws_at() {
let mut d = doc();
let st = CharStyle { size: 18.0, tracking: 50.0, leading: Some(24.0), fill: Paint::solid(Color::BLACK), ..CharStyle::default() };
let mut n = text(&mut d, "Area type that is too long for its frame", st);
let mut fit = 1.0;
if let NodeKind::Text(t) = &mut n.kind {
t.kind = vectorcraft_doc::TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 40.0)) };
t.area.fit = vectorcraft_doc::AreaFit::ShrinkText { min_percent: 10.0 };
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
assert!(!lay.overflow && lay.fit_scale < 0.9, "the text is shrunk to fit: {}", lay.fit_scale);
fit = lay.fit_scale;
}
let id = add(&mut d, n);
let r = one(&d, id, &CssOptions::default());
let px = |v: f64| format!("{}px", vectorcraft_svg::fmt_num(v, 3));
assert_eq!(prop(&r, "font-size"), Some(px(18.0 * fit).as_str()), "{r:?}");
assert_eq!(prop(&r, "letter-spacing"), Some(px(0.9 * fit).as_str()), "{r:?}");
assert_eq!(prop(&r, "line-height"), Some(px(24.0 * fit).as_str()), "{r:?}");
}
#[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());
}
#[test]
fn font_family_ends_with_a_generic_family_for_viewers_without_the_font() {
let family = |name: &str| {
let mut d = doc();
let st = CharStyle { font_family: name.into(), size: 18.0, ..CharStyle::default() };
let n = text(&mut d, "Hello", st);
let id = add(&mut d, n);
prop(&one(&d, id, &CssOptions::default()), "font-family").map(str::to_string)
};
// Source Sans 3 is a sans face; an unknown family gets the same default; a generic family stands alone.
assert_eq!(family("Source Sans 3").as_deref(), Some("'Source Sans 3', sans-serif"));
assert_eq!(family("No Such Font").as_deref(), Some("'No Such Font', sans-serif"));
assert_eq!(family("serif").as_deref(), Some("serif"));
}
#[test]
fn font_family_with_quote_or_backslash_is_escaped() {
for (family, want) in
[("Bob's Font", r"'Bob\'s Font', sans-serif"), (r"Back\slash Font", r"'Back\\slash Font', sans-serif"), ("Bob's", r"'Bob\'s', sans-serif")]
{
let mut d = doc();
let st = CharStyle { font_family: family.into(), size: 18.0, ..CharStyle::default() };
let n = text(&mut d, "Hello", st);
let id = add(&mut d, n);
let r = one(&d, id, &CssOptions::default());
assert_eq!(prop(&r, "font-family"), Some(want));
}
}