mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-15 23:05:39 +00:00
The Gradient panel's Method menu gets two methods of VectorCraft's own beside Classic and Perceptual. They mix a linear, radial, conic or diamond gradient's colors around the hue wheel: hue, saturation and brightness each change evenly from one stop to the next, and the hue angle keeps one direction, as the fixed-direction rules of https://www.w3.org/TR/css-color-4/#hue-interpolation do. Hue Clockwise lowers the angle (red, magenta, blue), which is clockwise on the color wheels, and Hue Counterclockwise raises it (red, yellow, green). Two stops whose hues are less than half a color level apart count as one hue and keep it, so a color and a tint or shade of it do not turn the whole wheel. A stop whose RGB numbers are less than half a color level apart is gray and takes the hue of the stop it mixes with, and black takes that stop's saturation too. - `paint.editGradient` and `paint.setFill` take `method: hue-clockwise` or `hue-counterclockwise`, in any case, with a space or an underscore for the hyphen or without it; other spellings are an error. The native file writes `HueClockwise` and `HueCounterclockwise`. - The canvas and raster exports draw the gradient through stops on its curve, within half a color level. - Reverse turns the method the other way with the stops, and a Reflect spread is repeated from the curve's stops, so both keep the colors. - SVG and PDF write the gradient as an RGB image, CMYK and spot color stops included, with the spot warning. EPS writes a shading in process colors with a warning for spot stops. A legacy .ai file and the .ai editing data get the colors between the stops as stops of their own, in process colors, at most 256, with a warning. CSS gets those stops too. - The `method` error names the four values, in every catalog. - The Method menu is as wide as its longest name in the interface language, within the panel. Docs: docs/mcp.md, docs/plugins.md, docs/target-app-parity.md, ROADMAP.
225 lines
12 KiB
Rust
225 lines
12 KiB
Rust
//! CSS Properties: `css.selection` (rectangles' backgrounds, borders and radii, type's fonts,
|
|
//! gradients), `css.generate` over the document and `css.export` writing the style sheet and the
|
|
//! pictures of rasterized art.
|
|
|
|
use serde_json::{Value, json};
|
|
|
|
use crate::*;
|
|
|
|
fn session() -> Session {
|
|
let mut s = Session::new();
|
|
s.execute("file.new", &json!({"width": 300, "height": 200})).unwrap();
|
|
s
|
|
}
|
|
|
|
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
|
s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}"))
|
|
}
|
|
|
|
fn css(v: &Value) -> &str {
|
|
v["css"].as_str().unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn a_rounded_rectangle_gives_background_border_and_radius() {
|
|
let mut s = session();
|
|
let id = run(&mut s, "shape.rectangle", json!({"x": 10, "y": 20, "width": 120, "height": 60, "radius": 6}))["id"].as_u64().unwrap();
|
|
run(&mut s, "paint.setFill", json!({"color": "#3366cc"}));
|
|
run(&mut s, "paint.setStroke", json!({"color": "#000000"}));
|
|
run(&mut s, "layer.setProps", json!({"id": id, "name": "Button"}));
|
|
let r = run(&mut s, "css.selection", json!({"position": true}));
|
|
let text = css(&r);
|
|
for decl in ["background-color: #3366cc;", "border: 1px solid #000000;", "border-radius: 6px;", "left: 10px;", "top: 20px;", "width: 120px;"] {
|
|
assert!(text.contains(decl), "{decl} in {text}");
|
|
}
|
|
assert!(text.starts_with(".Button {\n"), "{text}");
|
|
assert_eq!(r["rules"][0]["id"], json!(id));
|
|
assert_eq!(r["rules"][0]["selector"], json!(".Button"));
|
|
assert!(r["rules"][0].get("unsupported").is_none());
|
|
// A query: no undo step, no change.
|
|
let rev = s.doc().unwrap().revision;
|
|
run(&mut s, "css.selection", json!({"units": "mm"}));
|
|
assert_eq!(s.doc().unwrap().revision, rev);
|
|
assert!(s.execute("css.selection", &json!({"units": "furlong"})).is_err(), "unknown units are refused");
|
|
}
|
|
|
|
#[test]
|
|
fn type_gives_font_properties_and_gradients_css_gradients() {
|
|
let mut s = session();
|
|
let t = run(&mut s, "text.create", json!({"x": 20, "y": 50, "text": "Title", "size": 24}))["id"].as_u64().unwrap();
|
|
let text = css(&run(&mut s, "css.selection", json!({"ids": [t]}))).to_string();
|
|
for decl in ["font-family: 'Source Sans 3', sans-serif;", "font-size: 24px;", "color: #"] {
|
|
assert!(text.contains(decl), "{decl} in {text}");
|
|
}
|
|
run(&mut s, "shape.rectangle", json!({"x": 0, "y": 100, "width": 100, "height": 50}));
|
|
run(&mut s, "paint.setFill", json!({"gradient": {"kind": "linear"}}));
|
|
let text = css(&run(&mut s, "css.selection", json!({}))).to_string();
|
|
assert!(text.contains("background-image: linear-gradient(90deg, "), "{text}");
|
|
}
|
|
|
|
/// CSS gradients mix linearly: a gradient mixed around the hue wheel is written through the stops
|
|
/// on its way around the wheel, so red to blue lists green one way and magenta the other.
|
|
#[test]
|
|
fn gradients_mixed_around_the_hue_wheel_give_css_the_stops_on_their_way() {
|
|
let mut s = session();
|
|
run(&mut s, "shape.rectangle", json!({"x": 0, "y": 100, "width": 100, "height": 50}));
|
|
let stops = json!([{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]);
|
|
let gradient = |s: &mut Session, method: &str| {
|
|
run(s, "paint.setFill", json!({"gradient": {"kind": "linear", "method": method, "stops": stops}}));
|
|
let text = css(&run(s, "css.selection", json!({}))).to_string();
|
|
text.lines().find(|l| l.contains("linear-gradient(")).unwrap_or_else(|| panic!("a gradient in {text}")).to_string()
|
|
};
|
|
let classic = gradient(&mut s, "classic");
|
|
assert_eq!(classic.matches('%').count(), 2, "{classic}");
|
|
for (method, middle, other) in [("hue-counterclockwise", "#00ff00 50%", "#ff00ff"), ("hue-clockwise", "#ff00ff 50%", "#00ff00")] {
|
|
let line = gradient(&mut s, method);
|
|
assert!(line.matches('%').count() > 2, "{method}: {line}");
|
|
assert!(line.contains("#ff0000 0%") && line.contains("#0000ff 100%"), "{method}: {line}");
|
|
assert!(line.contains(middle) && !line.contains(other), "{method}: {line}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn generate_covers_the_document_and_an_empty_selection_has_none() {
|
|
let mut s = session();
|
|
run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10}));
|
|
run(&mut s, "shape.ellipse", json!({"x": 20, "y": 0, "width": 10, "height": 10}));
|
|
run(&mut s, "select.none", json!({}));
|
|
let none = run(&mut s, "css.selection", json!({}));
|
|
assert_eq!(css(&none), "");
|
|
let all = run(&mut s, "css.generate", json!({}));
|
|
let sel: Vec<&str> = all["rules"].as_array().unwrap().iter().map(|r| r["selector"].as_str().unwrap()).collect();
|
|
assert_eq!(sel, [".rectangle", ".ellipse"]);
|
|
assert!(css(&all).contains("border-radius: 50%;"), "{}", css(&all));
|
|
let named = run(&mut s, "css.generate", json!({"unnamed": false}));
|
|
assert_eq!((named["rules"].as_array().unwrap().len(), named["skipped"].as_u64()), (0, Some(2)));
|
|
}
|
|
|
|
#[test]
|
|
fn export_writes_the_sheet_and_rasterized_art() {
|
|
let mut s = session();
|
|
run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 40}));
|
|
run(&mut s, "shape.star", json!({"cx": 100, "cy": 100, "radius1": 40, "radius2": 20}));
|
|
// Without a path: the CSS as data, the pictures as base64.
|
|
let r = run(&mut s, "css.export", json!({"scope": "all", "rasterize": true}));
|
|
let data = r["data"].as_str().unwrap();
|
|
assert!(data.contains(".rectangle {") && data.contains(".path {\n") && data.contains("background-image: url(path.png);"), "{data}");
|
|
assert!(data.ends_with("}\n"));
|
|
assert_eq!(r["rules"], json!(2));
|
|
let images = r["images"].as_array().unwrap();
|
|
assert_eq!(images.len(), 1, "only the star is rasterized");
|
|
assert_eq!(images[0]["name"], json!("path.png"));
|
|
let png = vectorcraft_format::base64_decode(images[0]["dataBase64"].as_str().unwrap()).unwrap();
|
|
assert!(png.starts_with(b"\x89PNG"));
|
|
// The selection alone (the star, selected last).
|
|
let r = run(&mut s, "css.export", json!({}));
|
|
assert_eq!(r["rules"], json!(1));
|
|
assert!(r["images"].as_array().unwrap().is_empty(), "no rasterize: no pictures");
|
|
assert!(s.execute("css.export", &json!({"scope": "page"})).is_err());
|
|
run(&mut s, "select.none", json!({}));
|
|
assert!(s.execute("css.export", &json!({})).is_err(), "nothing selected: nothing to export");
|
|
}
|
|
|
|
#[test]
|
|
fn export_refuses_a_scope_or_path_that_is_not_a_string() {
|
|
let mut s = session();
|
|
run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 40}));
|
|
run(&mut s, "shape.ellipse", json!({"x": 60, "y": 0, "width": 40, "height": 40}));
|
|
let refused = |s: &mut Session, p: Value, key: &str| {
|
|
let e = s.execute("css.export", &p).expect_err(&p.to_string()).to_string();
|
|
assert_eq!(e, format!("invalid parameters for `css.export`: `{key}` must be a string"), "{p}");
|
|
};
|
|
for scope in [json!(5), json!(true), json!(["all"]), Value::Null] {
|
|
refused(&mut s, json!({"scope": scope}), "scope");
|
|
}
|
|
for path in [json!(5), Value::Null] {
|
|
refused(&mut s, json!({"path": path}), "path");
|
|
refused(&mut s, json!({"path": path, "scope": "all"}), "path");
|
|
}
|
|
// Left out, each keeps its default: the selection (the ellipse), and the CSS as data.
|
|
let r = run(&mut s, "css.export", json!({}));
|
|
assert_eq!((r["rules"].clone(), r["data"].is_string()), (json!(1), true), "{r}");
|
|
assert_eq!(run(&mut s, "css.export", json!({"scope": "all"}))["rules"], json!(2));
|
|
let e = s.execute("css.export", &json!({"scope": "page"})).unwrap_err().to_string();
|
|
assert!(e.contains("scope must be selection or all (got page)"), "{e}");
|
|
}
|
|
|
|
#[test]
|
|
fn every_object_refuses_malformed_ids_it_does_not_use() {
|
|
let mut s = session();
|
|
let rect = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 40}))["id"].as_u64().unwrap();
|
|
run(&mut s, "shape.ellipse", json!({"x": 60, "y": 0, "width": 40, "height": 40}));
|
|
for (cmd, base) in [("css.generate", json!({})), ("css.export", json!({"scope": "all"}))] {
|
|
for (key, value, says) in [
|
|
("ids", json!("abc"), "`ids` must be an array of object ids, not \"abc\""),
|
|
("ids", json!([1.5]), "object ids are non-negative integers, not 1.5"),
|
|
("id", json!(-1), "object ids are non-negative integers, not -1"),
|
|
("id", json!("$step"), "`id` is the step reference \"$step\""),
|
|
] {
|
|
let mut p = base.clone();
|
|
p[key] = value;
|
|
let e = s.execute(cmd, &p).expect_err(&format!("{cmd} {p}")).to_string();
|
|
assert!(e.contains(says), "{cmd} {p}: {e}");
|
|
}
|
|
// Well-formed ids are still not used: every object.
|
|
let mut p = base.clone();
|
|
p["ids"] = json!([rect]);
|
|
let rules = run(&mut s, cmd, p)["rules"].clone();
|
|
let count = rules.as_array().map_or(rules.as_u64(), |r| Some(r.len() as u64));
|
|
assert_eq!(count, Some(2), "{cmd}: {rules}");
|
|
}
|
|
}
|
|
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
#[test]
|
|
fn export_with_a_scope_that_is_not_a_string_writes_nothing() {
|
|
let mut s = session();
|
|
run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 40}));
|
|
let dir = std::env::temp_dir().join(format!("vc-css-scope-{}", std::process::id()));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let path = dir.join("site.css");
|
|
assert!(s.execute("css.export", &json!({"path": path.to_string_lossy(), "scope": 5})).is_err());
|
|
assert!(!path.exists(), "no file for a refused scope");
|
|
std::fs::remove_dir_all(&dir).ok();
|
|
}
|
|
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
#[test]
|
|
fn export_to_a_path_puts_the_pictures_next_to_the_sheet() {
|
|
let mut s = session();
|
|
run(&mut s, "shape.star", json!({"cx": 100, "cy": 100, "radius1": 40, "radius2": 20}));
|
|
let dir = std::env::temp_dir().join(format!("vc-css-{}", std::process::id()));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let path = dir.join("site.css");
|
|
let r = run(&mut s, "css.export", json!({"path": path.to_string_lossy(), "rasterize": true}));
|
|
let written = std::fs::read_to_string(&path).unwrap();
|
|
assert!(written.contains("url(path.png)"), "{written}");
|
|
assert_eq!(r["bytes"], json!(written.len()));
|
|
let png = dir.join("path.png");
|
|
assert_eq!(r["images"], json!([png.to_string_lossy()]));
|
|
assert!(std::fs::read(&png).unwrap().starts_with(b"\x89PNG"));
|
|
std::fs::remove_dir_all(&dir).ok();
|
|
}
|
|
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
#[test]
|
|
fn export_says_once_when_a_rasterized_image_could_not_be_decoded() {
|
|
const NOTE: &str = "an image couldn't be decoded and is missing from the picture";
|
|
let notes = |r: &Value| r["warnings"].as_array().unwrap().iter().filter(|w| w.as_str() == Some(NOTE)).count();
|
|
let mut s = session();
|
|
run(&mut s, "shape.star", json!({"cx": 100, "cy": 100, "radius1": 40, "radius2": 20}));
|
|
assert_eq!(notes(&run(&mut s, "css.export", json!({"scope": "all", "rasterize": true}))), 0);
|
|
// Two images the renderer can't decode, each rasterized to a picture of its own.
|
|
crate::tests_menucmds::place_undecodable_png(&mut s);
|
|
crate::tests_menucmds::place_undecodable_png(&mut s);
|
|
let r = run(&mut s, "css.export", json!({"scope": "all", "rasterize": true}));
|
|
assert_eq!(r["images"].as_array().unwrap().len(), 3, "{r}");
|
|
assert_eq!(notes(&r), 1, "{}", r["warnings"]);
|
|
let dir = std::env::temp_dir().join(format!("vc-css-undecoded-{}", std::process::id()));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let r = run(&mut s, "css.export", json!({"path": dir.join("site.css").to_string_lossy(), "scope": "all", "rasterize": true}));
|
|
assert_eq!(r["images"].as_array().unwrap().len(), 3, "the pictures are still written");
|
|
assert_eq!(notes(&r), 1, "{}", r["warnings"]);
|
|
std::fs::remove_dir_all(&dir).ok();
|
|
}
|