mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-29 12:17:14 -06:00
M4.96: Window › CSS Properties shows, copies and exports the CSS of the selection
The svg crate's new CSS writer (css_rules) gives the CSS web pages style objects with: rectangles and ellipses as background-color / linear-/radial-gradient(), border and border-radius; type as font, color, spacing and alignment; shadows, glows and blur as box-shadow, text-shadow and filter. It shares SVG export's declarations (transparency, fonts, type spacing, colours, gradient stops), which moved into svg/src/css.rs. Options: units, absolute position, width and height, unnamed objects, rasterize unsupported art (as a PNG background-image). Commands css.selection, css.generate and css.export; UI commands css.copy and css.exportFile; the CSS Properties panel with a highlighted code view. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
0f161926cf
commit
ed4eceb837
@@ -0,0 +1,129 @@
|
||||
//! Window → CSS Properties: the CSS web pages style objects with ([`vectorcraft_svg::css_rules`],
|
||||
//! which writes each property as SVG export writes it), for the selection or the whole document,
|
||||
//! and the `.css` file with the pictures of the art it rasterizes.
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::NodeId;
|
||||
use vectorcraft_svg::{CssOptions, CssSheet, css_rules};
|
||||
|
||||
use super::fileio::{default_name, encode, isolated, write_file};
|
||||
use super::*;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
query "css.selection",
|
||||
"CSS Properties",
|
||||
[],
|
||||
None,
|
||||
"{ids?: [id…] (default: the selection), units?: px|pt|mm|cm|in (default px; 1 px = 1 pt, as in SVG export), position?: false (position: absolute, left and top from the artboard's top-left), dimensions?: true (width, height), unnamed?: true (unnamed objects too, classes named after their kind; false: named objects only), rasterize?: false (art CSS can't describe as background-image: url(<class>.png), its picture, which css.export writes)} the CSS web pages style the objects with, one rule per object back to front (layers and plain groups stand for what they hold): a rectangle's or ellipse's fill as background-color or linear-/radial-gradient(), stroke as border, corners as border-radius; type's font, color, spacing and alignment; opacity, mix-blend-mode, shadows and glows (box-shadow, text-shadow), blur (filter) → {css, rules: [{id, selector, css, unsupported?: why CSS can't describe the art exactly (without rasterize it is written as its box), image?: the PNG it refers to}], skipped: n unnamed objects left out, warnings}",
|
||||
has_doc,
|
||||
selection
|
||||
),
|
||||
cmd!(query "css.generate", "Generate CSS", [], None, "{…css.selection options} css.selection of every object in the document", has_doc, generate),
|
||||
cmd!(
|
||||
"css.export",
|
||||
"Export CSS",
|
||||
[],
|
||||
None,
|
||||
"{path?, scope?: selection|all (default selection; ids? picks the objects), …css.selection options} write the CSS (css.selection, or css.generate for all) to path and, with rasterize, each picture its rules refer to next to it (PNG of the object's visual bounds, 1 px per point) → {path, bytes, rules: n, images: [path…], warnings}; no path → {data: the CSS, name: <document>.css, rules, images: [{name, dataBase64}], warnings}",
|
||||
has_doc,
|
||||
export
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// The CSS options of `p` (its other params are the command's own).
|
||||
fn options(p: &Value, cmd: &str) -> Result<CssOptions> {
|
||||
if !p.is_object() {
|
||||
return Ok(CssOptions::default());
|
||||
}
|
||||
CssOptions::deserialize(p).map_err(|e| bad(cmd, format!("CSS options: {e}")))
|
||||
}
|
||||
|
||||
/// The CSS of objects `ids` (back to front) with the options of `p`.
|
||||
fn sheet(s: &Session, p: &Value, ids: Vec<NodeId>, cmd: &str) -> Result<CssSheet> {
|
||||
let opts = options(p, cmd)?;
|
||||
let d = &s.doc()?.doc;
|
||||
Ok(css_rules(d, &d.paint_order(ids), &opts))
|
||||
}
|
||||
|
||||
/// Every object of the active document: its layers.
|
||||
fn everything(s: &Session) -> Result<Vec<NodeId>> {
|
||||
Ok(s.doc()?.doc.layers.iter().map(|l| l.id).collect())
|
||||
}
|
||||
|
||||
fn result(sheet: &CssSheet) -> Value {
|
||||
let rules: Vec<Value> = sheet
|
||||
.rules
|
||||
.iter()
|
||||
.map(|r| {
|
||||
let mut v = json!({ "id": r.id.0, "selector": r.selector, "css": r.text() });
|
||||
if let Some(why) = r.unsupported {
|
||||
v["unsupported"] = json!(why);
|
||||
}
|
||||
if let Some(image) = &r.image {
|
||||
v["image"] = json!(image);
|
||||
}
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
json!({ "css": sheet.text(), "rules": rules, "skipped": sheet.skipped, "warnings": sheet.warnings })
|
||||
}
|
||||
|
||||
fn selection(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = targets(s, p)?;
|
||||
Ok(result(&sheet(s, p, ids, "css.selection")?))
|
||||
}
|
||||
|
||||
fn generate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = everything(s)?;
|
||||
Ok(result(&sheet(s, p, ids, "css.generate")?))
|
||||
}
|
||||
|
||||
fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "css.export";
|
||||
let ids = match str_param(p, "scope").unwrap_or("selection") {
|
||||
"selection" => targets(s, p)?,
|
||||
"all" => everything(s)?,
|
||||
other => return Err(bad(C, format!("scope must be selection or all (got {other})"))),
|
||||
};
|
||||
let sheet = sheet(s, p, ids, C)?;
|
||||
if sheet.rules.is_empty() {
|
||||
let why = if sheet.skipped > 0 {
|
||||
"the objects are unnamed: name them, or give unnamed: true"
|
||||
} else {
|
||||
"no objects to export: select some, or give scope: all"
|
||||
};
|
||||
return Err(bad(C, why));
|
||||
}
|
||||
let css = format!("{}\n", sheet.text());
|
||||
let doc = &s.doc()?.doc;
|
||||
// The pictures of rasterized art.
|
||||
let mut images = Vec::new();
|
||||
for r in &sheet.rules {
|
||||
let Some(name) = &r.image else { continue };
|
||||
let (d, _) = isolated(doc, &[r.id], name).ok_or_else(|| bad(C, format!("{} paints nothing to rasterize", r.selector)))?;
|
||||
images.push((name, encode(&d, "png", &json!({}))?));
|
||||
}
|
||||
let n = sheet.rules.len();
|
||||
match str_param(p, "path") {
|
||||
Some(path) => {
|
||||
write_file(path, css.as_bytes())?;
|
||||
let folder = std::path::Path::new(path).parent();
|
||||
let mut written = Vec::new();
|
||||
for (name, bytes) in &images {
|
||||
let at = folder.map_or_else(|| name.to_string(), |f| f.join(name).to_string_lossy().into_owned());
|
||||
write_file(&at, bytes)?;
|
||||
written.push(at);
|
||||
}
|
||||
Ok(json!({ "path": path, "bytes": css.len(), "rules": n, "images": written, "warnings": sheet.warnings }))
|
||||
}
|
||||
None => {
|
||||
let images: Vec<Value> =
|
||||
images.iter().map(|(name, bytes)| json!({ "name": name, "dataBase64": vectorcraft_format::base64_encode(bytes) })).collect();
|
||||
Ok(json!({ "data": css, "name": default_name(doc, "css"), "rules": n, "images": images, "warnings": sheet.warnings }))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -131,7 +131,7 @@ fn selection(s: &mut Session, cmd: &str) -> Result<(Document, vectorcraft_geom::
|
||||
|
||||
/// Objects `ids` of `doc` alone on one layer (in that order, back to front), with one artboard
|
||||
/// named `name`: their visual bounds (`None` when they have none).
|
||||
pub(super) fn isolated(doc: &Document, ids: &[NodeId], name: &str) -> Option<(Document, vectorcraft_geom::Rect)> {
|
||||
pub(crate) fn isolated(doc: &Document, ids: &[NodeId], name: &str) -> Option<(Document, vectorcraft_geom::Rect)> {
|
||||
let nodes: Vec<Arc<Node>> = ids.iter().filter_map(|id| doc.node(*id).cloned().map(Arc::new)).collect();
|
||||
let bounds = nodes.iter().filter_map(|n| n.visual_bounds()).reduce(|a, b| a.union(b))?;
|
||||
let mut d = single_artboard(doc, bounds, name);
|
||||
|
||||
@@ -45,6 +45,7 @@ pub use dxf::{UNSUPPORTED, Unsupported, unsupported};
|
||||
pub use encode::{ARTBOARD_PARAMS, ArtboardPick, Encoded, encode, encode_all, encode_with_warnings};
|
||||
pub(crate) use encode::{anti_alias, background, with_single_artboard};
|
||||
pub use export::export_source;
|
||||
pub(crate) use export::isolated;
|
||||
use load::err;
|
||||
pub(crate) use load::import_svg;
|
||||
pub(crate) use load::native_file;
|
||||
@@ -789,7 +790,7 @@ pub(crate) fn merge(mut a: Value, b: Value) -> Value {
|
||||
}
|
||||
|
||||
/// A file name for bytes handed back without a path: the document's title with `ext`.
|
||||
fn default_name(doc: &vectorcraft_doc::Document, ext: &str) -> String {
|
||||
pub(crate) fn default_name(doc: &vectorcraft_doc::Document, ext: &str) -> String {
|
||||
let stem = std::path::Path::new(&doc.title).file_stem().map(|s| s.to_string_lossy().into_owned()).filter(|s| !s.is_empty());
|
||||
format!("{}.{ext}", stem.as_deref().unwrap_or("Untitled"))
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ mod colorcmds;
|
||||
pub mod colormgmt;
|
||||
pub mod colortheme;
|
||||
mod create;
|
||||
mod css;
|
||||
pub(crate) mod distortcmds;
|
||||
mod docinfo;
|
||||
mod docmenu;
|
||||
@@ -237,6 +238,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(fileio::dxfimport::specs());
|
||||
v.extend(webexport::specs());
|
||||
v.extend(assets::specs());
|
||||
v.extend(css::specs());
|
||||
v
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1114,6 +1114,8 @@ mod tests_colorthemes;
|
||||
#[cfg(test)]
|
||||
mod tests_containers;
|
||||
#[cfg(test)]
|
||||
mod tests_css;
|
||||
#[cfg(test)]
|
||||
mod tests_dashalign;
|
||||
#[cfg(test)]
|
||||
mod tests_distort;
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
//! 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 super::*;
|
||||
|
||||
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';", "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}");
|
||||
}
|
||||
|
||||
#[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");
|
||||
}
|
||||
|
||||
#[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();
|
||||
}
|
||||
Reference in New Issue
Block a user