//! 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());
}
/// Type whose font size measures the cap height gives the em size it is drawn at: 14 pt measuring
/// a 0.7 em cap height is `font-size: 20px`, and its Auto leading is not written.
#[test]
fn type_gives_the_font_size_it_is_drawn_at() {
use vectorcraft_text::test_fonts::{HEIGHT_FAMILY, height_font};
vectorcraft_text::FontDb::global().add_font(height_font(true).unwrap());
let mut d = doc();
let st = CharStyle {
font_family: HEIGHT_FAMILY.into(),
size: 14.0,
font_height_reference: vectorcraft_doc::FontHeightReference::CapHeight,
..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-size"), Some("20px"));
assert!(prop(&r, "line-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("").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, with units: CSS takes a bare number only for 0, and a browser drops
// `font-size:18`, leaving the type at its default size (#3209). Presentation attributes take
// bare numbers (`text_layout`).
let spacing = prop(&r, "letter-spacing").unwrap_or_default();
assert!(prop(&r, "font-size") == Some("18px") && spacing.ends_with("px"), "{r:?}");
for decl in ["font-size:18px".to_string(), format!("letter-spacing:{spacing}")] {
assert!(style.contains(&decl), "{decl} as in SVG export: {style}");
}
let inline = export(&d, &ExportOptions { styling: Styling::InlineStyle, ..ExportOptions::default() });
assert!(inline.contains("font-size:18px") && inline.contains(&format!("letter-spacing:{spacing}")), "{inline}");
}
#[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}");
}
/// A conic gradient is a CSS conic gradient (CSS turns clockwise from straight up): from where
/// the vector points, at the center. A mirrored one has its stops mirrored about the seam, and an
/// elliptical one is written circular, with a warning.
#[test]
fn conic_gradients_give_css_conic_gradients() {
let mut d = doc();
let conic = |geom: Option| {
let mut g = GradientPaint::new(Gradient { kind: GradientKind::Conic, ..Gradient::default() });
g.geom = geom;
Appearance::basic(Paint::Gradient(Box::new(g)), Paint::None, 0.0)
};
let mut n = rect(&mut d, (0.0, 0.0, 100.0, 60.0), 0.0);
n.appearance = conic(None).into();
let id = add(&mut d, n);
assert_eq!(
prop(&one(&d, id, &CssOptions::default()), "background-image"),
Some("conic-gradient(from 90deg at 50px 30px, #ffffff 0%, #000000 100%)")
);
// Pointing straight up and mirrored: it sweeps counterclockwise, so black comes first clockwise.
let mut n = rect(&mut d, (0.0, 0.0, 100.0, 60.0), 0.0);
n.appearance = conic(Some(GradientGeom { start: Point::new(40.0, 30.0), end: Point::new(40.0, 0.0), aspect: -1.0, focal: None })).into();
let id = add(&mut d, n);
assert_eq!(
prop(&one(&d, id, &CssOptions::default()), "background-image"),
Some("conic-gradient(from 0deg at 40px 30px, #000000 0%, #ffffff 100%)")
);
let mut n = rect(&mut d, (0.0, 0.0, 100.0, 60.0), 0.0);
n.appearance = conic(Some(GradientGeom { start: Point::new(40.0, 30.0), end: Point::new(70.0, 30.0), aspect: 0.5, focal: None })).into();
let id = add(&mut d, n);
let sheet = css_rules(&d, &[id], &CssOptions::default());
assert!(sheet.warnings.iter().any(|w| w.contains("conic gradients are written circular")), "{:?}", sheet.warnings);
}
/// CSS has no diamond gradient: a diamond is the radial gradient through its square's corners (its
/// vector, and the aspect across it), at its center, with a warning.
#[test]
fn diamond_gradients_give_css_radial_gradients_through_their_corners() {
let mut d = doc();
let mut g = GradientPaint::new(Gradient { kind: GradientKind::Diamond, ..Gradient::default() });
g.geom = Some(GradientGeom { start: Point::new(40.0, 30.0), end: Point::new(70.0, 30.0), aspect: 0.5, focal: None });
let mut n = rect(&mut d, (0.0, 0.0, 100.0, 60.0), 0.0);
n.appearance = Appearance::basic(Paint::Gradient(Box::new(g)), Paint::None, 0.0).into();
let id = add(&mut d, n);
let sheet = css_rules(&d, &[id], &CssOptions::default());
assert!(sheet.warnings.iter().any(|w| w.contains("diamond gradients are written as radial gradients")), "{:?}", sheet.warnings);
assert_eq!(
prop(&one(&d, id, &CssOptions::default()), "background-image"),
Some("radial-gradient(ellipse 30px 15px at 40px 30px, #ffffff 0%, #000000 100%)")
);
}
/// A linear or radial gradient that repeats or reflects is a repeating CSS gradient of one cycle (a
/// run, or for Reflect a run and its mirror); a conic one has its stops repeated around the turn.
#[test]
fn spread_gradients_give_repeating_css_gradients() {
use vectorcraft_color::GradientSpread;
let mut d = doc();
let mut css = |kind, spread, geom: GradientGeom| {
let mut g = GradientPaint::new(Gradient { kind, spread, repeats: 4, ..Gradient::default() });
g.geom = Some(geom);
let mut n = rect(&mut d, (0.0, 0.0, 100.0, 60.0), 0.0);
n.appearance = Appearance::basic(Paint::Gradient(Box::new(g)), Paint::None, 0.0).into();
let id = add(&mut d, n);
prop(&one(&d, id, &CssOptions::default()), "background-image").map(str::to_string)
};
// Left to right across the box: a run is a quarter of it.
let across = GradientGeom { start: Point::new(0.0, 30.0), end: Point::new(100.0, 30.0), aspect: 1.0, focal: None };
assert_eq!(
css(GradientKind::Linear, GradientSpread::Repeat, across).as_deref(),
Some("repeating-linear-gradient(90deg, #ffffff 0%, #000000 25%)")
);
assert_eq!(
css(GradientKind::Linear, GradientSpread::Reflect, across).as_deref(),
Some("repeating-linear-gradient(90deg, #ffffff 0%, #000000 25%, #000000 25%, #ffffff 50%)")
);
// A period that is not a whole hundredth of the box keeps four decimals, so it does not drift
// across the box.
let short = GradientGeom { end: Point::new(10.3452, 30.0), ..across };
assert_eq!(
css(GradientKind::Linear, GradientSpread::Repeat, short).as_deref(),
Some("repeating-linear-gradient(90deg, #ffffff 0%, #000000 2.5863%)")
);
// About 1,000 periods across the box: six decimals, so their rounding adds up to at most a
// hundredth of a period at the far side (with four, `0.1%` would be almost half a period out
// there).
let tiny = GradientGeom { end: Point::new(0.400196, 30.0), ..across };
assert_eq!(
css(GradientKind::Linear, GradientSpread::Repeat, tiny).as_deref(),
Some("repeating-linear-gradient(90deg, #ffffff 0%, #000000 0.100049%)")
);
// A radial period of 0.03085 px out to a corner 67 px away: its radius keeps the decimals that
// hold the periods in place there.
let dot = GradientGeom { start: Point::new(40.0, 30.0), end: Point::new(40.1234, 30.0), aspect: 1.0, focal: None };
assert_eq!(
css(GradientKind::Radial, GradientSpread::Repeat, dot).as_deref(),
Some("repeating-radial-gradient(circle 0.03085px at 40px 30px, #ffffff 0%, #000000 100%)")
);
let ring = GradientGeom { start: Point::new(40.0, 30.0), end: Point::new(80.0, 30.0), aspect: 1.0, focal: None };
assert_eq!(
css(GradientKind::Radial, GradientSpread::Reflect, ring).as_deref(),
Some("repeating-radial-gradient(circle 20px at 40px 30px, #ffffff 0%, #000000 50%, #000000 50%, #ffffff 100%)")
);
// A diamond's stand-in, the ellipse through its corners, repeats with each run of squares.
assert_eq!(
css(GradientKind::Diamond, GradientSpread::Repeat, ring).as_deref(),
Some("repeating-radial-gradient(circle 10px at 40px 30px, #ffffff 0%, #000000 100%)")
);
let conic = css(GradientKind::Conic, GradientSpread::Repeat, ring).unwrap();
assert!(conic.starts_with("conic-gradient(from 90deg at 40px 30px, #ffffff 0%, #000000 25%, #ffffff 25%"), "{conic}");
assert!(conic.ends_with("#000000 100%)"), "{conic}");
}
#[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));
}
}