Merge pull request #113 from storytold/pr/m4-css-properties
M4.96: CSS Properties
This commit is contained in:
commit
ced1527903
|
|
@ -0,0 +1,129 @@
|
|||
//! Window → CSS Properties: the CSS web pages style objects with ([`vectorcraft_svg::css_rules`],
|
||||
//! which writes each property as SVG export writes it), for the selection or the whole document,
|
||||
//! and the `.css` file with the pictures of the art it rasterizes.
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::NodeId;
|
||||
use vectorcraft_svg::{CssOptions, CssSheet, css_rules};
|
||||
|
||||
use super::fileio::{default_name, encode, isolated, write_file};
|
||||
use super::*;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
query "css.selection",
|
||||
"CSS Properties",
|
||||
[],
|
||||
None,
|
||||
"{ids?: [id…] (default: the selection), units?: px|pt|mm|cm|in (default px; 1 px = 1 pt, as in SVG export), position?: false (position: absolute, left and top from the artboard's top-left), dimensions?: true (width, height), unnamed?: true (unnamed objects too, classes named after their kind; false: named objects only), rasterize?: false (art CSS can't describe as background-image: url(<class>.png), its picture, which css.export writes)} the CSS web pages style the objects with, one rule per object back to front (layers and plain groups stand for what they hold): a rectangle's or ellipse's fill as background-color or linear-/radial-gradient(), stroke as border, corners as border-radius; type's font, color, spacing and alignment; opacity, mix-blend-mode, shadows and glows (box-shadow, text-shadow), blur (filter) → {css, rules: [{id, selector, css, unsupported?: why CSS can't describe the art exactly (without rasterize it is written as its box), image?: the PNG it refers to}], skipped: n unnamed objects left out, warnings}",
|
||||
has_doc,
|
||||
selection
|
||||
),
|
||||
cmd!(query "css.generate", "Generate CSS", [], None, "{…css.selection options} css.selection of every object in the document", has_doc, generate),
|
||||
cmd!(
|
||||
"css.export",
|
||||
"Export CSS",
|
||||
[],
|
||||
None,
|
||||
"{path?, scope?: selection|all (default selection; ids? picks the objects), …css.selection options} write the CSS (css.selection, or css.generate for all) to path and, with rasterize, each picture its rules refer to next to it (PNG of the object's visual bounds, 1 px per point) → {path, bytes, rules: n, images: [path…], warnings}; no path → {data: the CSS, name: <document>.css, rules, images: [{name, dataBase64}], warnings}",
|
||||
has_doc,
|
||||
export
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// The CSS options of `p` (its other params are the command's own).
|
||||
fn options(p: &Value, cmd: &str) -> Result<CssOptions> {
|
||||
if !p.is_object() {
|
||||
return Ok(CssOptions::default());
|
||||
}
|
||||
CssOptions::deserialize(p).map_err(|e| bad(cmd, format!("CSS options: {e}")))
|
||||
}
|
||||
|
||||
/// The CSS of objects `ids` (back to front) with the options of `p`.
|
||||
fn sheet(s: &Session, p: &Value, ids: Vec<NodeId>, cmd: &str) -> Result<CssSheet> {
|
||||
let opts = options(p, cmd)?;
|
||||
let d = &s.doc()?.doc;
|
||||
Ok(css_rules(d, &d.paint_order(ids), &opts))
|
||||
}
|
||||
|
||||
/// Every object of the active document: its layers.
|
||||
fn everything(s: &Session) -> Result<Vec<NodeId>> {
|
||||
Ok(s.doc()?.doc.layers.iter().map(|l| l.id).collect())
|
||||
}
|
||||
|
||||
fn result(sheet: &CssSheet) -> Value {
|
||||
let rules: Vec<Value> = sheet
|
||||
.rules
|
||||
.iter()
|
||||
.map(|r| {
|
||||
let mut v = json!({ "id": r.id.0, "selector": r.selector, "css": r.text() });
|
||||
if let Some(why) = r.unsupported {
|
||||
v["unsupported"] = json!(why);
|
||||
}
|
||||
if let Some(image) = &r.image {
|
||||
v["image"] = json!(image);
|
||||
}
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
json!({ "css": sheet.text(), "rules": rules, "skipped": sheet.skipped, "warnings": sheet.warnings })
|
||||
}
|
||||
|
||||
fn selection(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = targets(s, p)?;
|
||||
Ok(result(&sheet(s, p, ids, "css.selection")?))
|
||||
}
|
||||
|
||||
fn generate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = everything(s)?;
|
||||
Ok(result(&sheet(s, p, ids, "css.generate")?))
|
||||
}
|
||||
|
||||
fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "css.export";
|
||||
let ids = match str_param(p, "scope").unwrap_or("selection") {
|
||||
"selection" => targets(s, p)?,
|
||||
"all" => everything(s)?,
|
||||
other => return Err(bad(C, format!("scope must be selection or all (got {other})"))),
|
||||
};
|
||||
let sheet = sheet(s, p, ids, C)?;
|
||||
if sheet.rules.is_empty() {
|
||||
let why = if sheet.skipped > 0 {
|
||||
"the objects are unnamed: name them, or give unnamed: true"
|
||||
} else {
|
||||
"no objects to export: select some, or give scope: all"
|
||||
};
|
||||
return Err(bad(C, why));
|
||||
}
|
||||
let css = format!("{}\n", sheet.text());
|
||||
let doc = &s.doc()?.doc;
|
||||
// The pictures of rasterized art.
|
||||
let mut images = Vec::new();
|
||||
for r in &sheet.rules {
|
||||
let Some(name) = &r.image else { continue };
|
||||
let (d, _) = isolated(doc, &[r.id], name).ok_or_else(|| bad(C, format!("{} paints nothing to rasterize", r.selector)))?;
|
||||
images.push((name, encode(&d, "png", &json!({}))?));
|
||||
}
|
||||
let n = sheet.rules.len();
|
||||
match str_param(p, "path") {
|
||||
Some(path) => {
|
||||
write_file(path, css.as_bytes())?;
|
||||
let folder = std::path::Path::new(path).parent();
|
||||
let mut written = Vec::new();
|
||||
for (name, bytes) in &images {
|
||||
let at = folder.map_or_else(|| name.to_string(), |f| f.join(name).to_string_lossy().into_owned());
|
||||
write_file(&at, bytes)?;
|
||||
written.push(at);
|
||||
}
|
||||
Ok(json!({ "path": path, "bytes": css.len(), "rules": n, "images": written, "warnings": sheet.warnings }))
|
||||
}
|
||||
None => {
|
||||
let images: Vec<Value> =
|
||||
images.iter().map(|(name, bytes)| json!({ "name": name, "dataBase64": vectorcraft_format::base64_encode(bytes) })).collect();
|
||||
Ok(json!({ "data": css, "name": default_name(doc, "css"), "rules": n, "images": images, "warnings": sheet.warnings }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -131,7 +131,7 @@ fn selection(s: &mut Session, cmd: &str) -> Result<(Document, vectorcraft_geom::
|
|||
|
||||
/// Objects `ids` of `doc` alone on one layer (in that order, back to front), with one artboard
|
||||
/// named `name`: their visual bounds (`None` when they have none).
|
||||
pub(super) fn isolated(doc: &Document, ids: &[NodeId], name: &str) -> Option<(Document, vectorcraft_geom::Rect)> {
|
||||
pub(crate) fn isolated(doc: &Document, ids: &[NodeId], name: &str) -> Option<(Document, vectorcraft_geom::Rect)> {
|
||||
let nodes: Vec<Arc<Node>> = ids.iter().filter_map(|id| doc.node(*id).cloned().map(Arc::new)).collect();
|
||||
let bounds = nodes.iter().filter_map(|n| n.visual_bounds()).reduce(|a, b| a.union(b))?;
|
||||
let mut d = single_artboard(doc, bounds, name);
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ pub use dxf::{UNSUPPORTED, Unsupported, unsupported};
|
|||
pub use encode::{ARTBOARD_PARAMS, ArtboardPick, Encoded, encode, encode_all, encode_with_warnings};
|
||||
pub(crate) use encode::{anti_alias, background, with_single_artboard};
|
||||
pub use export::export_source;
|
||||
pub(crate) use export::isolated;
|
||||
use load::err;
|
||||
pub(crate) use load::import_svg;
|
||||
pub(crate) use load::native_file;
|
||||
|
|
@ -789,7 +790,7 @@ pub(crate) fn merge(mut a: Value, b: Value) -> Value {
|
|||
}
|
||||
|
||||
/// A file name for bytes handed back without a path: the document's title with `ext`.
|
||||
fn default_name(doc: &vectorcraft_doc::Document, ext: &str) -> String {
|
||||
pub(crate) fn default_name(doc: &vectorcraft_doc::Document, ext: &str) -> String {
|
||||
let stem = std::path::Path::new(&doc.title).file_stem().map(|s| s.to_string_lossy().into_owned()).filter(|s| !s.is_empty());
|
||||
format!("{}.{ext}", stem.as_deref().unwrap_or("Untitled"))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ mod colorcmds;
|
|||
pub mod colormgmt;
|
||||
pub mod colortheme;
|
||||
mod create;
|
||||
mod css;
|
||||
pub(crate) mod distortcmds;
|
||||
mod docinfo;
|
||||
mod docmenu;
|
||||
|
|
@ -237,6 +238,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
|||
v.extend(fileio::dxfimport::specs());
|
||||
v.extend(webexport::specs());
|
||||
v.extend(assets::specs());
|
||||
v.extend(css::specs());
|
||||
v
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1114,6 +1114,8 @@ mod tests_colorthemes;
|
|||
#[cfg(test)]
|
||||
mod tests_containers;
|
||||
#[cfg(test)]
|
||||
mod tests_css;
|
||||
#[cfg(test)]
|
||||
mod tests_dashalign;
|
||||
#[cfg(test)]
|
||||
mod tests_distort;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,117 @@
|
|||
//! CSS Properties: `css.selection` (rectangles' backgrounds, borders and radii, type's fonts,
|
||||
//! gradients), `css.generate` over the document and `css.export` writing the style sheet and the
|
||||
//! pictures of rasterized art.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 300, "height": 200})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}"))
|
||||
}
|
||||
|
||||
fn css(v: &Value) -> &str {
|
||||
v["css"].as_str().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rounded_rectangle_gives_background_border_and_radius() {
|
||||
let mut s = session();
|
||||
let id = run(&mut s, "shape.rectangle", json!({"x": 10, "y": 20, "width": 120, "height": 60, "radius": 6}))["id"].as_u64().unwrap();
|
||||
run(&mut s, "paint.setFill", json!({"color": "#3366cc"}));
|
||||
run(&mut s, "paint.setStroke", json!({"color": "#000000"}));
|
||||
run(&mut s, "layer.setProps", json!({"id": id, "name": "Button"}));
|
||||
let r = run(&mut s, "css.selection", json!({"position": true}));
|
||||
let text = css(&r);
|
||||
for decl in ["background-color: #3366cc;", "border: 1px solid #000000;", "border-radius: 6px;", "left: 10px;", "top: 20px;", "width: 120px;"] {
|
||||
assert!(text.contains(decl), "{decl} in {text}");
|
||||
}
|
||||
assert!(text.starts_with(".Button {\n"), "{text}");
|
||||
assert_eq!(r["rules"][0]["id"], json!(id));
|
||||
assert_eq!(r["rules"][0]["selector"], json!(".Button"));
|
||||
assert!(r["rules"][0].get("unsupported").is_none());
|
||||
// A query: no undo step, no change.
|
||||
let rev = s.doc().unwrap().revision;
|
||||
run(&mut s, "css.selection", json!({"units": "mm"}));
|
||||
assert_eq!(s.doc().unwrap().revision, rev);
|
||||
assert!(s.execute("css.selection", &json!({"units": "furlong"})).is_err(), "unknown units are refused");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_gives_font_properties_and_gradients_css_gradients() {
|
||||
let mut s = session();
|
||||
let t = run(&mut s, "text.create", json!({"x": 20, "y": 50, "text": "Title", "size": 24}))["id"].as_u64().unwrap();
|
||||
let text = css(&run(&mut s, "css.selection", json!({"ids": [t]}))).to_string();
|
||||
for decl in ["font-family: 'Source Sans 3';", "font-size: 24px;", "color: #"] {
|
||||
assert!(text.contains(decl), "{decl} in {text}");
|
||||
}
|
||||
run(&mut s, "shape.rectangle", json!({"x": 0, "y": 100, "width": 100, "height": 50}));
|
||||
run(&mut s, "paint.setFill", json!({"gradient": {"kind": "linear"}}));
|
||||
let text = css(&run(&mut s, "css.selection", json!({}))).to_string();
|
||||
assert!(text.contains("background-image: linear-gradient(90deg, "), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_covers_the_document_and_an_empty_selection_has_none() {
|
||||
let mut s = session();
|
||||
run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10}));
|
||||
run(&mut s, "shape.ellipse", json!({"x": 20, "y": 0, "width": 10, "height": 10}));
|
||||
run(&mut s, "select.none", json!({}));
|
||||
let none = run(&mut s, "css.selection", json!({}));
|
||||
assert_eq!(css(&none), "");
|
||||
let all = run(&mut s, "css.generate", json!({}));
|
||||
let sel: Vec<&str> = all["rules"].as_array().unwrap().iter().map(|r| r["selector"].as_str().unwrap()).collect();
|
||||
assert_eq!(sel, [".rectangle", ".ellipse"]);
|
||||
assert!(css(&all).contains("border-radius: 50%;"), "{}", css(&all));
|
||||
let named = run(&mut s, "css.generate", json!({"unnamed": false}));
|
||||
assert_eq!((named["rules"].as_array().unwrap().len(), named["skipped"].as_u64()), (0, Some(2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_writes_the_sheet_and_rasterized_art() {
|
||||
let mut s = session();
|
||||
run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 40}));
|
||||
run(&mut s, "shape.star", json!({"cx": 100, "cy": 100, "radius1": 40, "radius2": 20}));
|
||||
// Without a path: the CSS as data, the pictures as base64.
|
||||
let r = run(&mut s, "css.export", json!({"scope": "all", "rasterize": true}));
|
||||
let data = r["data"].as_str().unwrap();
|
||||
assert!(data.contains(".rectangle {") && data.contains(".path {\n") && data.contains("background-image: url(path.png);"), "{data}");
|
||||
assert!(data.ends_with("}\n"));
|
||||
assert_eq!(r["rules"], json!(2));
|
||||
let images = r["images"].as_array().unwrap();
|
||||
assert_eq!(images.len(), 1, "only the star is rasterized");
|
||||
assert_eq!(images[0]["name"], json!("path.png"));
|
||||
let png = vectorcraft_format::base64_decode(images[0]["dataBase64"].as_str().unwrap()).unwrap();
|
||||
assert!(png.starts_with(b"\x89PNG"));
|
||||
// The selection alone (the star, selected last).
|
||||
let r = run(&mut s, "css.export", json!({}));
|
||||
assert_eq!(r["rules"], json!(1));
|
||||
assert!(r["images"].as_array().unwrap().is_empty(), "no rasterize: no pictures");
|
||||
assert!(s.execute("css.export", &json!({"scope": "page"})).is_err());
|
||||
run(&mut s, "select.none", json!({}));
|
||||
assert!(s.execute("css.export", &json!({})).is_err(), "nothing selected: nothing to export");
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn export_to_a_path_puts_the_pictures_next_to_the_sheet() {
|
||||
let mut s = session();
|
||||
run(&mut s, "shape.star", json!({"cx": 100, "cy": 100, "radius1": 40, "radius2": 20}));
|
||||
let dir = std::env::temp_dir().join(format!("vc-css-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("site.css");
|
||||
let r = run(&mut s, "css.export", json!({"path": path.to_string_lossy(), "rasterize": true}));
|
||||
let written = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(written.contains("url(path.png)"), "{written}");
|
||||
assert_eq!(r["bytes"], json!(written.len()));
|
||||
let png = dir.join("path.png");
|
||||
assert_eq!(r["images"], json!([png.to_string_lossy()]));
|
||||
assert!(std::fs::read(&png).unwrap().starts_with(b"\x89PNG"));
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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}%\">");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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('"', """).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());
|
||||
}
|
||||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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, ¶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::<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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
||||
|
|
|
|||
20
docs/mcp.md
20
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(<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}}}
|
||||
```
|
||||
|
|
|
|||
Loading…
Reference in New Issue