From ed4eceb837b629bbb12cf4a64656b2a7c6e49a56 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 16:03:11 +0700 Subject: [PATCH] =?UTF-8?q?M4.96:=20Window=20=E2=80=BA=20CSS=20Properties?= =?UTF-8?q?=20shows,=20copies=20and=20exports=20the=20CSS=20of=20the=20sel?= =?UTF-8?q?ection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/engine/src/cmd/css.rs | 129 ++++ crates/engine/src/cmd/fileio/export.rs | 2 +- crates/engine/src/cmd/fileio/mod.rs | 3 +- crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/lib.rs | 2 + crates/engine/src/tests_css.rs | 117 ++++ crates/svg/src/css.rs | 636 ++++++++++++++++++ crates/svg/src/export.rs | 127 +--- crates/svg/src/lib.rs | 6 + crates/svg/tests/css_rules.rs | 227 +++++++ crates/ui-egui/src/menus.rs | 16 + crates/ui-egui/src/panels/css_properties.rs | 237 +++++++ crates/ui-egui/src/panels/mod.rs | 5 + .../src/panels/tests_css_properties.rs | 103 +++ crates/ui-egui/src/state.rs | 1 + docs/control-protocol.md | 1 + docs/mcp.md | 20 + 17 files changed, 1524 insertions(+), 110 deletions(-) create mode 100644 crates/engine/src/cmd/css.rs create mode 100644 crates/engine/src/tests_css.rs create mode 100644 crates/svg/src/css.rs create mode 100644 crates/svg/tests/css_rules.rs create mode 100644 crates/ui-egui/src/panels/css_properties.rs create mode 100644 crates/ui-egui/src/panels/tests_css_properties.rs diff --git a/crates/engine/src/cmd/css.rs b/crates/engine/src/cmd/css.rs new file mode 100644 index 00000000..08fdbab6 --- /dev/null +++ b/crates/engine/src/cmd/css.rs @@ -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 { + 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(.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: .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 { + 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, cmd: &str) -> Result { + 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> { + Ok(s.doc()?.doc.layers.iter().map(|l| l.id).collect()) +} + +fn result(sheet: &CssSheet) -> Value { + let rules: Vec = 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 { + let ids = targets(s, p)?; + Ok(result(&sheet(s, p, ids, "css.selection")?)) +} + +fn generate(s: &mut Session, p: &Value) -> Result { + let ids = everything(s)?; + Ok(result(&sheet(s, p, ids, "css.generate")?)) +} + +fn export(s: &mut Session, p: &Value) -> Result { + 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 = + 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 })) + } + } +} diff --git a/crates/engine/src/cmd/fileio/export.rs b/crates/engine/src/cmd/fileio/export.rs index 2c2ffffc..b794d075 100644 --- a/crates/engine/src/cmd/fileio/export.rs +++ b/crates/engine/src/cmd/fileio/export.rs @@ -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> = 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); diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index 1339a4cf..ea76cadc 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -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")) } diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index f81e023e..3f2259ff 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -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 }) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index ed569292..70eb333a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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; diff --git a/crates/engine/src/tests_css.rs b/crates/engine/src/tests_css.rs new file mode 100644 index 00000000..e62e243a --- /dev/null +++ b/crates/engine/src/tests_css.rs @@ -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(); +} diff --git a/crates/svg/src/css.rs b/crates/svg/src/css.rs new file mode 100644 index 00000000..df45d5b8 --- /dev/null +++ b/crates/svg/src/css.rs @@ -0,0 +1,636 @@ +//! CSS. SVG export's style attributes and sheets, and the CSS web pages style objects with +//! ([`css_rules`]: the CSS Properties panel and its exports), write each property they share in +//! the same way: the declarations of transparency, fonts and type spacing come from the functions +//! here, colours are `#rrggbb` (or `rgba()` where CSS has no separate opacity), gradients take the +//! stops SVG export writes, and numbers go through [`fmt_num`]. + +use std::collections::HashSet; + +use serde::{Deserialize, Serialize}; +use vectorcraft_color::{BlendMode, Color, GradientKind, GradientPaint, Paint}; +use vectorcraft_doc::{AppearanceItem, CharStyle, Document, Justify, LineCap, LiveShape, Node, NodeId, NodeKind, StrokeAlign, StrokeLayer, TextKind}; +use vectorcraft_effects::RasterFx; +use vectorcraft_geom::{Affine, PathData, Point, Rect}; + +use crate::export::sanitize_id; +use crate::fmt_num; + +/// CSS declarations, in order: (property, value). +pub(crate) type Props = Vec<(&'static str, String)>; + +/// A declaration block: `property:value;property:value`. +pub(crate) fn declarations<'a>(props: impl IntoIterator) -> String { + props.into_iter().map(|(k, v)| format!("{k}:{v}")).collect::>().join(";") +} + +/// A CSS string. +pub(crate) fn css_string(s: &str) -> String { + format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")) +} + +pub(crate) fn blend_css(b: BlendMode) -> &'static str { + match b { + BlendMode::Normal => "normal", + BlendMode::Darken => "darken", + BlendMode::Multiply => "multiply", + BlendMode::ColorBurn => "color-burn", + BlendMode::Lighten => "lighten", + BlendMode::Screen => "screen", + BlendMode::ColorDodge => "color-dodge", + BlendMode::Overlay => "overlay", + BlendMode::SoftLight => "soft-light", + BlendMode::HardLight => "hard-light", + BlendMode::Difference => "difference", + BlendMode::Exclusion => "exclusion", + BlendMode::Hue => "hue", + BlendMode::Saturation => "saturation", + BlendMode::Color => "color", + BlendMode::Luminosity => "luminosity", + } +} + +/// An object's opacity, blend mode and isolation. +pub(crate) fn transparency(n: &Node) -> Props { + let mut p = Props::new(); + if n.opacity < 1.0 { + p.push(("opacity", fmt_num(n.opacity as f64, 3))); + } + if n.blend != BlendMode::Normal { + p.push(("mix-blend-mode", blend_css(n.blend).into())); + } + if n.isolate { + p.push(("isolation", "isolate".into())); + } + p +} + +/// The weight and italic of the face style `st` asks for: what embedded fonts' `@font-face` +/// rules and the characters using them say, so each face is told apart (Semibold from Bold). +pub(crate) fn font_descriptor(st: &CharStyle) -> (u16, bool) { + let fs = st.font_style.to_ascii_lowercase(); + (vectorcraft_text::style_weight(&st.font_style).round().clamp(1.0, 1000.0) as u16, fs.contains("italic") || fs.contains("oblique")) +} + +/// The font of character style `st`: family, size (the vertical scale; writers stretch the +/// horizontal one), weight and style, lengths written by `len`. `numeric_weight`: the face's +/// weight as a number (embedded faces are told apart by it), else `bold` for bold styles. +pub(crate) fn font_props(st: &CharStyle, len: &dyn Fn(f64) -> String, numeric_weight: bool) -> Props { + let mut p = Props::new(); + let fam = if st.font_family.contains(|c: char| c.is_whitespace() || c == ',') { format!("'{}'", st.font_family) } else { st.font_family.clone() }; + p.push(("font-family", fam)); + p.push(("font-size", len(st.size * st.v_scale / 100.0))); + let fs = st.font_style.to_ascii_lowercase(); + let (weight, italic) = font_descriptor(st); + if numeric_weight { + if weight != 400 { + p.push(("font-weight", weight.to_string())); + } + } else if fs.contains("bold") || fs.contains("black") || fs.contains("heavy") { + p.push(("font-weight", "bold".into())); + } + if italic { + p.push(("font-style", "italic".into())); + } + p +} + +/// The spacing, kerning, OpenType features and decorations of character style `st`, lengths +/// written by `len`. +pub(crate) fn type_props(st: &CharStyle, len: &dyn Fn(f64) -> String) -> Props { + let mut p = Props::new(); + // Tracking and manual kerning both add space after every character. + let spacing = st.tracking + st.kerning.unwrap_or(0.0); + if spacing != 0.0 { + p.push(("letter-spacing", len(spacing / 1000.0 * st.size))); + } + if st.kerning.is_some() { + // Manual kerning replaces the font's pair kerning. + p.push(("font-kerning", "none".into())); + } + if !st.features.is_empty() { + let v: Vec = st + .features + .iter() + .map(|t| match t.strip_prefix('-') { + Some(off) => format!("\"{off}\" 0"), + None => format!("\"{t}\" 1"), + }) + .collect(); + p.push(("font-feature-settings", v.join(", "))); + } + match (st.underline, st.strikethrough) { + (true, true) => p.push(("text-decoration", "underline line-through".into())), + (true, false) => p.push(("text-decoration", "underline".into())), + (false, true) => p.push(("text-decoration", "line-through".into())), + _ => {} + } + p +} + +/// A colour at `opacity`: `#rrggbb` when opaque, else `rgba()`. +fn color_css(c: &Color, opacity: f32) -> String { + if opacity >= 1.0 { + return c.to_hex(); + } + let [r, g, b, _] = c.to_rgba8(1.0); + format!("rgba({r}, {g}, {b}, {})", fmt_num(opacity.clamp(0.0, 1.0) as f64, 3)) +} + +// ---------- CSS for objects ---------- + +/// The units of the lengths [`css_rules`] writes (CSS Properties → Units). One CSS pixel is one +/// point, as in SVG export; the other units keep the document's physical sizes (72 points to the +/// inch). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum CssUnits { + #[default] + Px, + Pt, + Mm, + Cm, + In, +} + +impl CssUnits { + pub const ALL: [CssUnits; 5] = [CssUnits::Px, CssUnits::Pt, CssUnits::Mm, CssUnits::Cm, CssUnits::In]; + + /// The unit's CSS name. + pub fn name(self) -> &'static str { + match self { + CssUnits::Px => "px", + CssUnits::Pt => "pt", + CssUnits::Mm => "mm", + CssUnits::Cm => "cm", + CssUnits::In => "in", + } + } + + /// `points` in these units (`0` without a unit). + fn len(self, points: f64) -> String { + let per_point = match self { + CssUnits::Px | CssUnits::Pt => 1.0, + CssUnits::Mm => 25.4 / 72.0, + CssUnits::Cm => 2.54 / 72.0, + CssUnits::In => 1.0 / 72.0, + }; + match fmt_num(points * per_point, 3) { + zero if zero == "0" => zero, + v => format!("{v}{}", self.name()), + } + } +} + +/// What [`css_rules`] writes (CSS Properties → Export Options). Deserializes from camelCase JSON +/// with every field optional. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct CssOptions { + pub units: CssUnits, + /// `position: absolute` with `left` and `top` from the top-left corner of the object's + /// artboard (the one its centre is on, else the first). + pub position: bool, + /// `width` and `height`. + pub dimensions: bool, + /// Rules for unnamed objects too, named after their kind (`.rectangle`); else unnamed objects + /// are left out ([`CssSheet::skipped`]). + pub unnamed: bool, + /// Art CSS can't describe ([`CssRule::unsupported`]) as a `background-image` of its own + /// picture ([`CssRule::image`]); else it is described as the box it sits in. + pub rasterize: bool, +} + +impl Default for CssOptions { + fn default() -> Self { + Self { units: CssUnits::Px, position: false, dimensions: true, unnamed: true, rasterize: false } + } +} + +/// The CSS rule of one object. +#[derive(Clone, Debug, PartialEq)] +pub struct CssRule { + pub id: NodeId, + /// `.name`: the object's name made a class name, else (unnamed) its kind's, unique in the sheet. + pub selector: String, + pub props: Vec<(&'static str, String)>, + /// Why CSS can't describe the object exactly (other shapes, images, patterns, live effects…). + pub unsupported: Option<&'static str>, + /// With [`CssOptions::rasterize`], the file name of the PNG `background-image` refers to for + /// unsupported art: the writer of the CSS file writes the object's picture under it (its + /// visual bounds at 1 px per point, which `left`/`top`/`width`/`height` then describe). + pub image: Option, +} + +impl CssRule { + /// The rule as written: `.name {` and one declaration a line. + pub fn text(&self) -> String { + let mut s = format!("{} {{\n", self.selector); + for (k, v) in &self.props { + s.push_str(&format!(" {k}: {v};\n")); + } + s.push('}'); + s + } +} + +/// The CSS of a set of objects. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct CssSheet { + pub rules: Vec, + /// Unnamed objects left out (without [`CssOptions::unnamed`]). + pub skipped: usize, + /// What the rules leave out or approximate (once each). + pub warnings: Vec, +} + +impl CssSheet { + /// The style sheet: the rules, a blank line between them. + pub fn text(&self) -> String { + self.rules.iter().map(CssRule::text).collect::>().join("\n\n") + } +} + +/// The CSS of objects `ids` of `doc` (back to front) as web pages style elements: one rule per +/// object. Layers and unnamed groups without transparency or effects stand for the objects in +/// them; hidden objects, guides and template layers are left out. +/// +/// A rectangle, rounded rectangle or ellipse gives its fill as `background-color` (gradients as +/// `linear-gradient()`/`radial-gradient()`), its stroke as `border` and its corners as +/// `border-radius`; type gives its first character style's font, colour and spacing and its +/// paragraph's alignment; shadows and glows give `box-shadow` (`text-shadow`), a Gaussian blur +/// `filter: blur()`. Other art is described as its box (see [`CssOptions::rasterize`]). +pub fn css_rules(doc: &Document, ids: &[NodeId], opts: &CssOptions) -> CssSheet { + let mut w = Rules { doc, opts, sheet: CssSheet::default(), seen: HashSet::new(), names: HashSet::new() }; + for n in ids.iter().filter_map(|id| doc.node(*id)) { + w.object(n); + } + w.sheet +} + +struct Rules<'a> { + doc: &'a Document, + opts: &'a CssOptions, + sheet: CssSheet, + /// Objects written (an object and a group holding it may both be asked for). + seen: HashSet, + /// Class names given, lower case. + names: HashSet, +} + +/// The CSS of one object's art, and why it is approximate. +#[derive(Default)] +struct Art { + props: Props, + unsupported: Option<&'static str>, +} + +impl Art { + /// Note that CSS can't describe the art exactly (the first reason stays). + fn unsupported(&mut self, why: &'static str) { + self.unsupported.get_or_insert(why); + } +} + +impl Rules<'_> { + fn len(&self, points: f64) -> String { + self.opts.units.len(points) + } + + fn warn(&mut self, w: &str) { + if !self.sheet.warnings.iter().any(|x| x == w) { + self.sheet.warnings.push(w.to_string()); + } + } + + fn object(&mut self, n: &Node) { + if !n.visible { + return; + } + let plain_group = n.name.is_none() && transparency(n).is_empty() && !n.appearance.effects.iter().any(|e| e.visible) && n.mask.is_none(); + match &n.kind { + NodeKind::Layer { template: true, .. } | NodeKind::Path { guide: true, .. } => {} + NodeKind::Layer { children, clip: false, .. } => children.iter().for_each(|c| self.object(c)), + NodeKind::Group { children, clip: false } if plain_group => children.iter().for_each(|c| self.object(c)), + _ => self.rule(n), + } + } + + /// A class name for `name` (an object's, or its kind's), unique in the sheet. + fn class_name(&mut self, name: &str) -> String { + let base = sanitize_id(name).replace('.', "_"); + let mut cand = base.clone(); + for i in 2.. { + if self.names.insert(cand.to_lowercase()) { + break; + } + cand = format!("{base}-{i}"); + } + cand + } + + fn rule(&mut self, n: &Node) { + if !self.seen.insert(n.id) { + return; + } + let name = match n.name.as_deref().map(str::trim).filter(|s| !s.is_empty()) { + Some(name) => name.to_string(), + None if self.opts.unnamed => n.kind_label().to_lowercase().replace(' ', "-"), + None => { + self.sheet.skipped += 1; + return; + } + }; + let class = self.class_name(&name); + let art = self.art(n); + let image = art.unsupported.filter(|_| self.opts.rasterize).map(|_| format!("{class}.png")); + // A picture covers what the object paints. + let bounds = if image.is_some() { n.visual_bounds() } else { n.geometric_bounds() }; + let mut props = self.box_props(bounds); + match &image { + Some(file) => { + props.push(("background-image", format!("url({file})"))); + // The picture has the object's opacity; its blend mode stays a property. + props.extend(transparency(n).into_iter().filter(|(k, _)| *k != "opacity")); + } + None => { + props.extend(art.props); + props.extend(transparency(n)); + if art.unsupported.is_some() { + self.warn("art CSS can't describe exactly is written as its box (rasterize writes it as a picture)"); + } + } + } + self.sheet.rules.push(CssRule { id: n.id, selector: format!(".{class}"), props, unsupported: art.unsupported, image }); + } + + /// Position and dimensions of a box (as the options ask), from its artboard's top-left. + fn box_props(&self, b: Option) -> Props { + let mut p = Props::new(); + let Some(b) = b else { return p }; + if self.opts.position { + let c = b.center(); + let ab = self.doc.artboards.iter().find(|a| a.rect.contains(c)).or_else(|| self.doc.artboards.first()); + let origin = ab.map_or(Point::ZERO, |a| Point::new(a.rect.x0, a.rect.y0)); + p.push(("position", "absolute".into())); + p.push(("left", self.len(b.x0 - origin.x))); + p.push(("top", self.len(b.y0 - origin.y))); + } + if self.opts.dimensions { + p.push(("width", self.len(b.width()))); + p.push(("height", self.len(b.height()))); + } + p + } + + /// The art of `n` as CSS: paints, corners, type and effects. + fn art(&mut self, n: &Node) -> Art { + let mut art = Art::default(); + let mut radius = None; + let text = match &n.kind { + NodeKind::Path { path, live, .. } => { + match corners(path, live.as_ref()) { + Some(Corners::Square) => {} + Some(Corners::Round(r)) => radius = Some(r.iter().map(|r| self.len(*r)).collect::>().join(" ")), + Some(Corners::Ellipse) => radius = Some("50%".into()), + None => art.unsupported("a shape other than a rectangle or an ellipse"), + } + false + } + NodeKind::Compound { .. } => { + art.unsupported("a compound path"); + false + } + NodeKind::Text(t) => { + self.type_art(n, t, &mut art); + true + } + _ => { + art.unsupported("art other than shapes and type"); + false + } + }; + if !text { + self.paints(n, &mut art); + } + art.props.extend(radius.map(|r| ("border-radius", r))); + self.effects(n, text, &mut art); + if n.mask.as_ref().is_some_and(|m| !m.disabled) { + art.unsupported("an opacity mask"); + } + art + } + + /// A shape's fill (`background-*`) and stroke (`border*`). + fn paints(&mut self, n: &Node, art: &mut Art) { + let bounds = n.geometric_bounds().unwrap_or_default(); + let shown = |i: &&AppearanceItem| i.visible() && !i.paint().is_none() && !matches!(i, AppearanceItem::Stroke(s) if s.width <= 0.0); + let items: Vec<&AppearanceItem> = n.appearance.items.iter().filter(shown).collect(); + let fills: Vec<_> = items.iter().filter_map(|i| if let AppearanceItem::Fill(f) = i { Some(f) } else { None }).collect(); + let strokes: Vec<_> = items.iter().filter_map(|i| if let AppearanceItem::Stroke(s) = i { Some(s) } else { None }).collect(); + if fills.len() > 1 || strokes.len() > 1 { + art.unsupported("several fills or strokes"); + } + if items.iter().any(|i| i.blend() != BlendMode::Normal || i.effects().iter().any(|e| e.visible)) { + art.unsupported("blend modes or effects on a fill or stroke"); + } + // The topmost of each, as the canvas shows it on top. + if let Some(f) = fills.last() { + match &f.paint { + Paint::Solid { color, .. } => art.props.push(("background-color", color_css(color, f.opacity))), + Paint::Gradient(g) => match self.gradient(g, bounds, f.opacity) { + Some(css) => art.props.push(("background-image", css)), + None => art.unsupported("a freeform gradient"), + }, + Paint::Pattern { .. } => art.unsupported("a pattern"), + Paint::None => {} + } + } + if let Some(s) = strokes.last() { + self.border(s, bounds, art); + } + } + + /// A stroke as a border: weight, style (dashed; dotted for round dots) and colour. + fn border(&mut self, s: &StrokeLayer, bounds: Rect, art: &mut Art) { + // Aligned strokes still draw as borders; arrowheads, profiles, brushes and fitted dashes don't. + if !vectorcraft_effects::stroke::is_plain(&StrokeLayer { align: StrokeAlign::Center, ..s.clone() }) { + art.unsupported("arrowheads, width profiles, brushes or dashes fitted to corners"); + } + let style = match &s.dash { + Some(d) if d.is_dashed() && s.cap == LineCap::Round && d.pattern.first() == Some(&0.0) => "dotted", + Some(d) if d.is_dashed() => "dashed", + _ => "solid", + }; + let width = self.len(s.width); + match &s.paint { + Paint::Solid { color, .. } => art.props.push(("border", format!("{width} {style} {}", color_css(color, s.opacity)))), + Paint::Gradient(g) => match self.gradient(g, s.paint_bounds(bounds), s.opacity) { + Some(css) => { + art.props.push(("border", format!("{width} {style}"))); + art.props.push(("border-image", format!("{css} 1"))); + } + None => art.unsupported("a freeform gradient"), + }, + Paint::Pattern { .. } => art.unsupported("a pattern"), + Paint::None => {} + } + } + + /// A linear or radial gradient over `b` at `opacity`, with the stops SVG export writes + /// (midpoints as stops of their own). `None` for freeform gradients. + fn gradient(&mut self, g: &GradientPaint, b: Rect, opacity: f32) -> Option { + let geom = g.resolve(b); + let stops = |pos: &dyn Fn(f32) -> f64| { + let stops: Vec = + g.gradient.expanded().map(|(t, c, o, _)| format!("{} {}%", color_css(&c, o * opacity), fmt_num(pos(t) * 100.0, 2))).collect(); + stops.join(", ") + }; + match g.gradient.kind { + GradientKind::Linear => { + // CSS runs the gradient along a line through the box's centre at its angle (0deg up, + // 90deg right), long enough for the corners: the stops are placed on it. + let v = geom.end - geom.start; + let length = v.hypot(); + let (dir, angle) = if length > 1e-9 { + (v / length, v.x.atan2(-v.y).to_degrees().rem_euclid(360.0)) + } else { + (vectorcraft_geom::Vec2::new(1.0, 0.0), 90.0) + }; + let line = (b.width() * dir.x).abs() + (b.height() * dir.y).abs(); + let from = (geom.start - b.center()).dot(dir); + let pos = |t: f32| if line > 1e-9 { (from + t as f64 * length) / line + 0.5 } else { t as f64 }; + Some(format!("linear-gradient({}deg, {})", fmt_num(angle, 2), stops(&pos))) + } + GradientKind::Radial => { + let r = (geom.end - geom.start).hypot(); + if geom.focal.is_some() { + self.warn("radial gradients start at their centre (an off-centre focal point is left out)"); + } + let shape = if (geom.aspect - 1.0).abs() < 1e-9 { + format!("circle {}", self.len(r)) + } else { + format!("ellipse {} {}", self.len(r), self.len(r * geom.aspect)) + }; + let at = format!("at {} {}", self.len(geom.start.x - b.x0), self.len(geom.start.y - b.y0)); + Some(format!("radial-gradient({shape} {at}, {})", stops(&|t| t as f64))) + } + GradientKind::Freeform => None, + } + } + + /// Type: its first character style's font, colour and spacing, its paragraph's alignment. + fn type_art(&mut self, n: &Node, t: &vectorcraft_doc::TextObject, art: &mut Art) { + if matches!(t.kind, TextKind::OnPath { .. }) { + art.unsupported("type on a path"); + } + if n.appearance.items.iter().any(|i| i.visible() && !i.paint().is_none()) { + art.unsupported("fills or strokes on the type object"); + } + let st = t.first_style(); + if t.runs.iter().any(|r| r.style != st) { + self.warn("type with several character styles is written in its first one"); + } + let len = |v: f64| self.len(v); + art.props.extend(font_props(&st, &len, false)); + match &st.fill { + Paint::Solid { color, .. } => art.props.push(("color", color.to_hex())), + Paint::None => art.props.push(("color", "transparent".into())), + _ => art.unsupported("type painted with a gradient or a pattern"), + } + if st.has_stroke() { + match &st.stroke { + Paint::Solid { color, .. } => art.props.push(("-webkit-text-stroke", format!("{} {}", self.len(st.stroke_width), color.to_hex()))), + _ => art.unsupported("type painted with a gradient or a pattern"), + } + } + art.props.extend(type_props(&st, &len)); + if let Some(l) = st.leading { + art.props.push(("line-height", self.len(l))); + } + let align = match t.para.justify { + Justify::Left => None, + Justify::Center => Some("center"), + Justify::Right => Some("right"), + Justify::JustifyLeft | Justify::JustifyCenter | Justify::JustifyRight | Justify::JustifyAll => Some("justify"), + }; + art.props.extend(align.map(|a| ("text-align", a.to_string()))); + if st.all_caps { + art.props.push(("text-transform", "uppercase".into())); + } + } + + /// Shadows and glows as `box-shadow` (type: `text-shadow`), a Gaussian blur as `filter`. + fn effects(&mut self, n: &Node, text: bool, art: &mut Art) { + let fx = &n.appearance.effects; + if fx.iter().any(|e| e.visible && !vectorcraft_effects::is_raster(&e.id)) { + art.unsupported("live effects"); + } + let mut shadows = vec![]; + let mut filter = None; + for f in vectorcraft_effects::raster_effects(fx) { + match f { + RasterFx::DropShadow { opacity, dx, dy, blur, color, .. } => { + shadows.push(format!("{} {} {} {}", self.len(dx), self.len(dy), self.len(blur), color_css(&color, opacity))) + } + RasterFx::OuterGlow { opacity, blur, color, .. } => shadows.push(format!("0 0 {} {}", self.len(blur), color_css(&color, opacity))), + RasterFx::InnerGlow { opacity, blur, color, center: false, .. } if !text => { + shadows.push(format!("inset 0 0 {} {}", self.len(blur), color_css(&color, opacity))) + } + RasterFx::GaussianBlur { radius } => filter = Some(format!("blur({})", self.len(radius / 2.0))), + RasterFx::InnerGlow { .. } | RasterFx::Feather { .. } => art.unsupported("inner glows or feathering"), + } + } + if !shadows.is_empty() { + art.props.push((if text { "text-shadow" } else { "box-shadow" }, shadows.join(", "))); + } + art.props.extend(filter.map(|f| ("filter", f))); + } +} + +/// How a box's corners round. +enum Corners { + Square, + /// Top-left, top-right, bottom-right, bottom-left (one value when all are the same). + Round(Vec), + Ellipse, +} + +/// The corners of a path that is an upright rectangle (a live one keeps its radii) or a whole, +/// upright live ellipse; `None` for other shapes. +fn corners(path: &PathData, live: Option<&LiveShape>) -> Option { + let upright = |xf: &Affine| { + let [_, b, c, _, _, _] = xf.as_coeffs(); + b.abs() < 1e-9 && c.abs() < 1e-9 + }; + match live { + Some(LiveShape::Rectangle { radii, xf, .. }) if upright(xf) => { + let [a, _, _, d, _, _] = xf.as_coeffs(); + let s = (a * d).abs().sqrt(); + if radii.iter().all(|r| *r <= 0.0) { + Some(Corners::Square) + } else if radii.iter().all(|r| (r - radii[0]).abs() < 1e-9) { + Some(Corners::Round(vec![radii[0] * s])) + } else { + Some(Corners::Round(radii.iter().map(|r| r.max(0.0) * s).collect())) + } + } + Some(LiveShape::Ellipse { pie, xf, .. }) if upright(xf) => { + let sweep = (pie.1 - pie.0).abs(); + (sweep < 1e-6 || (sweep - 360.0).abs() < 1e-6).then_some(Corners::Ellipse) + } + Some(LiveShape::Rectangle { .. } | LiveShape::Ellipse { .. } | LiveShape::Polygon { .. } | LiveShape::Line { .. }) => None, + None => is_upright_rectangle(path).then_some(Corners::Square), + } +} + +/// One closed subpath of four straight sides along the axes. +fn is_upright_rectangle(path: &PathData) -> bool { + let ([sp], Some(b)) = (path.subpaths.as_slice(), path.bounds()) else { return false }; + let corner = |p: Point| ((p.x - b.x0).abs() < 1e-6 || (p.x - b.x1).abs() < 1e-6) && ((p.y - b.y0).abs() < 1e-6 || (p.y - b.y1).abs() < 1e-6); + sp.closed + && sp.anchors.len() == 4 + && (0..sp.segment_count()).all(|i| sp.segment_is_line(i)) + && sp.anchors.iter().all(|a| corner(a.p)) + && b.width() > 0.0 + && b.height() > 0.0 +} diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index 24ff2203..663992a9 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -19,7 +19,7 @@ use crate::{ xml_escape, }; -type Props = Vec<(&'static str, String)>; +use crate::css::{self, Props, blend_css, css_string, font_descriptor}; pub(crate) fn export(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>) -> Output { // Live geometry effects (Roughen, Warp, Offset Path, Effect → Pathfinder…) export as their result. @@ -167,13 +167,6 @@ fn latin1_refs(s: &mut String) { *s = o; } -/// The weight and italic of the face style `st` asks for: what embedded fonts' `@font-face` -/// rules and the characters using them say, so each face is told apart (Semibold from Bold). -fn font_descriptor(st: &CharStyle) -> (u16, bool) { - let fs = st.font_style.to_ascii_lowercase(); - (vectorcraft_text::style_weight(&st.font_style).round().clamp(1.0, 1000.0) as u16, fs.contains("italic") || fs.contains("oblique")) -} - /// `chars` (sorted) as a CSS `unicode-range` (`U+41-5A,U+61`). fn unicode_range(chars: &BTreeSet) -> String { let mut runs: Vec<(u32, u32)> = Vec::new(); @@ -187,11 +180,6 @@ fn unicode_range(chars: &BTreeSet) -> String { runs.into_iter().map(one).collect::>().join(",") } -/// A CSS string. -fn css_string(s: &str) -> String { - format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")) -} - /// A CSS declaration block as the literal value of an ``: quotes, `%` and markup /// characters become references that still read as themselves inside the `style` attribute. fn entity_value(decl: &str) -> String { @@ -409,29 +397,8 @@ const MASK: &str = "maskUnits=\"userSpaceOnUse\" color-interpolation=\"sRGB\""; /// (clipping, not inverted): `noclip`, `invert`. pub(crate) const MASK_FLAGS: &str = "data-vectorcraft-mask"; -pub(crate) fn blend_css(b: BlendMode) -> &'static str { - match b { - BlendMode::Normal => "normal", - BlendMode::Darken => "darken", - BlendMode::Multiply => "multiply", - BlendMode::ColorBurn => "color-burn", - BlendMode::Lighten => "lighten", - BlendMode::Screen => "screen", - BlendMode::ColorDodge => "color-dodge", - BlendMode::Overlay => "overlay", - BlendMode::SoftLight => "soft-light", - BlendMode::HardLight => "hard-light", - BlendMode::Difference => "difference", - BlendMode::Exclusion => "exclusion", - BlendMode::Hue => "hue", - BlendMode::Saturation => "saturation", - BlendMode::Color => "color", - BlendMode::Luminosity => "luminosity", - } -} - /// Turn an object name into a valid, readable XML id. -fn sanitize_id(name: &str) -> String { +pub(crate) fn sanitize_id(name: &str) -> String { let mut s: String = name.trim().chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' { c } else { '_' }).collect(); if s.is_empty() || !s.starts_with(|c: char| c.is_alphabetic() || c == '_') { s.insert(0, '_'); @@ -531,7 +498,6 @@ impl Writer<'_> { if props.is_empty() { return String::new(); } - let css = |ps: &[&(&str, String)]| ps.iter().map(|(k, v)| format!("{k}:{v}")).collect::>().join(";"); // SVG Tiny has presentation attributes alone. let styling = if self.tiny() { Styling::PresentationAttributes } else { self.opts.styling }; match styling { @@ -544,15 +510,15 @@ impl Writer<'_> { if self.tiny() { self.warn("SVG Tiny 1.2 has no style sheets: blend modes, isolation and font features are left out"); } else { - s.push_str(&format!(" style=\"{}\"", xml_escape(&css(&style)))); + s.push_str(&format!(" style=\"{}\"", xml_escape(&css::declarations(style)))); } } s } - Styling::InlineStyle => format!(" style=\"{}\"", xml_escape(&css(&props.iter().collect::>()))), - Styling::StyleEntities => format!(" style=\"&st{};\"", self.class(css(&props.iter().collect::>()))), + Styling::InlineStyle => format!(" style=\"{}\"", xml_escape(&css::declarations(props))), + Styling::StyleEntities => format!(" style=\"&st{};\"", self.class(css::declarations(props))), Styling::InternalCss => { - let i = self.class(css(&props.iter().collect::>())); + let i = self.class(css::declarations(props)); format!(" class=\"{}cls-{i}\"", self.id_prefix) } } @@ -604,20 +570,6 @@ impl Writer<'_> { line(1, ""); } - fn node_props(n: &Node) -> Props { - let mut p = Props::new(); - if n.opacity < 1.0 { - p.push(("opacity", fmt_num(n.opacity as f64, 3))); - } - if n.blend != BlendMode::Normal { - p.push(("mix-blend-mode", blend_css(n.blend).into())); - } - if n.isolate { - p.push(("isolation", "isolate".into())); - } - p - } - fn path_d(&self, p: &PathData, m: Affine) -> String { let mut s = String::new(); let pt = |p: Point| { @@ -911,12 +863,12 @@ impl Writer<'_> { p.push(("paint-order", "stroke".into())); } } - p.extend(Self::node_props(n)); + p.extend(css::transparency(n)); let a = self.attrs(&p); self.line(&format!("")); return; } - let a = self.attrs(&Self::node_props(n)); + let a = self.attrs(&css::transparency(n)); self.line(&format!("")); self.depth += 1; for (it, plan) in items.into_iter().zip(plans) { @@ -1113,7 +1065,7 @@ impl Writer<'_> { /// Props of a group (or layer): a knockout group is isolated, a hidden layer not displayed. fn group_props(&self, n: &Node) -> Props { - let mut p = Self::node_props(n); + let mut p = css::transparency(n); if !n.isolate && n.knocks_out(self.knockout) { p.push(("isolation", "isolate".into())); } @@ -1296,7 +1248,7 @@ impl Writer<'_> { }; let id = self.id_attr(n); let m = self.matrix(self.xf * im.xf); - let a = self.attrs(&Self::node_props(n)); + let a = self.attrs(&css::transparency(n)); self.line(&format!( "", im.width, @@ -1311,7 +1263,7 @@ impl Writer<'_> { return; } let id = self.id_attr(n); - let a = self.attrs(&Self::node_props(n)); + let a = self.attrs(&css::transparency(n)); if let Some(def) = self.symbol_use(sym, n, *xf) { let m = self.xf * *xf; let tr = if m == Affine::IDENTITY { String::new() } else { format!(" transform=\"{}\"", self.matrix(m)) }; @@ -1551,25 +1503,9 @@ impl Writer<'_> { /// and the map from text space to user space, which character gradients resolve against /// (as on the canvas). fn char_props(&mut self, st: &CharStyle, space: (Rect, Affine)) -> Props { - let mut p = Props::new(); - let fam = - if st.font_family.contains(|c: char| c.is_whitespace() || c == ',') { format!("'{}'", st.font_family) } else { st.font_family.clone() }; - p.push(("font-family", fam)); - // Vertical scale is the font size; horizontal scale stretches it (`textLength`). - p.push(("font-size", self.num(st.size * st.v_scale / 100.0))); - let fs = st.font_style.to_ascii_lowercase(); - let (weight, italic) = font_descriptor(st); - if self.embeds_fonts() { - // Embedded faces are told apart by their weight. - if weight != 400 { - p.push(("font-weight", weight.to_string())); - } - } else if fs.contains("bold") || fs.contains("black") || fs.contains("heavy") { - p.push(("font-weight", "bold".into())); - } - if italic { - p.push(("font-style", "italic".into())); - } + let decimals = self.opts.decimals; + let len = |v: f64| fmt_num(v, decimals); + let mut p = css::font_props(st, &len, self.embeds_fonts()); // Character paints resolve in the text's space (`to_user` maps it into user space). let (bounds, to_user) = space; let saved = std::mem::replace(&mut self.xf, to_user); @@ -1582,32 +1518,7 @@ impl Writer<'_> { self.stroke_props(&layer, layer.width, Some(layer.paint_bounds(bounds)), &mut p); } self.xf = saved; - // Tracking and manual kerning both add space after every character. - let spacing = st.tracking + st.kerning.unwrap_or(0.0); - if spacing != 0.0 { - p.push(("letter-spacing", self.num(spacing / 1000.0 * st.size))); - } - if st.kerning.is_some() { - // Manual kerning replaces the font's pair kerning. - p.push(("font-kerning", "none".into())); - } - if !st.features.is_empty() { - let v: Vec = st - .features - .iter() - .map(|t| match t.strip_prefix('-') { - Some(off) => format!("\"{off}\" 0"), - None => format!("\"{t}\" 1"), - }) - .collect(); - p.push(("font-feature-settings", v.join(", "))); - } - match (st.underline, st.strikethrough) { - (true, true) => p.push(("text-decoration", "underline line-through".into())), - (true, false) => p.push(("text-decoration", "underline".into())), - (false, true) => p.push(("text-decoration", "line-through".into())), - _ => {} - } + p.extend(css::type_props(st, &len)); p } @@ -1620,7 +1531,7 @@ impl Writer<'_> { return chars(self, n); } let id = self.id_attr(n); - let a = self.attrs(&Self::node_props(n)); + let a = self.attrs(&css::transparency(n)); self.line(&format!("")); self.depth += 1; let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); @@ -1657,7 +1568,7 @@ impl Writer<'_> { } } let id = self.id_attr(n); - let a = self.attrs(&Self::node_props(n)); + let a = self.attrs(&css::transparency(n)); self.line(&format!("")); self.depth += 1; // The glyphs stay in text space; the text's transform joins the document's meanwhile. @@ -1701,7 +1612,7 @@ impl Writer<'_> { if let Some((a, _)) = anchor { props.push(("text-anchor", a.into())); } - props.extend(Self::node_props(n)); + props.extend(css::transparency(n)); let id = self.id_attr(n); let a = self.attrs(&props); let m = self.xf * t.xf; @@ -1857,7 +1768,7 @@ impl Writer<'_> { Justify::Right | Justify::JustifyRight => props.push(("text-anchor", "end".into())), _ => {} } - props.extend(Self::node_props(n)); + props.extend(css::transparency(n)); let a = self.attrs(&props); let offset = fmt_num(start * 100.0, 3); let mut s = format!(""); diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index a3d9bc76..e5d85966 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -9,6 +9,9 @@ //! ([`Profile`]), optionally with the fonts type uses embedded as `@font-face` subsets. //! * [`import`] / [`import_with_report`] parse SVG with `usvg` and convert its normalized tree into //! document nodes with all transforms baked into the geometry. +//! * [`css_rules`] writes the CSS web pages style objects with (the CSS Properties panel): shapes' +//! paints as backgrounds and borders, type as fonts, shadows as `box-shadow`, each property +//! written as SVG export writes it in its style sheets. //! //! ## Export approximations //! @@ -75,9 +78,12 @@ //! rotation and style names). #![forbid(unsafe_code)] +mod css; mod export; mod import; +pub use css::{CssOptions, CssRule, CssSheet, CssUnits, css_rules}; + use serde::{Deserialize, Serialize}; pub use vectorcraft_doc::Document; diff --git a/crates/svg/tests/css_rules.rs b/crates/svg/tests/css_rules.rs new file mode 100644 index 00000000..88bd2a7f --- /dev/null +++ b/crates/svg/tests/css_rules.rs @@ -0,0 +1,227 @@ +//! 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::{Affine, Point, Rect, shapes}; +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], 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 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("").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()); +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 0691d3e0..52927311 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -491,6 +491,18 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{multiple?: true} collect the selected objects as assets (assets.add: one per object; false: one of them all) and open Export for Screens on its Assets tab with them checked → {assets: [asset id…]}; document.exportSelection exports the selection in one go instead", ), + ( + "css.copy", + "Copy Selected Style", + "", + "{scope?: selection|all (default selection), …css.selection options} copy the CSS of the selection (css.selection; all: css.generate) to the clipboard (CSS Properties panel) → {css, rules: n}", + ), + ( + "css.exportFile", + "Export CSS…", + "", + "{path?, scope?: selection|all (default selection), …css.selection options} css.export written to path, else a picked file (the web downloads it), with the pictures of rasterized art next to it (CSS Properties panel: Export Selected CSS…, Export All…) → {path, rules, images: [path…]}", + ), ]; /// Handle a UI command. `None` = not a UI command (the engine handles it). @@ -884,6 +896,8 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::panels::css_properties::copy(app, p), + "css.exportFile" => crate::panels::css_properties::export_file(app, p), _ => return None, }; Some(r) @@ -1120,6 +1134,7 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool { "ui.epsOptionsDialog" => app.session.active().is_some(), "file.saveForWeb" | "file.saveForWeb.browser" => app.session.active().is_some(), "file.exportSelection" => app.session.active().is_some_and(|d| !d.selection.is_empty()), + "css.copy" | "css.exportFile" => app.session.active().is_some(), _ => true, } } @@ -1717,6 +1732,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { panel("Color", "color"), panel("Color Guide", "colorGuide"), panel("Color Themes", "colorThemes"), + panel("CSS Properties", crate::panels::css_properties::ID), panel("Document Info", "docInfo"), panel("Flattener Preview", crate::panels::flattener_preview::ID), panel("Gradient", "gradient"), diff --git a/crates/ui-egui/src/panels/css_properties.rs b/crates/ui-egui/src/panels/css_properties.rs new file mode 100644 index 00000000..274be815 --- /dev/null +++ b/crates/ui-egui/src/panels/css_properties.rs @@ -0,0 +1,237 @@ +//! CSS Properties panel (Window → CSS Properties): the CSS web pages style the selected objects +//! with (`css.selection`), or after Generate CSS, while nothing is selected, the whole document's +//! (`css.generate`), in a code view. Copy Selected Style copies it (`css.copy`); Export Selected +//! CSS… and Export All… write a `.css` file with the pictures of rasterized art next to it +//! (`css.exportFile`). The panel menu holds the export options (units, absolute position, width +//! and height, unnamed objects, rasterize), which all of these use. + +use std::sync::Arc; + +use egui::text::{LayoutJob, TextFormat}; +use egui::{FontId, Ui}; +use serde_json::{Value, json}; +use vectorcraft_svg::{CssOptions, CssUnits}; + +use super::{pstate, set_pstate}; +use crate::theme::Tokens; +use crate::widgets::{self, menu_item}; +use crate::{VectorcraftApp, io}; + +pub const ID: &str = "cssProperties"; +/// Panel state: the export options. +pub(super) const OPTIONS: &str = "css-options"; +/// Panel state: Generate CSS is on (the document's CSS shows while nothing is selected). +pub(super) const ALL: &str = "css-all"; +/// The most rules the code view shows (Export All writes every one). +const MAX_SHOWN: usize = 300; + +/// The CSS shown, and what it was made for: (document uid, revision, whole document, options, +/// dark theme). +#[derive(Clone, Default)] +struct Cache { + key: Option<(u64, u64, bool, CssOptions, bool)>, + shown: Arc, +} + +#[derive(Default)] +struct Shown { + /// The rules shown, highlighted. + job: Option>, + rules: usize, + /// Rules beyond [`MAX_SHOWN`]. + more: usize, + skipped: u64, + warnings: Vec, +} + +/// The command params of `opts` for `scope` (`selection` or `all`). +fn params(opts: &CssOptions, scope: &str) -> Value { + let mut p = serde_json::to_value(opts).unwrap_or_else(|_| json!({})); + p["scope"] = json!(scope); + p +} + +/// The CSS rules as a code view: selectors and braces strong, property names dim. +fn highlight(rules: &[&str], t: &Tokens, font: FontId) -> LayoutJob { + let fmt = |color| TextFormat { font_id: font.clone(), color, ..Default::default() }; + let mut job = LayoutJob::default(); + for (i, rule) in rules.iter().enumerate() { + if i > 0 { + job.append("\n\n", 0.0, fmt(t.text)); + } + for (j, line) in rule.lines().enumerate() { + if j > 0 { + job.append("\n", 0.0, fmt(t.text)); + } + match line.split_once(": ") { + Some((k, v)) if line.starts_with(" ") => { + job.append(k, 0.0, fmt(t.text_dim)); + job.append(": ", 0.0, fmt(t.text_dim)); + job.append(v, 0.0, fmt(t.text)); + } + _ => job.append(line, 0.0, fmt(t.text_strong)), + } + } + } + job +} + +/// The CSS for the panel, made again only when the document, the scope or the options change. +fn shown(app: &mut VectorcraftApp, ui: &Ui, opts: &CssOptions, all: bool) -> Option> { + let st = app.session.active()?; + let t = Tokens::get(ui.ctx()); + let key = Some((st.uid, st.revision, all, opts.clone(), t.dark)); + let mut c: Cache = pstate(ui.ctx(), "css-cache"); + if c.key != key { + let (id, scope) = if all { ("css.generate", "all") } else { ("css.selection", "selection") }; + let out = app.session.execute(id, ¶ms(opts, scope)).unwrap_or_default(); + let rules: Vec<&str> = out["rules"].as_array().into_iter().flatten().filter_map(|r| r["css"].as_str()).collect(); + let warnings = out["warnings"].as_array().into_iter().flatten().filter_map(Value::as_str).map(str::to_string).collect(); + let job = (!rules.is_empty()) + .then(|| Arc::new(highlight(rules.get(..MAX_SHOWN).unwrap_or(&rules), &t, egui::TextStyle::Monospace.resolve(ui.style())))); + let skipped = out["skipped"].as_u64().unwrap_or(0); + c = Cache { key, shown: Arc::new(Shown { job, rules: rules.len(), more: rules.len().saturating_sub(MAX_SHOWN), skipped, warnings }) }; + set_pstate(ui.ctx(), "css-cache", c.clone()); + } + Some(c.shown) +} + +/// `w` as a sentence: capitalised, with a full stop. +fn sentence(w: &str) -> String { + let mut c = w.chars(); + c.next().map(|f| f.to_uppercase().chain(c).chain(['.']).collect()).unwrap_or_default() +} + +pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { + let ctx = ui.ctx().clone(); + let Some(selected) = app.session.active().map(|st| !st.selection.is_empty()) else { + widgets::dim_label(ui, "No document"); + return; + }; + let opts: CssOptions = pstate(&ctx, OPTIONS); + let all = !selected && pstate::(&ctx, ALL); + let Some(shown) = shown(app, ui, &opts, all) else { return }; + widgets::list_box(ui, |ui| { + egui::ScrollArea::both().id_salt("css-properties-scroll").max_height(300.0).auto_shrink([false, true]).show(ui, |ui| { + ui.set_min_height(120.0); + match &shown.job { + Some(job) => { + ui.add(egui::Label::new(egui::WidgetText::LayoutJob(job.clone())).selectable(true).extend()); + } + None if selected => super::empty_state(ui, "globe", "No CSS", "The selected objects have no CSS of their own (see below)."), + None => super::empty_state(ui, "globe", "No selection", "Select objects to see their CSS, or Generate CSS for the whole document."), + } + }); + }); + let notes = (shown.more > 0) + .then(|| format!("{} more rules: Export writes every one.", shown.more)) + .into_iter() + .chain( + (shown.skipped > 0) + .then(|| format!("{} unnamed objects left out: name them, or turn on Unnamed Objects in the panel menu.", shown.skipped)), + ) + .chain(shown.warnings.iter().map(|w| sentence(w))); + let dim = Tokens::get(&ctx).text_dim; + for n in notes { + ui.add(egui::Label::new(egui::RichText::new(n).size(11.0).color(dim)).wrap()); + } + let has_css = shown.rules > 0; + let scope = if all { "all" } else { "selection" }; + let (mut generate, mut copy, mut export) = (false, false, false); + widgets::bottom_bar(ui, |ui| { + generate = + widgets::icon_button_enabled(ui, "sparkles", "Generate CSS: the whole document's CSS while nothing is selected", all, !selected, 24.0) + .clicked(); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let tip = if all { "Export All…" } else { "Export Selected CSS…" }; + export = widgets::icon_button_enabled(ui, "save", tip, false, has_css, 24.0).clicked(); + let tip = if all { "Copy All Styles" } else { "Copy Selected Style" }; + copy = widgets::icon_button_enabled(ui, "copy", tip, false, has_css, 24.0).clicked(); + }); + }); + if generate { + set_pstate(&ctx, ALL, !all); + } + if copy { + crate::menus::invoke(app, "css.copy", params(&opts, scope)); + } + if export { + crate::menus::invoke(app, "css.exportFile", params(&opts, scope)); + } +} + +/// `css.copy {scope?, …css.selection options}`: copy the CSS of the selection (or with scope +/// `all` the document's) → `{css, rules}`. +pub(crate) fn copy(app: &mut VectorcraftApp, p: &Value) -> Result { + let id = if p.get("scope").and_then(Value::as_str) == Some("all") { "css.generate" } else { "css.selection" }; + let r = app.session.execute(id, p).map_err(|e| e.to_string())?; + let css = r["css"].as_str().filter(|c| !c.is_empty()).ok_or("no CSS to copy: select objects with CSS of their own")?.to_string(); + let rules = r["rules"].as_array().map_or(0, Vec::len); + app.clipboard_out = Some(css.clone()); + app.status(format!("Copied the CSS of {rules} object(s)")); + Ok(json!({ "css": css, "rules": rules })) +} + +/// `css.exportFile {path?, scope?, …css.selection options}`: `css.export` written to `path`, else +/// a picked file (the web downloads it), its pictures next to it → `{path, rules, images}`. +pub(crate) fn export_file(app: &mut VectorcraftApp, p: &Value) -> Result { + let mut q = p.clone(); + let path = q.as_object_mut().and_then(|o| o.remove("path")).and_then(|v| v.as_str().map(str::to_string)); + let r = app.session.execute("css.export", &q).map_err(|e| e.to_string())?; + let path = io::target_path(app, path, "css")?; + io::write_to(&mut app.services, &path, r["data"].as_str().unwrap_or_default().as_bytes())?; + let folder = std::path::Path::new(&path).parent().map(std::path::Path::to_path_buf); + let mut images = Vec::new(); + for im in r["images"].as_array().into_iter().flatten() { + let (Some(name), Some(data)) = (im["name"].as_str(), im["dataBase64"].as_str()) else { continue }; + let bytes = vectorcraft_format::base64_decode(data).ok_or("the export returned unreadable data")?; + let at = folder.as_ref().map_or_else(|| name.to_string(), |f| f.join(name).to_string_lossy().into_owned()); + io::write_to(&mut app.services, &at, &bytes)?; + images.push(at); + } + app.status(format!("Exported {path}")); + Ok(json!({ "path": path, "rules": r["rules"], "images": images })) +} + +/// The panel (≡) menu: copy and export, Generate CSS and the export options. +pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { + let ctx = ui.ctx().clone(); + let Some(selected) = app.session.active().map(|st| !st.selection.is_empty()) else { return }; + let mut opts: CssOptions = pstate(&ctx, OPTIONS); + let all: bool = pstate(&ctx, ALL); + if menu_item(ui, "Copy Selected Style", selected, false) { + crate::menus::invoke(app, "css.copy", params(&opts, "selection")); + } + if menu_item(ui, "Export Selected CSS…", selected, false) { + crate::menus::invoke(app, "css.exportFile", params(&opts, "selection")); + } + if menu_item(ui, "Export All…", true, false) { + crate::menus::invoke(app, "css.exportFile", params(&opts, "all")); + } + ui.separator(); + if menu_item(ui, "Generate CSS", true, all) { + set_pstate(&ctx, ALL, !all); + } + ui.separator(); + let before = opts.clone(); + ui.menu_button(format!(" Units: {}", opts.units.name()), |ui| { + for u in CssUnits::ALL { + if menu_item(ui, u.name(), true, opts.units == u) { + opts.units = u; + } + } + }); + for (label, on) in [ + ("Absolute Position", &mut opts.position), + ("Width and Height", &mut opts.dimensions), + ("Unnamed Objects", &mut opts.unnamed), + ("Rasterize Unsupported Art", &mut opts.rasterize), + ] { + if menu_item(ui, label, true, *on) { + *on = !*on; + } + } + if opts != before { + set_pstate(&ctx, OPTIONS, opts); + } +} diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 3f6aa3d7..521db16b 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -14,6 +14,7 @@ pub mod character; pub mod color; pub mod color_guide; pub mod color_themes; +pub mod css_properties; pub mod doc_info; pub mod flattener_preview; pub mod glyphs; @@ -98,6 +99,7 @@ pub fn show_icon_panel(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) { "colorThemes" => color_themes::show(app, ui), links::ID => links::show(app, ui), asset_export::ID => asset_export::show(app, ui), + css_properties::ID => css_properties::show(app, ui), _ => { dim_label(ui, "This panel is on the roadmap (see the parity plan)."); } @@ -142,6 +144,7 @@ pub fn panel_menu_items(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) { "layers" => layers::menu(app, ui), links::ID => links::menu(app, ui), asset_export::ID => asset_export::menu(app, ui), + css_properties::ID => css_properties::menu(app, ui), _ => { ui.add_enabled(false, egui::Button::new("No options").frame(false)); } @@ -450,6 +453,8 @@ mod tests_appearance; #[cfg(test)] mod tests_asset_export; #[cfg(test)] +mod tests_css_properties; +#[cfg(test)] mod tests_effectedit; #[cfg(test)] mod tests_freeform; diff --git a/crates/ui-egui/src/panels/tests_css_properties.rs b/crates/ui-egui/src/panels/tests_css_properties.rs new file mode 100644 index 00000000..a73e1164 --- /dev/null +++ b/crates/ui-egui/src/panels/tests_css_properties.rs @@ -0,0 +1,103 @@ +//! The CSS Properties panel drawn headless: the selection's CSS in the code view, Generate CSS +//! for the whole document, the export options in the panel menu, and Copy Selected Style and +//! Export through their UI commands. + +use std::cell::RefCell; +use std::rc::Rc; + +use serde_json::json; +use vectorcraft_engine::Session; +use vectorcraft_svg::{CssOptions, CssUnits}; + +use super::css_properties::{ALL, ID, OPTIONS, menu, show}; +use super::set_pstate; +use crate::tests_labels::painted_text; +use crate::{Services, VectorcraftApp}; + +type Log = Rc)>>>; + +/// An app with a selected red 30 × 20 rectangle; files written go to the log. +fn app() -> (VectorcraftApp, Log) { + let log = Log::default(); + let l = log.clone(); + let services = Services { + write: Some(Box::new(move |path: &str, bytes: &[u8]| { + l.borrow_mut().push((path.to_string(), bytes.to_vec())); + Ok(()) + })), + ..Services::default() + }; + let mut app = VectorcraftApp::new(Session::new(), services); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap(); + app.run("paint.setFill", json!({"color": "#ff0000"})).unwrap(); + (app, log) +} + +#[test] +fn the_code_view_shows_the_selections_css_with_the_options() { + let (mut app, _) = app(); + assert!(crate::state::ICON_PANELS.iter().any(|(id, ..)| *id == ID)); + let text = painted_text(&mut app, show); + assert!(text.contains(".rectangle {") && text.contains("background-color: #ff0000;") && text.contains("width: 30px;"), "{text}"); + let text = painted_text(&mut app, |app, ui| { + set_pstate(ui.ctx(), OPTIONS, CssOptions { units: CssUnits::Pt, position: true, ..CssOptions::default() }); + show(app, ui); + }); + assert!(text.contains("width: 30pt;") && text.contains("left: 10pt;"), "{text}"); +} + +#[test] +fn generate_css_shows_the_document_while_nothing_is_selected() { + let (mut app, _) = app(); + app.run("shape.ellipse", json!({"x": 100, "y": 10, "width": 20, "height": 20})).unwrap(); + app.run("select.none", json!({})).unwrap(); + let text = painted_text(&mut app, show); + assert!(text.contains("No selection") && !text.contains(".rectangle"), "{text}"); + let text = painted_text(&mut app, |app, ui| { + set_pstate(ui.ctx(), ALL, true); + show(app, ui); + }); + assert!(text.contains(".rectangle {") && text.contains(".ellipse {") && text.contains("border-radius: 50%;"), "{text}"); +} + +#[test] +fn the_panel_menu_lists_the_actions_and_options() { + let (mut app, _) = app(); + let text = painted_text(&mut app, menu); + for item in [ + "Copy Selected Style", + "Export Selected CSS…", + "Export All…", + "Generate CSS", + "Units: px", + "Absolute Position", + "Width and Height", + "Unnamed Objects", + "Rasterize Unsupported Art", + ] { + assert!(text.contains(item), "{item} in {text}"); + } +} + +#[test] +fn copy_and_export_go_through_the_ui_commands() { + let (mut app, log) = app(); + let r = app.run("css.copy", json!({})).unwrap(); + assert_eq!(r["rules"], json!(1)); + assert!(app.clipboard_out.as_deref().is_some_and(|c| c.starts_with(".rectangle {") && c.contains("#ff0000"))); + app.run("select.none", json!({})).unwrap(); + assert!(app.run("css.copy", json!({})).is_err(), "nothing selected: nothing to copy"); + // Export All with a star rasterized: the sheet and the star's picture beside it. + app.run("shape.star", json!({"cx": 100, "cy": 50, "radius1": 30, "radius2": 15})).unwrap(); + let r = app.run("css.exportFile", json!({"path": "/tmp/site/site.css", "scope": "all", "rasterize": true})).unwrap(); + assert_eq!(r["rules"], json!(2)); + let png = std::path::Path::new("/tmp/site").join("path.png").to_string_lossy().into_owned(); + assert_eq!(r["images"], json!([png])); + let log = log.borrow(); + let names: Vec<&str> = log.iter().map(|(p, _)| p.as_str()).collect(); + assert_eq!(names, ["/tmp/site/site.css", png.as_str()]); + let css = String::from_utf8(log[0].1.clone()).unwrap(); + assert!(css.contains(".rectangle {") && css.contains("background-image: url(path.png);"), "{css}"); + assert!(log[1].1.starts_with(b"\x89PNG")); +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 4ddb0462..7a7d1303 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -108,6 +108,7 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[ ("colorThemes", "Color Themes", "sun"), (crate::panels::links::ID, "Links", "link"), (crate::panels::asset_export::ID, "Asset Export", "share-2"), + (crate::panels::css_properties::ID, "CSS Properties", "globe"), ]; /// Groups of icon panels separated by dividers in the collapsed column. diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 3e0494e4..ba3f810d 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -34,6 +34,7 @@ | `engine.execute` `file.exportForScreens` | `{}` / `{…document.exportForScreens params}` | without params opens `exportForScreens` on the settings the document last exported with (`document.exportSettings`). Fields: `tab` (`artboards` \| `assets`), `select` (`all` \| `range` \| `full`), `range` ("1-3, 5"), `boards` (a bool per artboard; checking them sets `select` and `range`), `includeBleed`, `folder` (desktop; default the Desktop, else home), `openLocation`, `subfolders`, `prefix`, `preset` (`""` \| `mobile` \| `density`), `formats` (rows `{format, scale: "2x" \| "100w" \| "100h" \| "72ppi", suffix, quality?, folder?}`), `settings` (`{png \| png8 \| jpg \| webp \| gif \| svg \| pdf: {…options}}`, the gear's Format Settings; `__settings` names the format shown). `ui.dialog.confirm` exports (a bad range or no folder keeps it open); on the web OK reads "Download" and the files download (several as one `.zip`). With params it runs `document.exportForScreens`: no `folder` returns the files (or with `zip: true` one store-only zip) as `dataBase64`; with `openLocation` the first file is shown in the file manager | | Export for Screens' Assets tab, Asset Export panel | `ui.dialog.set` `tab: "assets"`, `assets: [asset id…]`; `window.panel {panel: "assetExport"}` | on the Assets tab of `exportForScreens` the `assets` field lists the checked asset ids (all of them when it opens); `ui.dialog.confirm` runs `document.exportForScreens {assets}` (nothing checked keeps it open) and OK reads "Export Asset". The Asset Export panel acts through `assets.*` and shares the format rows with the dialog (`assets.settings.set`) | | `engine.execute` `file.exportSelection` | `{multiple?}` | File › Export Selection…: collects the selected objects as assets (`assets.add`; one per object unless `multiple` is false) and opens `exportForScreens` on its Assets tab with just them checked → `{assets}`. `document.exportSelection` still exports the selection in one go, without the dialog | +| CSS Properties panel | `window.panel {panel: "cssProperties"}`; `engine.execute` `css.copy` / `css.exportFile` | the panel shows `css.selection` of the selection (after Generate CSS, while nothing is selected, `css.generate`); its menu holds the options the commands take (`units`, `position`, `dimensions`, `unnamed`, `rasterize`). `css.copy {scope?, …options}` copies the CSS to the clipboard → `{css, rules}`; `css.exportFile {path?, scope?, …options}` writes `css.export` to `path` (else a picked file; the web downloads it) with the pictures of rasterized art next to it → `{path, rules, images}` | | `ui.dialog.*` on `importPdf` | `{field: "page" \| "allPages" \| "range" \| "cropTo" \| "password", value}` | opening (`app.open`, `file.open`) a PDF or `.ai` with several pages or a password, or placing one (`file.place` without `page`), opens the Import PDF dialog (`mode`: `open` or `place`; `pageCount`). While `__locked` is true, set `password` and `ui.dialog.confirm` to unlock it (a one-page file then opens or is placed). Then set `allPages: false` and `range` ("2-3, 5") to open some pages, or `page` to place one, and `cropTo` (`bounding`, `art`, `crop`, `trim`, `bleed`, `media`); `ui.dialog.confirm` opens or places them (placing keeps the other `file.place` params, such as `link`, `template` and `at`, in `__place`). `document.open {pages, cropTo, password}` and `file.place {page, crop, password}` do the same without the dialog | diff --git a/docs/mcp.md b/docs/mcp.md index 3b797f00..e5c0d032 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1434,3 +1434,23 @@ those shared settings (not an undo step). `document.exportForScreens {assets: [i {"name":"run_command","arguments":{"command":"assets.settings.set","params":{"formats":[{"format":"png","scale":"1x"},{"format":"png","scale":"2x"},{"format":"svg"}]}}} {"name":"run_command","arguments":{"command":"assets.export","params":{"zip":true}}} ``` + +## CSS Properties + +`css.selection {ids?, units?, position?, dimensions?, unnamed?, rasterize?}` returns the CSS web pages style the +selected objects (or `ids`) with, one rule per object, as Window › CSS Properties shows it: a rectangle's or ellipse's +fill as `background-color` or a `linear-gradient()`/`radial-gradient()`, its stroke as `border`, its corners as +`border-radius`; type's font, `color`, spacing and alignment; opacity, blend mode, shadows and glows (`box-shadow`, +`text-shadow`) and blur (`filter`). Layers and plain groups stand for what they hold. Properties SVG export writes too +(font, spacing, opacity, blend mode, colours, gradient stops) read exactly as in its style sheets. Each rule in `rules` +says when CSS can't describe the art exactly (`unsupported`: other shapes, images, patterns, live effects…); it is then +written as its box, or with `rasterize: true` as `background-image: url(.png)`. `units` is `px` (1 px per +point, as in SVG export), `pt`, `mm`, `cm` or `in`; `unnamed: false` leaves out unnamed objects (counted in +`skipped`). `css.generate` does the same for the whole document, and `css.export {path?, scope?: selection|all}` +writes the `.css` file with the PNGs of rasterized art next to it (no path: `data` and `images` as base64). In the app, +`css.copy` copies the CSS and `css.exportFile` writes it through a save panel (the web downloads it). + +```json +{"name":"run_command","arguments":{"command":"css.selection","params":{"units":"px","position":true}}} +{"name":"run_command","arguments":{"command":"css.export","params":{"path":"/tmp/site/styles.css","scope":"all","rasterize":true}}} +```