Merge pull request #113 from storytold/pr/m4-css-properties

M4.96: CSS Properties
This commit is contained in:
BFlat 2026-10-05 16:13:53 +07:00 committed by GitHub
commit ced1527903
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 1524 additions and 110 deletions

View File

@ -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 }))
}
}
}

View File

@ -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);

View File

@ -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"))
}

View File

@ -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
})
}

View File

@ -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;

View File

@ -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();
}

636
crates/svg/src/css.rs Normal file
View File

@ -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<Item = &'a (&'static str, String)>) -> String {
props.into_iter().map(|(k, v)| format!("{k}:{v}")).collect::<Vec<_>>().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<String> = 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<String>,
}
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<CssRule>,
/// Unnamed objects left out (without [`CssOptions::unnamed`]).
pub skipped: usize,
/// What the rules leave out or approximate (once each).
pub warnings: Vec<String>,
}
impl CssSheet {
/// The style sheet: the rules, a blank line between them.
pub fn text(&self) -> String {
self.rules.iter().map(CssRule::text).collect::<Vec<_>>().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<NodeId>,
/// Class names given, lower case.
names: HashSet<String>,
}
/// 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<Rect>) -> 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::<Vec<_>>().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<String> {
let geom = g.resolve(b);
let stops = |pos: &dyn Fn(f32) -> f64| {
let stops: Vec<String> =
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<f64>),
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<Corners> {
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
}

View File

@ -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<char>) -> String {
let mut runs: Vec<(u32, u32)> = Vec::new();
@ -187,11 +180,6 @@ fn unicode_range(chars: &BTreeSet<char>) -> String {
runs.into_iter().map(one).collect::<Vec<_>>().join(",")
}
/// A CSS string.
fn css_string(s: &str) -> String {
format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
}
/// A CSS declaration block as the literal value of an `<!ENTITY>`: 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::<Vec<_>>().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::<Vec<_>>()))),
Styling::StyleEntities => format!(" style=\"&st{};\"", self.class(css(&props.iter().collect::<Vec<_>>()))),
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::<Vec<_>>()));
let i = self.class(css::declarations(props));
format!(" class=\"{}cls-{i}\"", self.id_prefix)
}
}
@ -604,20 +570,6 @@ impl Writer<'_> {
line(1, "</metadata>");
}
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!("<path{id} d=\"{d}\"{a}/>"));
return;
}
let a = self.attrs(&Self::node_props(n));
let a = self.attrs(&css::transparency(n));
self.line(&format!("<g{id}{a}>"));
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!(
"<image{id} width=\"{}\" height=\"{}\" transform=\"{m}\" preserveAspectRatio=\"none\"{a} xlink:href=\"{}\"/>",
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<String> = 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!("<g{id}{a}>"));
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!("<g{id}{a}>"));
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!("<text{id} xml:space=\"preserve\"{a}><textPath xlink:href=\"#{pid}\" startOffset=\"{offset}%\">");

View File

@ -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;

View File

@ -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("<style>").unwrap()..svg.find("</style>").unwrap()];
for k in ["font-family", "font-weight", "font-kerning", "font-feature-settings", "text-decoration", "opacity", "mix-blend-mode"] {
let v = prop(&r, k).unwrap_or_else(|| panic!("{k} is written: {r:?}"));
// The SVG sheet escapes quotes as XML.
let decl = format!("{k}:{v}").replace('"', "&quot;").replace('\'', "&apos;");
assert!(style.contains(&decl), "{decl} as in SVG export: {style}");
}
// Lengths are the same numbers, with units on the web.
assert!(style.contains("font-size:18") && prop(&r, "font-size") == Some("18px"), "{style}");
}
#[test]
fn gradients_give_css_gradients() {
let mut d = doc();
let mut n = rect(&mut d, (0.0, 0.0, 200.0, 100.0), 0.0);
n.appearance = Appearance::basic(Paint::Gradient(Box::new(GradientPaint::new(Gradient::default()))), Paint::None, 0.0);
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());
}

View File

@ -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<R
crate::dialogs::open_export_for_screens_assets(app, Some(&ids));
json!({ "assets": ids })
}),
"css.copy" => 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<Item>)> {
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"),

View File

@ -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<Shown>,
}
#[derive(Default)]
struct Shown {
/// The rules shown, highlighted.
job: Option<Arc<LayoutJob>>,
rules: usize,
/// Rules beyond [`MAX_SHOWN`].
more: usize,
skipped: u64,
warnings: Vec<String>,
}
/// 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<Arc<Shown>> {
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, &params(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::<bool>(&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<Value, String> {
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<Value, String> {
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);
}
}

View File

@ -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;

View File

@ -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<RefCell<Vec<(String, Vec<u8>)>>>;
/// 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"));
}

View File

@ -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.

View File

@ -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 |

View File

@ -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(<class>.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}}}
```