Files
vectorcraft/crates/svg/src/css.rs
T
arhammusheer 5beac4f122 #1244: smaller objects: Node 352 -> 152 bytes, NodeKind 200 -> 80, and a cheaper undo estimate
Rarely set fields move into one optional boxed NodeExtras, and large NodeKind payloads (images,
live shapes, blends, envelopes) are boxed. Every object, and every undo step that copies it,
shrinks: a 50k-path move keeps ~24.5 MB per step instead of ~40 MB. Files stay byte-identical
(Node keeps its flat JSON through a borrowed Serialize and a from-wire Deserialize, no flatten),
and loading is slightly faster. Undo and redo reuse their step's size estimate instead of
walking the document again (8.3 -> 5.6 ms for a 50k-path move).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmMmc1J8XeNLxhRihWv9cc

# Conflicts:
#	crates/engine/src/cmd/graph.rs
#	crates/svg/src/export.rs
2026-10-12 11:02:32 +07:00

656 lines
28 KiB
Rust

//! 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::shapes::{self, CornerKind};
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, and `display: none` when it is hidden (written
/// only when hidden objects are kept).
pub(crate) fn transparency(n: &Node) -> Props {
let mut p = Props::new();
if !n.visible {
p.push(("display", "none".into()));
}
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`. The weight is the style's number
/// (600 for Semibold, 300 for Light), `bold` for 700 and left out for 400.
pub(crate) fn font_props(st: &CharStyle, len: &dyn Fn(f64) -> String) -> 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 (weight, italic) = font_descriptor(st);
match weight {
400 => {}
700 => p.push(("font-weight", "bold".into())),
w => p.push(("font-weight", w.to_string())),
}
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()));
}
let mut v: Vec<String> = st
.features
.iter()
.map(|t| match t.strip_prefix('-') {
Some(off) => format!("\"{off}\" 0"),
None => format!("\"{t}\" 1"),
})
.collect();
// Proportional Metrics: `palt` (SVG writes vertical type as outlines).
if st.proportional_metrics {
v.push("\"palt\" 1".into());
}
if !v.is_empty() {
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.extras().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_deref()) {
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.extras().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));
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)));
}
// One element: the first paragraph's alignment.
if (1..t.paragraph_count()).any(|i| t.para_at(i).justify != t.para.justify) {
art.unsupported("paragraphs aligned differently");
}
let align = match t.para.justify {
Justify::Auto | 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"),
RasterFx::Pixel(_) => art.unsupported("pixel effects"),
}
}
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
};
// Sizes and radii in document units.
match live.map(LiveShape::folded).as_ref() {
// CSS rounds corners only: an inverted round or chamfered corner has no border radius.
Some(LiveShape::Rectangle { w, h, radii, kinds, xf })
if upright(xf) && radii.iter().zip(kinds).all(|(r, k)| *r <= 0.0 || *k == CornerKind::Round) =>
{
// The radii as drawn, none past half the shorter side (CSS draws one further when its
// neighbours leave room).
let radii = radii.map(|r| shapes::fitted_corner_radius(*w, *h, r));
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]]))
} else {
Some(Corners::Round(radii.to_vec()))
}
}
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 { .. } | LiveShape::Path { .. },
) => 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
}