M6: SVG import fidelity (#90)
SVG import comes in editable and looking right: groups named from their ids with transforms baked into the layers (rotated, scaled and skewed groups, viewBox scaling and <use> offsets used to be lost); rectangles, ellipses and lines recognised and kept as those shapes; gradients with exact handles, reflect/repeat and focal points; patterns as image fills; clip paths and masks as vector, luminance and alpha masks; shadow and blur filter chains as drop shadows, inner shadows and layer blurs; blend modes; text as text layers (it used to vanish); images and nested SVG images. What can't be represented is reported as import warnings (file.import / file.open warnings, figcraft-cli info notes). Linked files are never read, and documents nested too deep for the parser are refused instead of crashing. Pasted SVG is recognised after XML declarations, comments and doctypes and lands centred in the view (file.import center). SVG export writes vector masks as clip paths and alpha/luminance masks as the mask layer drawn, so masks survive a round trip. Tests: a corpus of 21 synthetic SVG files with hand-worked expected pixels (158 probes), structure checks and export round trips; unit tests for shape recognition, paints, filters, structure, mask export; hostile input and byte-mangled corpus fuzzing; engine file command tests. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
|
|
@ -1483,6 +1483,7 @@ dependencies = [
|
|||
"figcraft-geom",
|
||||
"figcraft-pathops",
|
||||
"figcraft-render",
|
||||
"figcraft-text",
|
||||
"usvg",
|
||||
]
|
||||
|
||||
|
|
|
|||
14
ROADMAP.md
|
|
@ -167,13 +167,21 @@ dimension measured side by side.
|
|||
Export panel: a row per export setting (typed or preset sizes like `2x` / `512w` / `512h`, PNG,
|
||||
JPG, WebP, SVG and PDF, suffix), a preview, and "Export N layers" (`file.exportLayers`) in the
|
||||
panel and the Export dialog. Paste from other apps: images, SVG and text (the newest clipboard
|
||||
content wins over our own copy). PDF export: one page per frame in points (Export frames to
|
||||
content wins over our own copy; pasted SVG lands centred in the view). SVG import is editable and
|
||||
checked against a corpus of synthetic files (expected pixels worked out by hand, layer structure,
|
||||
export and re-import): groups named from their ids with transforms baked into the layers;
|
||||
rectangles (corner radius), ellipses and lines kept as those shapes; gradients with exact
|
||||
handles, `reflect`/`repeat` and focal points; patterns as image fills; clip paths and masks as
|
||||
vector, luminance and alpha masks; shadow and blur filter chains as drop shadows, inner shadows
|
||||
and layer blurs; blend modes; text as text layers with runs, anchors and stacked lines; images
|
||||
and nested SVG images; import warnings for what's left out; linked files never read; hostile
|
||||
input fuzzed. SVG export keeps alpha and luminance masks. PDF export: one page per frame in points (Export frames to
|
||||
PDF, the Export panel, `figcraft-cli export -o x.pdf`), vectors, gradients and strokes as
|
||||
vectors, real selectable text in subset fonts with clickable hyperlinks, images embedded once,
|
||||
opacity and blend modes, clip content, alpha/luminance/vector masks, shadows and layer and
|
||||
background blurs as images; checked by rendering the PDFs back against PNG exports.
|
||||
*Left:* .fig import (best effort), variable mode lookups in prototype expressions, PDF print
|
||||
options (bleed, CMYK). 10–20 h.
|
||||
options (bleed, CMYK), SVG filters other than shadows and blurs (left out with a warning). 10–20 h.
|
||||
7. **Look & feel and interaction-fidelity pass**: *landed:* keyboard shortcuts audited against the
|
||||
public shortcut reference: one keymap (`figcraft_engine::keymap`) drives the menus, the canvas,
|
||||
the control channel and MCP, with macOS and Windows/Linux keys where they differ (mask, bring to
|
||||
|
|
@ -302,7 +310,7 @@ Scores by behaviour, 0–100%. Hours are what remains (one agent).
|
|||
| Components & variants | 9 | 76% | QA with larger real-world libraries | 1 |
|
||||
| Styles & variables | 5 | 89% | variant properties and gradient stops bound to variables, REST import of bindings past the first paint and on layout grids, a colour picker for layout grid colours | 1 |
|
||||
| Prototyping | 6 | 89% | dragging actions to reorder them, QA against larger prototypes with conditionals | 2–4 |
|
||||
| Import/export | 6 | 66% | .fig import (best effort), variable-mode lookups in prototype expressions, PDF print options (bleed, CMYK) | 10–20 |
|
||||
| Import/export | 6 | 66% | .fig import (best effort), variable-mode lookups in prototype expressions, PDF print options (bleed, CMYK), SVG filters beyond shadows and blurs and rotated repeating patterns | 10–20 |
|
||||
| Dev mode / inspect | 2 | 84% | changes since ready for dev (compare view), focus view on one frame, annotation categories, visual QA | 1–2 |
|
||||
| Comments, versions, misc | 3 | 55% | plugins (WASM), multiplayer | 8–14 |
|
||||
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
spec!(
|
||||
"file.import",
|
||||
"Import",
|
||||
"{path | data (base64) + name, x?, y?, variables? (REST: the local variables JSON as an object, JSON text or a path)} SVG becomes vectors, images become image layers, .figcraft and REST API file JSON pages are appended with their variables and flows (REST: with `warnings`)",
|
||||
"{path | data (base64) + name, x?, y?, center? (SVG: centre it on x, y), variables? (REST: the local variables JSON as an object, JSON text or a path)} SVG becomes an editable frame (shapes, text, gradients, masks, effects; with `warnings` for what it left out), images become image layers, .figcraft and REST API file JSON pages are appended with their variables and flows (REST: with `warnings`)",
|
||||
true,
|
||||
import
|
||||
),
|
||||
|
|
@ -406,8 +406,10 @@ fn load_with(
|
|||
"svg" => {
|
||||
let mut doc = Document::new(file_stem(name_hint));
|
||||
let page = doc.first_page().ok_or(EngineError::NoDocument)?;
|
||||
figcraft_svg::import_svg(&mut doc, bytes, page, 0.0, 0.0, &file_stem(name_hint)).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
Ok((doc.into(), false))
|
||||
let notes = figcraft_svg::import_svg_with_notes(&mut doc, bytes, page, 0.0, 0.0, &file_stem(name_hint))
|
||||
.map_err(|e| EngineError::Other(e.to_string()))?
|
||||
.notes;
|
||||
Ok((figcraft_format::Loaded { issues: notes, ..doc.into() }, false))
|
||||
}
|
||||
"png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" => {
|
||||
let mut doc = Document::new(file_stem(name_hint));
|
||||
|
|
@ -525,10 +527,11 @@ fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
}
|
||||
let (loaded, rest) = load_path(&path, variables.as_ref())?;
|
||||
let native = !rest && matches!(ext(&path).as_str(), "figcraft" | "json");
|
||||
let warnings = rest || ext(&path) == "svg";
|
||||
s.docs.push(DocState::new(loaded.doc, native.then_some(path)).with_versions(loaded.versions));
|
||||
s.active = s.docs.len() - 1;
|
||||
let mut out = json!({"index": s.active});
|
||||
out[if rest { "warnings" } else { "repairs" }] = json!(loaded.issues);
|
||||
out[if warnings { "warnings" } else { "repairs" }] = json!(loaded.issues);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
|
|
@ -832,10 +835,22 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
match ext(&name).as_str() {
|
||||
"svg" => {
|
||||
let doc = s.doc_mut()?;
|
||||
let id = figcraft_svg::import_svg(doc, &bytes, page, x.unwrap_or(center.0), y.unwrap_or(center.1), &file_stem(&name))
|
||||
.map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let (x, y) = (x.unwrap_or(center.0), y.unwrap_or(center.1));
|
||||
let imported =
|
||||
figcraft_svg::import_svg_with_notes(doc, &bytes, page, x, y, &file_stem(&name)).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let id = imported.frame;
|
||||
// Centred on the point (a paste lands in the middle of the view).
|
||||
if boolean(p, "center").unwrap_or(false)
|
||||
&& let Some(n) = doc.node_mut(id)
|
||||
{
|
||||
n.transform = figcraft_geom::Affine::translate(((x - n.width / 2.0).round(), (y - n.height / 2.0).round()));
|
||||
}
|
||||
s.set_selection(vec![id]);
|
||||
Ok(json!({"id": id}))
|
||||
let mut out = json!({"id": id});
|
||||
if !imported.notes.is_empty() {
|
||||
out["warnings"] = json!(imported.notes);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
"figcraft" | "json" => {
|
||||
let resolver = path.as_deref().map(rest_images);
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ pub mod vectoredit;
|
|||
|
||||
pub use cmd::{CommandSpec, all_commands, find_command};
|
||||
pub use figcraft_doc as doc;
|
||||
pub use figcraft_svg::looks_like_svg;
|
||||
pub use tools::{PointerEvent, PointerKind, ToolId};
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
|
@ -1026,6 +1027,8 @@ mod slot_tests;
|
|||
#[cfg(test)]
|
||||
mod style_tests;
|
||||
#[cfg(test)]
|
||||
mod svg_import_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod text_fill_tests;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,67 @@
|
|||
//! SVG through the file commands: imported centred where it's pasted, with warnings for what it
|
||||
//! left out, opened with them, undone in one step; clipboard text recognised as SVG.
|
||||
|
||||
use base64::Engine as _;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use crate::params::id_of;
|
||||
|
||||
const SVG: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="20"><filter id="noise"><feTurbulence baseFrequency="0.1"/></filter><rect id="Card" width="40" height="20" rx="4" fill="#3366cc"/><circle cx="10" cy="10" r="5" filter="url(#noise)"/></svg>"##;
|
||||
|
||||
fn data(svg: &str) -> String {
|
||||
base64::engine::general_purpose::STANDARD.encode(svg)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_svg_is_editable_centred_and_reports_what_it_left_out() {
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("file.import", &json!({"data": data(SVG), "name": "Pasted SVG.svg", "x": 100, "y": 50, "center": true})).unwrap();
|
||||
let id = id_of(&r["id"]).unwrap();
|
||||
assert_eq!(s.selection(), [id]);
|
||||
let doc = s.doc().unwrap();
|
||||
let frame = doc.node(id).unwrap();
|
||||
assert_eq!((frame.name.as_str(), frame.width, frame.height), ("Pasted SVG", 40.0, 20.0));
|
||||
let o = doc.world_transform(id).translation();
|
||||
assert_eq!((o.x, o.y), (80.0, 40.0), "centred on the point");
|
||||
let card = doc.subtree(id).into_iter().filter_map(|i| doc.node(i)).find(|n| n.name == "Card").unwrap();
|
||||
assert_eq!((card.kind.type_name(), card.corners.radii[0]), ("RECTANGLE", 4.0));
|
||||
assert!(r["warnings"][0].as_str().is_some_and(|w| w.contains("noise")), "{r}");
|
||||
// Top-left at the point without `center`; one undo takes it away.
|
||||
let r = s.execute("file.import", &json!({"data": data(SVG), "name": "x.svg", "x": 100, "y": 50})).unwrap();
|
||||
let id2 = id_of(&r["id"]).unwrap();
|
||||
assert_eq!(s.doc().unwrap().world_transform(id2).translation().x, 100.0);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(s.doc().unwrap().node(id2).is_none());
|
||||
// Not SVG: an error, nothing added.
|
||||
let before = s.doc().unwrap().node_count();
|
||||
assert!(s.execute("file.import", &json!({"data": data("<svg"), "name": "bad.svg"})).is_err());
|
||||
assert_eq!(s.doc().unwrap().node_count(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opened_svg_lists_its_warnings() {
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-svg-open-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("icon.svg");
|
||||
std::fs::write(&path, SVG).unwrap();
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
assert!(r["warnings"].as_array().is_some_and(|w| w.len() == 1), "{r}");
|
||||
assert!(r.get("repairs").is_none(), "{r}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipboard_text_is_recognised_as_svg() {
|
||||
for yes in [
|
||||
"<svg/>",
|
||||
" \n<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
|
||||
"\u{feff}<?xml version=\"1.0\"?>\n<!-- Generator: x -->\n<!DOCTYPE svg PUBLIC \"-//W3C//DTD SVG 1.1//EN\" \"x.dtd\">\n<svg>",
|
||||
] {
|
||||
assert!(crate::looks_like_svg(yes), "{yes}");
|
||||
}
|
||||
for no in ["", "svg", "<html><svg/></html>", "<?xml version=\"1.0\"?><note/>", "<!-- unclosed", "Hello <svg>"] {
|
||||
assert!(!crate::looks_like_svg(no), "{no}");
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ figcraft-geom = { workspace = true }
|
|||
figcraft-color = { workspace = true }
|
||||
figcraft-doc = { workspace = true }
|
||||
figcraft-render = { workspace = true }
|
||||
figcraft-text = { workspace = true }
|
||||
figcraft-pathops = { workspace = true }
|
||||
usvg = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -1,238 +0,0 @@
|
|||
//! SVG import: usvg's normalised tree → a frame of vector nodes (groups kept as groups).
|
||||
|
||||
use figcraft_color::{Color, ColorStop, Gradient, GradientKind, Paint};
|
||||
use figcraft_doc::{Document, Node, NodeId, NodeKind, StrokeAlign, StrokeCap, StrokeJoin, VectorNetwork, WindingRule};
|
||||
use figcraft_geom::{Affine, BezPath, Point, Rect, Shape};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ImportError(pub String);
|
||||
|
||||
impl std::fmt::Display for ImportError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "SVG import failed: {}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
fn xf(t: usvg::Transform) -> Affine {
|
||||
Affine::new([f64::from(t.sx), f64::from(t.ky), f64::from(t.kx), f64::from(t.sy), f64::from(t.tx), f64::from(t.ty)])
|
||||
}
|
||||
|
||||
fn to_bez(p: &usvg::tiny_skia_path::Path) -> BezPath {
|
||||
use usvg::tiny_skia_path::PathSegment::*;
|
||||
let mut b = BezPath::new();
|
||||
for s in p.segments() {
|
||||
match s {
|
||||
MoveTo(a) => b.move_to((f64::from(a.x), f64::from(a.y))),
|
||||
LineTo(a) => b.line_to((f64::from(a.x), f64::from(a.y))),
|
||||
QuadTo(c, a) => b.quad_to((f64::from(c.x), f64::from(c.y)), (f64::from(a.x), f64::from(a.y))),
|
||||
CubicTo(c1, c2, a) => {
|
||||
b.curve_to((f64::from(c1.x), f64::from(c1.y)), (f64::from(c2.x), f64::from(c2.y)), (f64::from(a.x), f64::from(a.y)))
|
||||
}
|
||||
Close => b.close_path(),
|
||||
}
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
fn color(c: usvg::Color, op: f32) -> Color {
|
||||
Color::new(f32::from(c.red) / 255.0, f32::from(c.green) / 255.0, f32::from(c.blue) / 255.0, op)
|
||||
}
|
||||
|
||||
/// Convert a usvg paint into ours for a node whose local box is `bbox` (absolute coordinates) and
|
||||
/// whose absolute transform is `abs`.
|
||||
fn paint(p: &usvg::Paint, opacity: f32, bbox: Rect, abs: Affine) -> Option<Paint> {
|
||||
let to_unit = |pt: Point| -> (f64, f64) {
|
||||
let w = bbox.width().max(1e-9);
|
||||
let h = bbox.height().max(1e-9);
|
||||
((pt.x - bbox.x0) / w, (pt.y - bbox.y0) / h)
|
||||
};
|
||||
match p {
|
||||
usvg::Paint::Color(c) => Some(Paint::solid(color(*c, 1.0)).with_opacity(opacity)),
|
||||
usvg::Paint::LinearGradient(g) => {
|
||||
let gt = abs * xf(g.transform());
|
||||
let a = gt * Point::new(f64::from(g.x1()), f64::from(g.y1()));
|
||||
let b = gt * Point::new(f64::from(g.x2()), f64::from(g.y2()));
|
||||
let stops = g.stops().iter().map(|s| ColorStop { position: s.offset().get(), color: color(s.color(), s.opacity().get()) }).collect();
|
||||
let from = to_unit(a);
|
||||
let to = to_unit(b);
|
||||
Some(
|
||||
Paint::gradient(Gradient {
|
||||
kind: GradientKind::Linear,
|
||||
stops,
|
||||
from,
|
||||
to,
|
||||
width: (from.0 - (to.1 - from.1), from.1 + (to.0 - from.0)),
|
||||
})
|
||||
.with_opacity(opacity),
|
||||
)
|
||||
}
|
||||
usvg::Paint::RadialGradient(g) => {
|
||||
let gt = abs * xf(g.transform());
|
||||
let c = gt * Point::new(f64::from(g.cx()), f64::from(g.cy()));
|
||||
let r = f64::from(g.r().get());
|
||||
let ex = gt * Point::new(f64::from(g.cx()) + r, f64::from(g.cy()));
|
||||
let ey = gt * Point::new(f64::from(g.cx()), f64::from(g.cy()) + r);
|
||||
let stops = g.stops().iter().map(|s| ColorStop { position: s.offset().get(), color: color(s.color(), s.opacity().get()) }).collect();
|
||||
Some(
|
||||
Paint::gradient(Gradient { kind: GradientKind::Radial, stops, from: to_unit(c), to: to_unit(ex), width: to_unit(ey) })
|
||||
.with_opacity(opacity),
|
||||
)
|
||||
}
|
||||
usvg::Paint::Pattern(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
trait WithOpacity {
|
||||
fn with_opacity(self, o: f32) -> Self;
|
||||
}
|
||||
|
||||
impl WithOpacity for Paint {
|
||||
fn with_opacity(mut self, o: f32) -> Self {
|
||||
self.opacity *= o.clamp(0.0, 1.0);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
struct Ctx<'a> {
|
||||
doc: &'a mut Document,
|
||||
/// Inverse of the root frame's placement: absolute SVG → frame-local.
|
||||
count: usize,
|
||||
}
|
||||
|
||||
const MAX_NODES: usize = 100_000;
|
||||
|
||||
fn add_group(cx: &mut Ctx, g: &usvg::Group, parent: NodeId, parent_abs: Affine, depth: usize) {
|
||||
if depth > 128 {
|
||||
return;
|
||||
}
|
||||
for child in g.children() {
|
||||
if cx.count > MAX_NODES {
|
||||
return;
|
||||
}
|
||||
match child {
|
||||
usvg::Node::Group(sub) => {
|
||||
let abs = xf(sub.abs_transform());
|
||||
let id = cx.doc.alloc_id();
|
||||
let name = if sub.id().is_empty() { "Group".to_string() } else { sub.id().to_string() };
|
||||
let mut n = Node::new(id, NodeKind::Group, name, 0.0, 0.0);
|
||||
n.opacity = sub.opacity().get();
|
||||
if cx.doc.insert(n, parent, None).is_err() {
|
||||
continue;
|
||||
}
|
||||
cx.count += 1;
|
||||
add_group(cx, sub, id, abs, depth + 1);
|
||||
if cx.doc.node(id).is_some_and(|n| n.children.is_empty()) {
|
||||
let _ = cx.doc.remove(id);
|
||||
} else {
|
||||
let _ = cx.doc.fit_to_children(id);
|
||||
}
|
||||
}
|
||||
usvg::Node::Path(p) => {
|
||||
if !p.is_visible() {
|
||||
continue;
|
||||
}
|
||||
let abs = xf(p.abs_transform());
|
||||
let mut path = to_bez(p.data());
|
||||
path.apply_affine(parent_abs.inverse() * abs);
|
||||
// Place the vector at its bbox origin in the parent.
|
||||
let bb = path.bounding_box();
|
||||
if !(bb.x0.is_finite() && bb.y0.is_finite()) {
|
||||
continue;
|
||||
}
|
||||
path.apply_affine(Affine::translate((-bb.x0, -bb.y0)));
|
||||
let mut net = VectorNetwork::from_path(&path);
|
||||
let even = p.fill().is_some_and(|f| f.rule() == usvg::FillRule::EvenOdd);
|
||||
for r in &mut net.regions {
|
||||
r.winding = if even { WindingRule::EvenOdd } else { WindingRule::NonZero };
|
||||
}
|
||||
if p.fill().is_none() {
|
||||
net.regions.clear();
|
||||
}
|
||||
let id = cx.doc.alloc_id();
|
||||
let name = if p.id().is_empty() { "Vector".to_string() } else { p.id().to_string() };
|
||||
let mut n = Node::new(id, NodeKind::Vector { network: net }, name, bb.width(), bb.height());
|
||||
n.transform = Affine::translate((bb.x0, bb.y0));
|
||||
let abs_bbox = figcraft_geom::transform_rect_bbox(parent_abs, bb);
|
||||
// Paint coordinates are absolute: express them in the vector's box.
|
||||
n.fills = p.fill().and_then(|f| paint(f.paint(), f.opacity().get(), abs_bbox, Affine::IDENTITY)).into_iter().collect();
|
||||
if let Some(s) = p.stroke() {
|
||||
n.strokes = paint(s.paint(), s.opacity().get(), abs_bbox, Affine::IDENTITY).into_iter().collect();
|
||||
n.stroke.weight = f64::from(s.width().get());
|
||||
n.stroke.align = StrokeAlign::Center;
|
||||
n.stroke.join = match s.linejoin() {
|
||||
usvg::LineJoin::Round => StrokeJoin::Round,
|
||||
usvg::LineJoin::Bevel => StrokeJoin::Bevel,
|
||||
_ => StrokeJoin::Miter,
|
||||
};
|
||||
let cap = match s.linecap() {
|
||||
usvg::LineCap::Round => StrokeCap::Round,
|
||||
usvg::LineCap::Square => StrokeCap::Square,
|
||||
usvg::LineCap::Butt => StrokeCap::None,
|
||||
};
|
||||
n.stroke.cap_start = cap;
|
||||
n.stroke.cap_end = cap;
|
||||
n.stroke.miter_limit = f64::from(s.miterlimit().get());
|
||||
if let Some(d) = s.dasharray() {
|
||||
n.stroke.dashes = d.iter().map(|v| f64::from(*v)).collect();
|
||||
}
|
||||
} else {
|
||||
n.strokes.clear();
|
||||
}
|
||||
if cx.doc.insert(n, parent, None).is_ok() {
|
||||
cx.count += 1;
|
||||
}
|
||||
}
|
||||
usvg::Node::Text(t) => {
|
||||
let flat = t.flattened();
|
||||
add_group(cx, flat, parent, parent_abs, depth + 1);
|
||||
}
|
||||
usvg::Node::Image(img) => {
|
||||
let bytes = match img.kind() {
|
||||
usvg::ImageKind::PNG(b) | usvg::ImageKind::JPEG(b) | usvg::ImageKind::GIF(b) | usvg::ImageKind::WEBP(b) => b.as_ref().clone(),
|
||||
usvg::ImageKind::SVG(_) => continue,
|
||||
};
|
||||
let hash = simple_hash(&bytes);
|
||||
let abs = xf(img.abs_transform());
|
||||
let local = parent_abs.inverse() * abs;
|
||||
let size = img.size();
|
||||
let id = cx.doc.alloc_id();
|
||||
let mut n = Node::new(id, NodeKind::Rectangle, "Image", f64::from(size.width()), f64::from(size.height()));
|
||||
n.transform = local;
|
||||
n.fills = vec![Paint::image(hash.clone(), figcraft_color::ImageScaleMode::Fill)];
|
||||
let (w, h) = (size.width() as u32, size.height() as u32);
|
||||
cx.doc.images.insert(hash, figcraft_doc::ImageData { bytes: std::sync::Arc::new(bytes), width: w, height: h });
|
||||
if cx.doc.insert(n, parent, None).is_ok() {
|
||||
cx.count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn simple_hash(b: &[u8]) -> String {
|
||||
// FNV-1a 128 (two lanes); the format crate re-keys by sha256 on save if needed.
|
||||
let mut h1: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
let mut h2: u64 = 0x8422_2325_cbf2_9ce4;
|
||||
for (i, x) in b.iter().enumerate() {
|
||||
h1 ^= u64::from(*x);
|
||||
h1 = h1.wrapping_mul(0x0100_0000_01b3);
|
||||
h2 ^= u64::from(*x).wrapping_add(i as u64);
|
||||
h2 = h2.wrapping_mul(0x0100_0000_01b3);
|
||||
}
|
||||
format!("{h1:016x}{h2:016x}")
|
||||
}
|
||||
|
||||
/// Import SVG bytes as a new frame under `parent` at `(x, y)`. Returns the frame id.
|
||||
pub fn import_svg(doc: &mut Document, data: &[u8], parent: NodeId, x: f64, y: f64, name: &str) -> Result<NodeId, ImportError> {
|
||||
let opts = usvg::Options::default();
|
||||
let tree = usvg::Tree::from_data(data, &opts).map_err(|e| ImportError(e.to_string()))?;
|
||||
let size = tree.size();
|
||||
let frame =
|
||||
doc.create(parent, NodeKind::Frame, name, x, y, f64::from(size.width()), f64::from(size.height())).map_err(|e| ImportError(e.to_string()))?;
|
||||
if let Some(f) = doc.node_mut(frame) {
|
||||
f.fills.clear();
|
||||
}
|
||||
let mut cx = Ctx { doc, count: 0 };
|
||||
add_group(&mut cx, tree.root(), frame, Affine::IDENTITY, 0);
|
||||
Ok(frame)
|
||||
}
|
||||
|
|
@ -0,0 +1,257 @@
|
|||
//! SVG filters → layer effects. Each filter's primitives are evaluated symbolically: what each
|
||||
//! result *is* (the layer, the layer blurred, a shadow made from the layer's alpha, a flood, a
|
||||
//! stack of those) rather than its pixels. A filter whose result is the layer with shadows under
|
||||
//! it, shadows cut into it, or blurred, maps exactly onto drop shadows, inner shadows and a layer
|
||||
//! blur: `feDropShadow`; `feOffset` + `feGaussianBlur` + `feFlood` + `feComposite` + `feMerge`;
|
||||
//! the colour-matrix form (alpha → offset → blur → matrix tint → blend), with `feMorphology` as
|
||||
//! spread and arithmetic or `out` composites for inner shadows. Anything else is reported back as
|
||||
//! unsupported and the layer is drawn without it.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use figcraft_color::{BlendMode, Color};
|
||||
use figcraft_doc::{Blur, Effect, EffectKind, Shadow};
|
||||
use figcraft_geom::{Affine, Vec2};
|
||||
use usvg::filter::{ColorMatrixKind, CompositeOperator, Input, Kind, MorphologyOperator};
|
||||
|
||||
use super::paint::color;
|
||||
use super::shape::length_scale;
|
||||
|
||||
/// A shadow-like image: the layer's alpha, offset, spread, blurred and tinted.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct Sh {
|
||||
dx: f64,
|
||||
dy: f64,
|
||||
sigma: f64,
|
||||
spread: f64,
|
||||
color: Color,
|
||||
/// Cut out where the layer is (drop shadows that don't show behind it).
|
||||
knockout: bool,
|
||||
/// Inside the layer where the offset copy isn't: an inner shadow.
|
||||
inner: bool,
|
||||
/// Everything but the shadow (a flood with the shadow cut out): becomes an inner shadow once
|
||||
/// it's cut to the layer.
|
||||
inverted: bool,
|
||||
}
|
||||
|
||||
impl Sh {
|
||||
fn alpha() -> Self {
|
||||
Sh { dx: 0.0, dy: 0.0, sigma: 0.0, spread: 0.0, color: Color::BLACK, knockout: false, inner: false, inverted: false }
|
||||
}
|
||||
/// Still exactly the layer's alpha.
|
||||
fn is_plain(&self) -> bool {
|
||||
*self == Sh::alpha()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
enum Img {
|
||||
/// The layer (blurred by sigma).
|
||||
Layer(f64),
|
||||
Shadow(Sh),
|
||||
Flood(Color),
|
||||
Clear,
|
||||
/// Bottom to top.
|
||||
Stack(Vec<Img>),
|
||||
}
|
||||
|
||||
impl Img {
|
||||
fn into_layers(self) -> Vec<Img> {
|
||||
match self {
|
||||
Img::Stack(v) => v.into_iter().flat_map(Img::into_layers).collect(),
|
||||
Img::Clear => vec![],
|
||||
i => vec![i],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn stack(bottom: Img, top: Img) -> Img {
|
||||
let mut v = bottom.into_layers();
|
||||
v.extend(top.into_layers());
|
||||
Img::Stack(v)
|
||||
}
|
||||
|
||||
/// The effects `filters` amount to on a layer whose user space maps to the page by `world`; `None`
|
||||
/// when one of them can't be expressed as effects.
|
||||
pub(crate) fn effects(filters: &[std::sync::Arc<usvg::filter::Filter>], world: Affine) -> Option<Vec<Effect>> {
|
||||
let mut out = Vec::new();
|
||||
for f in filters {
|
||||
out.extend(filter(f, world)?);
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn filter(f: &usvg::filter::Filter, world: Affine) -> Option<Vec<Effect>> {
|
||||
let mut results: HashMap<&str, Img> = HashMap::new();
|
||||
let mut last = Img::Layer(0.0);
|
||||
for p in f.primitives().iter().take(64) {
|
||||
let get = |i: &Input| -> Option<Img> {
|
||||
match i {
|
||||
Input::SourceGraphic => Some(Img::Layer(0.0)),
|
||||
Input::SourceAlpha => Some(Img::Shadow(Sh::alpha())),
|
||||
Input::Reference(r) => results.get(r.as_str()).cloned(),
|
||||
}
|
||||
};
|
||||
let img = primitive(p.kind(), &get)?;
|
||||
results.insert(p.result(), img.clone());
|
||||
last = img;
|
||||
}
|
||||
to_effects(last, world)
|
||||
}
|
||||
|
||||
fn primitive(k: &Kind, get: &dyn Fn(&Input) -> Option<Img>) -> Option<Img> {
|
||||
Some(match k {
|
||||
Kind::Offset(o) => match get(o.input())? {
|
||||
Img::Shadow(s) => Img::Shadow(Sh { dx: s.dx + f64::from(o.dx()), dy: s.dy + f64::from(o.dy()), ..s }),
|
||||
Img::Layer(b) if o.dx() == 0.0 && o.dy() == 0.0 => Img::Layer(b),
|
||||
Img::Clear => Img::Clear,
|
||||
_ => return None,
|
||||
},
|
||||
Kind::GaussianBlur(g) => {
|
||||
let sigma = (f64::from(g.std_dev_x().get()) + f64::from(g.std_dev_y().get())) / 2.0;
|
||||
match get(g.input())? {
|
||||
Img::Shadow(s) => Img::Shadow(Sh { sigma: s.sigma.hypot(sigma), ..s }),
|
||||
Img::Layer(b) => Img::Layer(b.hypot(sigma)),
|
||||
Img::Clear => Img::Clear,
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
Kind::Flood(fl) => {
|
||||
let a = fl.opacity().get();
|
||||
if a <= 0.0 { Img::Clear } else { Img::Flood(color(fl.color(), a)) }
|
||||
}
|
||||
Kind::Morphology(m) => match get(m.input())? {
|
||||
Img::Shadow(s) if s.sigma == 0.0 && s.spread == 0.0 && !s.inner && !s.inverted => {
|
||||
let r = (f64::from(m.radius_x().get()) + f64::from(m.radius_y().get())) / 2.0;
|
||||
Img::Shadow(Sh { spread: if m.operator() == MorphologyOperator::Dilate { r } else { -r }, ..s })
|
||||
}
|
||||
_ => return None,
|
||||
},
|
||||
Kind::ColorMatrix(cm) => {
|
||||
let ColorMatrixKind::Matrix(v) = cm.kind() else { return None };
|
||||
let s = match get(cm.input())? {
|
||||
Img::Shadow(s) => s,
|
||||
Img::Layer(0.0) => Sh::alpha(),
|
||||
_ => return None,
|
||||
};
|
||||
tint(v, s)?
|
||||
}
|
||||
Kind::Composite(c) => composite(get(c.input1())?, get(c.input2())?, c.operator())?,
|
||||
Kind::Merge(m) => {
|
||||
let mut v = Vec::new();
|
||||
for i in m.inputs() {
|
||||
v.extend(get(i)?.into_layers());
|
||||
}
|
||||
Img::Stack(v)
|
||||
}
|
||||
Kind::Blend(b) => {
|
||||
let (top, bottom) = (get(b.input1())?, get(b.input2())?);
|
||||
match b.mode() {
|
||||
usvg::BlendMode::Normal => stack(bottom, top),
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
Kind::DropShadow(d) => {
|
||||
let sigma = (f64::from(d.std_dev_x().get()) + f64::from(d.std_dev_y().get())) / 2.0;
|
||||
let s = Sh { dx: f64::from(d.dx()), dy: f64::from(d.dy()), sigma, color: color(d.color(), d.opacity().get()), ..Sh::alpha() };
|
||||
match get(d.input())? {
|
||||
Img::Layer(b) => stack(Img::Shadow(s), Img::Layer(b)),
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// A colour matrix applied to a shadow: rows that set a constant colour and scale alpha tint it;
|
||||
/// the "hard alpha" row (alpha × 127, opaque where there's any alpha) keeps it as it is.
|
||||
fn tint(v: &[f32], s: Sh) -> Option<Img> {
|
||||
let row = |r: usize| v.get(r * 5..r * 5 + 5);
|
||||
let (rr, gr, br, ar) = (row(0)?, row(1)?, row(2)?, row(3)?);
|
||||
let constant = |r: &[f32]| r.get(..4).is_some_and(|c| c.iter().all(|x| *x == 0.0));
|
||||
if !(constant(rr) && constant(gr) && constant(br)) || ar.get(..3).is_none_or(|c| c.iter().any(|x| *x != 0.0)) || ar.get(4) != Some(&0.0) {
|
||||
return None;
|
||||
}
|
||||
let a = f64::from(*ar.get(3)?);
|
||||
let k = |r: &[f32]| r.get(4).copied().unwrap_or(0.0).clamp(0.0, 1.0);
|
||||
if a >= 100.0 && [k(rr), k(gr), k(br)] == [0.0; 3] {
|
||||
return Some(Img::Shadow(s));
|
||||
}
|
||||
Some(Img::Shadow(Sh { color: Color::new(k(rr), k(gr), k(br), (f64::from(s.color.a) * a).clamp(0.0, 1.0) as f32), ..s }))
|
||||
}
|
||||
|
||||
fn composite(a: Img, b: Img, op: CompositeOperator) -> Option<Img> {
|
||||
let tinted = |s: Sh, c: Color| Sh { color: Color::new(c.r, c.g, c.b, (c.a * s.color.a).clamp(0.0, 1.0)), ..s };
|
||||
Some(match (op, a, b) {
|
||||
(CompositeOperator::Over, a, b) => stack(b, a),
|
||||
(CompositeOperator::In, Img::Flood(c), Img::Shadow(s)) | (CompositeOperator::In, Img::Shadow(s), Img::Flood(c)) => Img::Shadow(tinted(s, c)),
|
||||
// A flood cut to everything but a shadow, then cut to the layer: an inner shadow.
|
||||
(CompositeOperator::Out, Img::Flood(c), Img::Shadow(s)) if !s.inner && !s.inverted => Img::Shadow(Sh { inverted: true, ..tinted(s, c) }),
|
||||
(CompositeOperator::In, Img::Shadow(s), Img::Shadow(l)) if s.inverted && l.is_plain() => {
|
||||
Img::Shadow(Sh { inverted: false, inner: true, ..s })
|
||||
}
|
||||
(CompositeOperator::Out, Img::Shadow(s), Img::Shadow(l)) if l.is_plain() && !s.inner && !s.inverted => {
|
||||
Img::Shadow(Sh { knockout: true, ..s })
|
||||
}
|
||||
// layer − shadow: inside the layer where the offset copy isn't.
|
||||
(CompositeOperator::Arithmetic { k1, k2, k3, k4 }, Img::Shadow(s), Img::Shadow(l))
|
||||
if k1 == 0.0 && k4 == 0.0 && k2 == -1.0 && k3 == 1.0 && l.is_plain() && !s.inner =>
|
||||
{
|
||||
Img::Shadow(Sh { inner: true, ..s })
|
||||
}
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The effects of a filter whose result is `img`.
|
||||
fn to_effects(img: Img, world: Affine) -> Option<Vec<Effect>> {
|
||||
let layers = img.into_layers();
|
||||
let at = layers.iter().position(|l| matches!(l, Img::Layer(_)))?;
|
||||
if layers.iter().filter(|l| matches!(l, Img::Layer(_))).count() != 1 {
|
||||
return None;
|
||||
}
|
||||
let k = length_scale(world);
|
||||
let mut out = Vec::new();
|
||||
for (i, l) in layers.iter().enumerate() {
|
||||
match l {
|
||||
Img::Layer(b) if *b > 0.0 => out.push(EffectKind::LayerBlur(Blur { radius: 2.0 * b * k, ..Blur::default() })),
|
||||
Img::Layer(_) => {}
|
||||
Img::Shadow(s) if s.inverted => return None,
|
||||
Img::Shadow(s) => {
|
||||
let off = linear(world, Vec2::new(s.dx, s.dy));
|
||||
let shadow = Shadow {
|
||||
color: s.color,
|
||||
offset: (off.x, off.y),
|
||||
radius: 2.0 * s.sigma * k,
|
||||
spread: s.spread * k,
|
||||
blend: BlendMode::Normal,
|
||||
show_behind: !s.knockout,
|
||||
};
|
||||
match (s.inner, i < at) {
|
||||
(false, true) => out.push(EffectKind::DropShadow(shadow)),
|
||||
(true, false) => out.push(EffectKind::InnerShadow(Shadow { show_behind: false, ..shadow })),
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
Img::Flood(_) | Img::Clear | Img::Stack(_) => return None,
|
||||
}
|
||||
}
|
||||
if out.iter().any(|e| !effect_is_finite(e)) {
|
||||
return None;
|
||||
}
|
||||
Some(out.into_iter().map(|kind| Effect { kind, visible: true }).collect())
|
||||
}
|
||||
|
||||
/// `v` through `world` without its translation.
|
||||
pub(crate) fn linear(world: Affine, v: Vec2) -> Vec2 {
|
||||
let [a, b, c, d, ..] = world.as_coeffs();
|
||||
Vec2::new(a * v.x + c * v.y, b * v.x + d * v.y)
|
||||
}
|
||||
|
||||
fn effect_is_finite(e: &EffectKind) -> bool {
|
||||
match e {
|
||||
EffectKind::DropShadow(s) | EffectKind::InnerShadow(s) => [s.offset.0, s.offset.1, s.radius, s.spread].iter().all(|v| v.is_finite()),
|
||||
EffectKind::LayerBlur(b) => b.radius.is_finite(),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
//! Filters → effects: the primitive chains tools write for shadows and blurs, scaled with their
|
||||
//! layer, and our own exports coming back as the effects they were.
|
||||
|
||||
use figcraft_color::{Color, Paint};
|
||||
use figcraft_doc::{Blur, Document, Effect, EffectKind, NodeKind, Shadow};
|
||||
|
||||
use super::import_svg_with_notes;
|
||||
|
||||
/// The effects on the first layer of `svg` with filter `filter` (its `<filter>` content) on a
|
||||
/// 40 × 40 rectangle, optionally inside a group with `transform`.
|
||||
fn effects(filter: &str, transform: &str) -> (Vec<EffectKind>, Vec<String>) {
|
||||
let svg = format!(
|
||||
r##"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200"><filter id="f" x="-1" y="-1" width="3" height="3">{filter}</filter><g transform="{transform}"><rect x="10" y="10" width="40" height="40" fill="red" filter="url(#f)"/></g></svg>"##
|
||||
);
|
||||
let mut d = Document::new("t");
|
||||
let p = d.first_page().unwrap();
|
||||
let i = import_svg_with_notes(&mut d, svg.as_bytes(), p, 0.0, 0.0, "x").unwrap();
|
||||
let rect = d.subtree(i.frame).into_iter().filter_map(|id| d.node(id)).find(|n| n.kind == NodeKind::Rectangle).unwrap().clone();
|
||||
(rect.effects.into_iter().map(|e| e.kind).collect(), i.notes)
|
||||
}
|
||||
|
||||
fn shadow(k: &EffectKind) -> &Shadow {
|
||||
match k {
|
||||
EffectKind::DropShadow(s) | EffectKind::InnerShadow(s) => s,
|
||||
k => panic!("{k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blurs_and_drop_shadows() {
|
||||
let (fx, _) = effects(r##"<feGaussianBlur stdDeviation="3"/>"##, "");
|
||||
assert!(matches!(&fx[..], [EffectKind::LayerBlur(b)] if b.radius == 6.0), "{fx:?}");
|
||||
let (fx, _) = effects(r##"<feDropShadow dx="2" dy="3" stdDeviation="1.5" flood-color="#00f" flood-opacity="0.4"/>"##, "");
|
||||
let s = shadow(&fx[0]);
|
||||
assert!(matches!(fx[0], EffectKind::DropShadow(_)));
|
||||
assert_eq!((s.offset, s.radius, s.spread), ((2.0, 3.0), 3.0, 0.0));
|
||||
assert!((s.color.b - 1.0).abs() < 1e-6 && (s.color.a - 0.4).abs() < 1e-6);
|
||||
assert!(s.show_behind, "an SVG shadow shows through the layer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effects_scale_with_their_layer() {
|
||||
let (fx, _) = effects(r##"<feDropShadow dx="2" dy="0" stdDeviation="1"/>"##, "scale(2)");
|
||||
let s = shadow(&fx[0]);
|
||||
assert_eq!((s.offset, s.radius), ((4.0, 0.0), 4.0));
|
||||
// Turned with the layer.
|
||||
let (fx, _) = effects(r##"<feDropShadow dx="2" dy="0" stdDeviation="1"/>"##, "rotate(90)");
|
||||
let s = shadow(&fx[0]);
|
||||
assert!(s.offset.0.abs() < 1e-9 && (s.offset.1 - 2.0).abs() < 1e-9, "{s:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_blur_flood_composite_merge_is_a_drop_shadow() {
|
||||
let (fx, _) = effects(
|
||||
r##"<feOffset in="SourceAlpha" dx="1" dy="2" result="o"/><feGaussianBlur in="o" stdDeviation="2" result="b"/><feFlood flood-color="#0f0" flood-opacity="0.5"/><feComposite in2="b" operator="in" result="s"/><feMerge><feMergeNode in="s"/><feMergeNode in="SourceGraphic"/></feMerge>"##,
|
||||
"",
|
||||
);
|
||||
let s = shadow(&fx[0]);
|
||||
assert_eq!((s.offset, s.radius), ((1.0, 2.0), 4.0));
|
||||
assert!((s.color.g - 1.0).abs() < 1e-6 && (s.color.a - 0.5).abs() < 1e-6);
|
||||
// Spread from a dilated alpha.
|
||||
let (fx, _) = effects(
|
||||
r##"<feMorphology in="SourceAlpha" operator="dilate" radius="3"/><feOffset dy="1"/><feGaussianBlur stdDeviation="1"/><feMerge><feMergeNode/><feMergeNode in="SourceGraphic"/></feMerge>"##,
|
||||
"",
|
||||
);
|
||||
assert_eq!(shadow(&fx[0]).spread, 3.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colour_matrix_shadows_inner_and_drop() {
|
||||
let hard = r##"<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hard"/>"##;
|
||||
let (fx, _) = effects(
|
||||
&format!(
|
||||
r##"<feFlood flood-opacity="0" result="bg"/>{hard}<feOffset dy="4"/><feGaussianBlur stdDeviation="2"/><feComposite in2="hard" operator="out"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/><feBlend mode="normal" in2="bg" result="drop"/><feBlend mode="normal" in="SourceGraphic" in2="drop" result="shape"/>{}<feOffset dx="-1"/><feGaussianBlur stdDeviation="0.5"/><feComposite in2="hard2" operator="arithmetic" k2="-1" k3="1"/><feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.5 0"/><feBlend mode="normal" in2="shape"/>"##,
|
||||
hard.replace("hard", "hard2")
|
||||
),
|
||||
"",
|
||||
);
|
||||
let [EffectKind::DropShadow(d), EffectKind::InnerShadow(i)] = &fx[..] else { panic!("{fx:?}") };
|
||||
assert_eq!((d.offset, d.radius, d.color.a, d.show_behind), ((0.0, 4.0), 4.0, 0.25, false));
|
||||
assert_eq!((i.offset, i.radius, i.color), ((-1.0, 0.0), 1.0, Color::new(1.0, 1.0, 1.0, 0.5)));
|
||||
// The flood-out-in way of writing an inner shadow.
|
||||
let (fx, _) = effects(
|
||||
r##"<feOffset in="SourceAlpha" dx="2" dy="2"/><feGaussianBlur stdDeviation="1" result="b"/><feFlood flood-color="#000" flood-opacity="0.6"/><feComposite in2="b" operator="out"/><feComposite in2="SourceAlpha" operator="in" result="inner"/><feMerge><feMergeNode in="SourceGraphic"/><feMergeNode in="inner"/></feMerge>"##,
|
||||
"",
|
||||
);
|
||||
assert!(matches!(&fx[..], [EffectKind::InnerShadow(s)] if s.offset == (2.0, 2.0) && (s.color.a - 0.6).abs() < 1e-6), "{fx:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filters_that_arent_shadows_or_blurs_are_left_out_and_reported() {
|
||||
for f in [
|
||||
r##"<feTurbulence baseFrequency="0.1"/>"##,
|
||||
r##"<feColorMatrix type="saturate" values="0"/>"##,
|
||||
// Just a shadow, without the layer.
|
||||
r##"<feOffset in="SourceAlpha" dx="2"/>"##,
|
||||
// A shadow drawn over the layer.
|
||||
r##"<feOffset in="SourceAlpha" dx="2" result="s"/><feMerge><feMergeNode in="SourceGraphic"/><feMergeNode in="s"/></feMerge>"##,
|
||||
r##"<feBlend mode="multiply" in="SourceGraphic" in2="SourceAlpha"/>"##,
|
||||
] {
|
||||
let (fx, notes) = effects(f, "");
|
||||
assert!(fx.is_empty(), "{f}: {fx:?}");
|
||||
assert!(notes.iter().any(|n| n.contains("filter")), "{f}: {notes:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exported_effects_come_back_as_they_were() {
|
||||
let mut d = Document::new("t");
|
||||
let p = d.first_page().unwrap();
|
||||
let r = d.create(p, NodeKind::Rectangle, "R", 20.0, 20.0, 60.0, 40.0).unwrap();
|
||||
let shadow = Shadow { color: Color::new(0.1, 0.2, 0.3, 0.4), offset: (3.0, 5.0), radius: 6.0, ..Shadow::default() };
|
||||
let node = d.get_mut(r).unwrap();
|
||||
node.fills = vec![Paint::solid(Color::from_hex("336699").unwrap())];
|
||||
node.effects = vec![
|
||||
Effect { kind: EffectKind::DropShadow(shadow.clone()), visible: true },
|
||||
Effect { kind: EffectKind::LayerBlur(Blur { radius: 4.0, ..Blur::default() }), visible: true },
|
||||
];
|
||||
let svg = crate::export_svg(&d, &[r], &crate::SvgOptions::default()).unwrap();
|
||||
let mut d2 = Document::new("back");
|
||||
let p2 = d2.first_page().unwrap();
|
||||
let f = crate::import_svg(&mut d2, svg.as_bytes(), p2, 0.0, 0.0, "x").unwrap();
|
||||
let back = d2.subtree(f).into_iter().filter_map(|i| d2.node(i)).find(|n| n.kind == NodeKind::Rectangle).unwrap();
|
||||
let [EffectKind::DropShadow(s), EffectKind::LayerBlur(b)] = &back.effects.iter().map(|e| e.kind.clone()).collect::<Vec<_>>()[..] else {
|
||||
panic!("{:?}", back.effects)
|
||||
};
|
||||
assert_eq!((s.offset, s.radius, b.radius), (shadow.offset, shadow.radius, 4.0));
|
||||
let c = s.color;
|
||||
assert!((c.r - 0.1).abs() < 1e-3 && (c.g - 0.2).abs() < 1e-3 && (c.b - 0.3).abs() < 1e-3 && (c.a - 0.4).abs() < 1e-3, "{c:?}");
|
||||
}
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
//! Hostile input: malformed, extreme and self-referencing SVG, and the corpus mangled byte by
|
||||
//! byte. The importer never panics, and whatever it makes is a valid document that renders.
|
||||
|
||||
use figcraft_doc::Document;
|
||||
|
||||
use super::import_svg;
|
||||
|
||||
/// Import `data`; when it parses, check the document and draw it.
|
||||
fn survive(data: &[u8]) {
|
||||
let mut d = Document::new("hostile");
|
||||
let Some(p) = d.first_page() else { return };
|
||||
if let Ok(f) = import_svg(&mut d, data, p, 0.0, 0.0, "x") {
|
||||
assert!(d.validate().is_empty(), "{:?}", d.validate());
|
||||
let view = figcraft_render::View { origin: (0.0, 0.0), scale: 0.5, width: 64, height: 64 };
|
||||
if let Some(mut pix) = figcraft_render::Pixmap::new(64, 64) {
|
||||
figcraft_render::render_nodes_into(&d, &[f], &view, &mut pix);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn svg(body: &str) -> Vec<u8> {
|
||||
format!(r##"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100" height="100">{body}</svg>"##)
|
||||
.into_bytes()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_documents() {
|
||||
for data in [
|
||||
&b""[..],
|
||||
b"<",
|
||||
b"<svg",
|
||||
b"<svg>",
|
||||
b"<svg xmlns=\"http://www.w3.org/2000/svg\"><rect width=\"10\" height=\"10\">",
|
||||
b"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"-5\" height=\"0\"/>",
|
||||
b"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 0 0\"><rect width=\"1\" height=\"1\"/></svg>",
|
||||
b"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"nan nan inf -inf\" width=\"1e39\" height=\"1e39\"/>",
|
||||
b"<?xml version=\"1.0\"?><!DOCTYPE svg [<!ENTITY a \"&a;\">]><svg xmlns=\"http://www.w3.org/2000/svg\">&a;</svg>",
|
||||
b"<svg:svg xmlns:svg=\"http://www.w3.org/2000/svg\" width=\"10\" height=\"10\"><svg:rect width=\"5\" height=\"5\"/></svg:svg>",
|
||||
b"\xef\xbb\xbf<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"10\" height=\"10\"><circle r=\"3\"/></svg>",
|
||||
b"\xff\xfe\x00<\x00s\x00v\x00g",
|
||||
b"not svg at all",
|
||||
] {
|
||||
survive(data);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extreme_values() {
|
||||
for body in [
|
||||
r##"<rect x="1e38" y="-1e38" width="1e38" height="1e38"/>"##,
|
||||
r##"<rect width="NaN" height="inf"/><circle r="-5"/><ellipse rx="0" ry="1e30"/>"##,
|
||||
r##"<path d="M0 0 L1e38 1e38 L-1e38 0 Z" stroke="red" stroke-width="1e30" stroke-dasharray="0.000001 0.000001"/>"##,
|
||||
r##"<path d="M0 0 A1e30 1e-30 1e9 1 1 10 10 Z M nan 0 L 5 inf"/>"##,
|
||||
r##"<g transform="scale(0)"><rect width="10" height="10"/></g><g transform="matrix(1e30 1e30 1e30 1e30 0 0)"><rect width="10" height="10"/></g>"##,
|
||||
r##"<g transform="rotate(1e30) skewX(90)"><rect width="10" height="10" rx="1e30"/></g>"##,
|
||||
r##"<line x2="10" stroke="black" stroke-width="2" stroke-dashoffset="1e30" stroke-dasharray="3 1 2"/>"##,
|
||||
r##"<filter id="f"><feGaussianBlur stdDeviation="1e9"/></filter><rect width="10" height="10" filter="url(#f)"/>"##,
|
||||
r##"<filter id="f"><feDropShadow dx="1e30" dy="-1e30" stdDeviation="1e20"/></filter><rect width="10" height="10" filter="url(#f)"/>"##,
|
||||
r##"<filter id="f"><feMorphology operator="dilate" radius="1e20"/><feMerge><feMergeNode/><feMergeNode in="SourceGraphic"/></feMerge></filter><rect width="10" height="10" filter="url(#f)"/>"##,
|
||||
r##"<linearGradient id="g"/><rect width="10" height="10" fill="url(#g)"/>"##,
|
||||
r##"<linearGradient id="g" x1="0" x2="0" spreadMethod="repeat"><stop offset="0" stop-color="#000"/><stop offset="1" stop-color="#fff"/></linearGradient><rect width="10" height="10" fill="url(#g)"/>"##,
|
||||
r##"<linearGradient id="g" x2="1e-30" spreadMethod="reflect"><stop offset="0" stop-color="#000"/><stop offset="1" stop-color="#fff"/></linearGradient><rect width="1e30" height="10" fill="url(#g)"/>"##,
|
||||
r##"<radialGradient id="g" r="1e-30" spreadMethod="repeat"><stop offset="0" stop-color="#000"/><stop offset="1"/></radialGradient><rect width="10" height="10" fill="url(#g)"/>"##,
|
||||
r##"<pattern id="p" width="1e-30" height="1e30" patternUnits="userSpaceOnUse"><rect width="1" height="1"/></pattern><rect width="10" height="10" fill="url(#p)"/>"##,
|
||||
r##"<pattern id="p" width="1e30" height="1e30" patternUnits="userSpaceOnUse"><circle r="1e29"/></pattern><rect width="1e30" height="1e30" fill="url(#p)"/>"##,
|
||||
r##"<text x="1e38" y="nan" font-size="1e30" letter-spacing="-1e30">x</text><text font-size="0">y</text>"##,
|
||||
r##"<text><tspan x="0 1 2 3" y="5" dx="1e30" rotate="1e9 nan">abcd</tspan><tspan></tspan></text>"##,
|
||||
r##"<image width="10" height="10" href="data:image/png;base64,iVBORw0KGgoAAAANSU"/><image width="1e30" height="0" href="data:,"/>"##,
|
||||
r##"<image width="10" height="10" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Cimage href='data:image/svg+xml,%253Csvg/%253E'/%3E%3C/svg%3E"/>"##,
|
||||
r##"<style>* { fill: url(#nope); stroke-width: -3; } rect { transform: scale(-1e30) }</style><rect width="10" height="10"/>"##,
|
||||
] {
|
||||
survive(&svg(body));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn references_to_themselves() {
|
||||
for body in [
|
||||
r##"<use id="u" href="#u"/>"##,
|
||||
r##"<g id="a"><use href="#b"/></g><g id="b"><use href="#a"/></g>"##,
|
||||
r##"<clipPath id="c" clip-path="url(#c)"><rect width="5" height="5"/></clipPath><rect width="10" height="10" clip-path="url(#c)"/>"##,
|
||||
r##"<mask id="m" mask="url(#m)"><rect width="5" height="5" fill="white"/></mask><rect width="10" height="10" mask="url(#m)"/>"##,
|
||||
r##"<pattern id="p" width="5" height="5" patternUnits="userSpaceOnUse" href="#p"><rect width="2" height="2" fill="url(#p)"/></pattern><rect width="10" height="10" fill="url(#p)"/>"##,
|
||||
r##"<linearGradient id="g" href="#h"/><linearGradient id="h" href="#g"/><rect width="10" height="10" fill="url(#g)"/>"##,
|
||||
r##"<filter id="f" href="#f"><feImage href="#r"/></filter><rect id="r" width="10" height="10" filter="url(#f)"/>"##,
|
||||
] {
|
||||
survive(&svg(body));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deep_and_wide_documents_are_bounded() {
|
||||
survive(&svg(&format!("{}<rect width=\"1\" height=\"1\"/>{}", "<g>".repeat(3000), "</g>".repeat(3000))));
|
||||
survive(&svg(&format!("{}<rect width=\"1\" height=\"1\"/>{}", "<g transform=\"scale(1.01)\" opacity=\"0.99\">".repeat(200), "</g>".repeat(200))));
|
||||
let many: String = (0..5000).map(|i| format!("<rect x=\"{}\" width=\"1\" height=\"1\"/>", i % 100)).collect();
|
||||
survive(&svg(&many));
|
||||
let prims: String = (0..500).map(|i| format!("<feOffset dx=\"{i}\"/>")).collect();
|
||||
survive(&svg(&format!(r##"<filter id="f">{prims}</filter><rect width="10" height="10" filter="url(#f)"/>"##)));
|
||||
let dashes: String = (0..10_000).map(|i| format!("{} ", i % 7 + 1)).collect();
|
||||
survive(&svg(&format!(r##"<line x2="100" stroke="black" stroke-dasharray="{dashes}"/>"##)));
|
||||
}
|
||||
|
||||
/// A small deterministic generator for the mangling.
|
||||
struct Rng(u64);
|
||||
|
||||
impl Rng {
|
||||
fn next(&mut self) -> u64 {
|
||||
self.0 ^= self.0 << 13;
|
||||
self.0 ^= self.0 >> 7;
|
||||
self.0 ^= self.0 << 17;
|
||||
self.0
|
||||
}
|
||||
fn below(&mut self, n: usize) -> usize {
|
||||
(self.next() % n.max(1) as u64) as usize
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_corpus_mangled_byte_by_byte() {
|
||||
let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/corpus");
|
||||
let files: Vec<Vec<u8>> = std::fs::read_dir(dir).unwrap().filter_map(|e| std::fs::read(e.ok()?.path()).ok()).collect();
|
||||
assert!(files.len() > 10);
|
||||
let mut rng = Rng(0x9e37_79b9_7f4a_7c15);
|
||||
let junk: &[&[u8]] = &[b"<", b">", b"\"", b"-", b"e", b"9999999", b"url(#", b"NaN", b"\0", b"</g>", b"<g transform=\"scale(1e30)\">"];
|
||||
for f in &files {
|
||||
// Cut short at a spread of points.
|
||||
for k in 1..12 {
|
||||
survive(f.get(..f.len() * k / 12).unwrap_or(f));
|
||||
}
|
||||
// Bytes changed, dropped and inserted.
|
||||
for _ in 0..24 {
|
||||
let mut m = f.clone();
|
||||
for _ in 0..1 + rng.below(6) {
|
||||
let at = rng.below(m.len());
|
||||
match rng.below(3) {
|
||||
0 => {
|
||||
if let Some(b) = m.get_mut(at) {
|
||||
*b = (rng.next() & 0xff) as u8;
|
||||
}
|
||||
}
|
||||
1 => {
|
||||
let end = (at + rng.below(16)).min(m.len());
|
||||
m.drain(at..end);
|
||||
}
|
||||
_ => {
|
||||
let j = junk[rng.below(junk.len())];
|
||||
m.splice(at..at, j.iter().copied());
|
||||
}
|
||||
}
|
||||
}
|
||||
survive(&m);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nesting_is_measured_before_parsing() {
|
||||
use super::nesting;
|
||||
assert_eq!(nesting(b"<svg><g><g/><g a='>' b=\"/>\"></g></g></svg>", 99), 3);
|
||||
assert_eq!(nesting(b"<?xml version='1.0'?><!-- <g><g> --><svg><![CDATA[<g><g>]]></svg>", 99), 1);
|
||||
assert_eq!(nesting(b"<svg><g", 99), 2);
|
||||
let deep = format!("{}{}", "<g>".repeat(5000), "</g>".repeat(5000));
|
||||
assert_eq!(nesting(deep.as_bytes(), 512), 513, "stops once past the limit");
|
||||
// Far too deep for the parser's stack: refused.
|
||||
let mut d = Document::new("t");
|
||||
let p = d.first_page().unwrap();
|
||||
assert!(import_svg(&mut d, &svg(&deep), p, 0.0, 0.0, "x").is_err());
|
||||
}
|
||||
|
|
@ -0,0 +1,650 @@
|
|||
//! SVG import: usvg's normalised tree (CSS, `<use>`, units, inheritance and the like resolved) →
|
||||
//! an editable frame.
|
||||
//!
|
||||
//! - Groups keep their `id` as their name and stay groups; the wrappers SVG needs for a transform,
|
||||
//! opacity or filter on a single element merge into that element.
|
||||
//! - Transforms are baked into the layers (groups follow their children, like everywhere else):
|
||||
//! rectangles (with a uniform corner radius), ellipses and lines stay those shapes, rotated or
|
||||
//! flipped as needed; anything else, or anything skewed, becomes a vector.
|
||||
//! - Gradients stay gradients (`gradientTransform`, `reflect`/`repeat`, `href` chains) and patterns
|
||||
//! become image fills ([`paint`]); clip paths
|
||||
//! become vector masks and masks luminance or alpha masks; filters become drop shadows, inner
|
||||
//! shadows and layer blurs ([`fx`]); `mix-blend-mode` and opacity carry over.
|
||||
//! - Text becomes text layers ([`text`]); images become rectangles with an image fill, and an SVG
|
||||
//! image is imported in place.
|
||||
//!
|
||||
//! What can't be represented (filters other than shadows and blurs, rotated repeating patterns,
|
||||
//! nested clip paths) is left out and reported in the import notes. Linked files are never read: only data URIs.
|
||||
|
||||
mod fx;
|
||||
mod paint;
|
||||
mod shape;
|
||||
mod text;
|
||||
|
||||
#[cfg(test)]
|
||||
mod fx_tests;
|
||||
#[cfg(test)]
|
||||
mod hostile_tests;
|
||||
#[cfg(test)]
|
||||
mod paint_tests;
|
||||
#[cfg(test)]
|
||||
mod shape_tests;
|
||||
#[cfg(test)]
|
||||
mod structure_tests;
|
||||
|
||||
use std::collections::{BTreeSet, HashSet};
|
||||
|
||||
use figcraft_color::{BlendMode, ImageScaleMode, Paint};
|
||||
use figcraft_doc::{Document, Effect, MaskType, Node, NodeId, NodeKind, StrokeAlign, StrokeCap, StrokeJoin, VectorNetwork, WindingRule};
|
||||
use figcraft_geom::{Affine, BezPath, Point, Rect, Shape};
|
||||
|
||||
use paint::Target;
|
||||
use shape::{Prim, length_scale, place};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ImportError(pub String);
|
||||
|
||||
impl std::fmt::Display for ImportError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "SVG import failed: {}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// An SVG import: the frame holding it and notes on what couldn't be represented.
|
||||
#[derive(Debug)]
|
||||
pub struct Imported {
|
||||
pub frame: NodeId,
|
||||
pub notes: Vec<String>,
|
||||
}
|
||||
|
||||
pub(crate) fn xf(t: usvg::Transform) -> Affine {
|
||||
Affine::new([f64::from(t.sx), f64::from(t.ky), f64::from(t.kx), f64::from(t.sy), f64::from(t.tx), f64::from(t.ty)])
|
||||
}
|
||||
|
||||
pub(crate) fn to_bez(p: &usvg::tiny_skia_path::Path) -> BezPath {
|
||||
use usvg::tiny_skia_path::PathSegment::*;
|
||||
let pt = |p: usvg::tiny_skia_path::Point| (f64::from(p.x), f64::from(p.y));
|
||||
let mut b = BezPath::new();
|
||||
for s in p.segments() {
|
||||
match s {
|
||||
MoveTo(a) => b.move_to(pt(a)),
|
||||
LineTo(a) => b.line_to(pt(a)),
|
||||
QuadTo(c, a) => b.quad_to(pt(c), pt(a)),
|
||||
CubicTo(c1, c2, a) => b.curve_to(pt(c1), pt(c2), pt(a)),
|
||||
Close => b.close_path(),
|
||||
}
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
pub(crate) fn finite(a: Affine) -> bool {
|
||||
a.as_coeffs().iter().all(|v| v.is_finite())
|
||||
}
|
||||
|
||||
const MAX_NODES: usize = 100_000;
|
||||
const MAX_DEPTH: usize = 128;
|
||||
|
||||
pub(crate) struct Ctx<'a> {
|
||||
pub doc: &'a mut Document,
|
||||
count: usize,
|
||||
/// Groups made for SVG structure rather than a named `<g>`: merged into their only child.
|
||||
anonymous: HashSet<NodeId>,
|
||||
notes: BTreeSet<String>,
|
||||
fonts: Option<text::Fonts>,
|
||||
/// Patterns being drawn into tile images (inside one another).
|
||||
pattern_depth: usize,
|
||||
}
|
||||
|
||||
impl<'a> Ctx<'a> {
|
||||
pub(crate) fn new(doc: &'a mut Document) -> Self {
|
||||
Ctx { doc, count: 0, anonymous: HashSet::new(), notes: BTreeSet::new(), fonts: None, pattern_depth: 0 }
|
||||
}
|
||||
|
||||
pub(crate) fn full(&self) -> bool {
|
||||
self.count >= MAX_NODES
|
||||
}
|
||||
|
||||
pub(crate) fn note(&mut self, s: impl Into<String>) {
|
||||
self.notes.insert(s.into());
|
||||
}
|
||||
|
||||
/// Insert `n` on top of `parent`'s children.
|
||||
pub(crate) fn add(&mut self, n: Node, parent: NodeId) -> Option<NodeId> {
|
||||
if self.full() {
|
||||
return None;
|
||||
}
|
||||
let id = self.doc.insert(n, parent, None).ok()?;
|
||||
self.count += 1;
|
||||
Some(id)
|
||||
}
|
||||
|
||||
pub(crate) fn group(&mut self, parent: NodeId, name: &str, anonymous: bool) -> Option<NodeId> {
|
||||
let n = Node::new(self.doc.alloc_id(), NodeKind::Group, name, 0.0, 0.0);
|
||||
let id = self.add(n, parent)?;
|
||||
if anonymous {
|
||||
self.anonymous.insert(id);
|
||||
}
|
||||
Some(id)
|
||||
}
|
||||
}
|
||||
|
||||
/// A layer name from an SVG `id`: `_xHH_` escapes (written for spaces and punctuation) decoded.
|
||||
pub(crate) fn layer_name(id: &str, fallback: &str) -> String {
|
||||
if id.is_empty() {
|
||||
return fallback.to_string();
|
||||
}
|
||||
let mut out = String::new();
|
||||
let mut rest = id;
|
||||
while let Some(i) = rest.find("_x") {
|
||||
let (head, tail) = rest.split_at(i);
|
||||
out.push_str(head);
|
||||
let hex = tail.get(2..4).filter(|_| tail.get(4..5) == Some("_"));
|
||||
match hex.and_then(|h| u8::from_str_radix(h, 16).ok()).map(char::from) {
|
||||
Some(c) if !c.is_control() => {
|
||||
out.push(c);
|
||||
rest = tail.get(5..).unwrap_or("");
|
||||
}
|
||||
_ => {
|
||||
out.push_str("_x");
|
||||
rest = tail.get(2..).unwrap_or("");
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
fn blend(b: usvg::BlendMode) -> Option<BlendMode> {
|
||||
use usvg::BlendMode as B;
|
||||
Some(match b {
|
||||
B::Normal => return None,
|
||||
B::Multiply => BlendMode::Multiply,
|
||||
B::Screen => BlendMode::Screen,
|
||||
B::Overlay => BlendMode::Overlay,
|
||||
B::Darken => BlendMode::Darken,
|
||||
B::Lighten => BlendMode::Lighten,
|
||||
B::ColorDodge => BlendMode::ColorDodge,
|
||||
B::ColorBurn => BlendMode::ColorBurn,
|
||||
B::HardLight => BlendMode::HardLight,
|
||||
B::SoftLight => BlendMode::SoftLight,
|
||||
B::Difference => BlendMode::Difference,
|
||||
B::Exclusion => BlendMode::Exclusion,
|
||||
B::Hue => BlendMode::Hue,
|
||||
B::Saturation => BlendMode::Saturation,
|
||||
B::Color => BlendMode::Color,
|
||||
B::Luminosity => BlendMode::Luminosity,
|
||||
})
|
||||
}
|
||||
|
||||
/// Convert `g`'s children into `parent`; `base` maps `g`'s tree into the frame.
|
||||
pub(crate) fn children(cx: &mut Ctx, g: &usvg::Group, parent: NodeId, base: Affine, depth: usize) {
|
||||
if depth > MAX_DEPTH {
|
||||
cx.note(format!("layers nested more than {MAX_DEPTH} deep were left out"));
|
||||
return;
|
||||
}
|
||||
for child in g.children() {
|
||||
if cx.full() {
|
||||
cx.note(format!("more than {MAX_NODES} layers: the rest was left out"));
|
||||
return;
|
||||
}
|
||||
match child {
|
||||
usvg::Node::Group(sub) => group(cx, sub, parent, base, depth + 1),
|
||||
usvg::Node::Path(p) => path(cx, p, parent, base),
|
||||
usvg::Node::Image(img) => image(cx, img, img.id(), parent, base, depth + 1),
|
||||
usvg::Node::Text(t) => text::text(cx, t, parent, base, depth + 1),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn group(cx: &mut Ctx, g: &usvg::Group, parent: NodeId, base: Affine, depth: usize) {
|
||||
let world = base * xf(g.abs_transform());
|
||||
if depth > MAX_DEPTH || !finite(world) {
|
||||
return;
|
||||
}
|
||||
if g.mask().is_some_and(|m| !m.root().has_children()) {
|
||||
// An empty mask hides everything.
|
||||
return;
|
||||
}
|
||||
// `<image id>` arrives as a group (placing it) named by the id around an unnamed image.
|
||||
if let [usvg::Node::Image(img)] = g.children()
|
||||
&& img.id().is_empty()
|
||||
&& g.clip_path().is_none()
|
||||
&& g.mask().is_none()
|
||||
&& g.filters().is_empty()
|
||||
&& g.opacity().get() >= 1.0
|
||||
&& g.blend_mode() == usvg::BlendMode::Normal
|
||||
{
|
||||
image(cx, img, g.id(), parent, base, depth + 1);
|
||||
return;
|
||||
}
|
||||
let effects: Vec<Effect> = if g.filters().is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
fx::effects(g.filters(), world).unwrap_or_else(|| {
|
||||
let ids: Vec<&str> = g.filters().iter().map(|f| f.id()).collect();
|
||||
cx.note(format!("filter {} isn't a shadow or blur: the layer is drawn without it", ids.join(", ")));
|
||||
Vec::new()
|
||||
})
|
||||
};
|
||||
let clip = g.clip_path().filter(|c| !g.filters().is_empty() || !clip_is_noop(c, g, base));
|
||||
let mask = g.mask();
|
||||
let masked = clip.is_some() || mask.is_some();
|
||||
let name = layer_name(g.id(), if masked { "Mask group" } else { "Group" });
|
||||
let Some(outer) = cx.group(parent, &name, g.id().is_empty()) else { return };
|
||||
if let Some(n) = cx.doc.node_mut(outer) {
|
||||
n.opacity = g.opacity().get();
|
||||
n.blend = if let Some(b) = blend(g.blend_mode()) {
|
||||
b
|
||||
} else if g.isolate() {
|
||||
BlendMode::Normal
|
||||
} else {
|
||||
BlendMode::PassThrough
|
||||
};
|
||||
}
|
||||
let mut inner = outer;
|
||||
if let Some(c) = clip {
|
||||
mask_layer(cx, c.root(), inner, world * xf(c.transform()), MaskType::Vector, depth);
|
||||
if c.clip_path().is_some() {
|
||||
cx.note("a clip path clipped by another clips by the first only");
|
||||
}
|
||||
if mask.is_some()
|
||||
&& let Some(id) = cx.group(inner, "Mask group", true)
|
||||
{
|
||||
inner = id;
|
||||
}
|
||||
}
|
||||
if let Some(m) = mask {
|
||||
let kind = if m.kind() == usvg::MaskType::Alpha { MaskType::Alpha } else { MaskType::Luminance };
|
||||
mask_layer(cx, m.root(), inner, world, kind, depth);
|
||||
if m.mask().is_some() {
|
||||
cx.note("a mask masked by another masks by the first only");
|
||||
}
|
||||
}
|
||||
// Filters apply before clipping and masking: on the content.
|
||||
let content = if masked && !effects.is_empty() { cx.group(inner, "Group", true).unwrap_or(inner) } else { inner };
|
||||
if let Some(n) = cx.doc.node_mut(content) {
|
||||
n.effects = effects;
|
||||
}
|
||||
children(cx, g, content, base, depth);
|
||||
finish(cx, content);
|
||||
if inner != content {
|
||||
finish(cx, inner);
|
||||
}
|
||||
if outer != inner {
|
||||
finish(cx, outer);
|
||||
}
|
||||
}
|
||||
|
||||
/// A clip path or mask's content as the mask layer at the bottom of `parent`.
|
||||
fn mask_layer(cx: &mut Ctx, root: &usvg::Group, parent: NodeId, base: Affine, kind: MaskType, depth: usize) {
|
||||
let Some(id) = cx.group(parent, if kind == MaskType::Vector { "Clip path" } else { "Mask" }, true) else { return };
|
||||
if let Some(n) = cx.doc.node_mut(id) {
|
||||
n.is_mask = true;
|
||||
n.mask_type = kind;
|
||||
}
|
||||
children(cx, root, id, base, depth + 1);
|
||||
finish(cx, id);
|
||||
}
|
||||
|
||||
/// Whether clip path `c` on `g` clips nothing: one rectangle, upright, around all of `g`'s content.
|
||||
fn clip_is_noop(c: &usvg::ClipPath, g: &usvg::Group, base: Affine) -> bool {
|
||||
let [usvg::Node::Path(p)] = c.root().children() else { return false };
|
||||
if c.clip_path().is_some() {
|
||||
return false;
|
||||
}
|
||||
let Some(Prim::Rect { rect, radius }) = shape::recognize(&to_bez(p.data()), true) else { return false };
|
||||
let cw = base * xf(g.abs_transform()) * xf(c.transform()) * xf(p.abs_transform());
|
||||
let [_, b, cc, ..] = cw.as_coeffs();
|
||||
if radius > 0.0 || b.abs() > 1e-9 || cc.abs() > 1e-9 {
|
||||
return false;
|
||||
}
|
||||
let clip = figcraft_geom::transform_rect_bbox(cw, rect).inflate(0.01, 0.01);
|
||||
let r = g.abs_stroke_bounding_box();
|
||||
let content =
|
||||
figcraft_geom::transform_rect_bbox(base, Rect::new(f64::from(r.left()), f64::from(r.top()), f64::from(r.right()), f64::from(r.bottom())));
|
||||
clip.contains(Point::new(content.x0, content.y0)) && clip.contains(Point::new(content.x1, content.y1))
|
||||
}
|
||||
|
||||
/// Done filling group `id`: drop it when it holds nothing, merge an anonymous one into its only
|
||||
/// child, else fit it to its children.
|
||||
pub(crate) fn finish(cx: &mut Ctx, id: NodeId) {
|
||||
let Some(n) = cx.doc.node(id) else { return };
|
||||
if !matches!(n.kind, NodeKind::Group) {
|
||||
return;
|
||||
}
|
||||
if !n.children.iter().any(|c| cx.doc.node(*c).is_some_and(|k| !k.is_mask)) {
|
||||
let _ = cx.doc.remove(id);
|
||||
return;
|
||||
}
|
||||
if let &[child] = n.children.as_slice()
|
||||
&& cx.anonymous.contains(&id)
|
||||
&& merge_into_child(cx, id, child)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let _ = cx.doc.fit_to_children(id);
|
||||
}
|
||||
|
||||
/// Move anonymous group `id`'s opacity, blend, effects and mask role onto its only `child` and put
|
||||
/// the child in its place. `false` (nothing changed) when they don't combine.
|
||||
fn merge_into_child(cx: &mut Ctx, id: NodeId, child: NodeId) -> bool {
|
||||
let (Some(g), Some(c)) = (cx.doc.node(id), cx.doc.node(child)) else { return false };
|
||||
let plain = |b: BlendMode| matches!(b, BlendMode::Normal | BlendMode::PassThrough);
|
||||
if c.is_mask || (!g.effects.is_empty() && !c.effects.is_empty()) || (!plain(g.blend) && !plain(c.blend)) {
|
||||
return false;
|
||||
}
|
||||
let (opacity, blend, effects, is_mask, mask_type) = (g.opacity, g.blend, g.effects.clone(), g.is_mask, g.mask_type);
|
||||
let (Some(parent), Some(at)) = (g.parent, cx.doc.index_in_parent(id)) else { return false };
|
||||
if cx.doc.reparent(child, parent, Some(at)).is_err() {
|
||||
return false;
|
||||
}
|
||||
let _ = cx.doc.remove(id);
|
||||
cx.anonymous.remove(&id);
|
||||
if let Some(c) = cx.doc.node_mut(child) {
|
||||
c.opacity *= opacity;
|
||||
if !plain(blend) {
|
||||
c.blend = blend;
|
||||
}
|
||||
if !effects.is_empty() {
|
||||
c.effects = effects;
|
||||
}
|
||||
if is_mask {
|
||||
c.is_mask = true;
|
||||
c.mask_type = mask_type;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn path(cx: &mut Ctx, p: &usvg::Path, parent: NodeId, base: Affine) {
|
||||
let world = base * xf(p.abs_transform());
|
||||
if !p.is_visible() || !finite(world) {
|
||||
return;
|
||||
}
|
||||
let local = to_bez(p.data());
|
||||
let (fill, stroke) = (p.fill(), p.stroke());
|
||||
let shaped = shape::recognize(&local, stroke.is_none()).and_then(|prim| primitive(prim, world));
|
||||
let (kind, transform, w, h, radius) = match shaped {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
let mut path = local;
|
||||
path.apply_affine(world);
|
||||
let bb = path.bounding_box();
|
||||
if ![bb.x0, bb.y0, bb.x1, bb.y1].iter().all(|v| v.is_finite()) {
|
||||
return;
|
||||
}
|
||||
path.apply_affine(Affine::translate((-bb.x0, -bb.y0)));
|
||||
let mut network = VectorNetwork::from_path(&path);
|
||||
let rule = if fill.is_some_and(|f| f.rule() == usvg::FillRule::EvenOdd) { WindingRule::EvenOdd } else { WindingRule::NonZero };
|
||||
for r in &mut network.regions {
|
||||
r.winding = rule;
|
||||
}
|
||||
if fill.is_none() {
|
||||
network.regions.clear();
|
||||
}
|
||||
(NodeKind::Vector { network }, Affine::translate((bb.x0, bb.y0)), bb.width(), bb.height(), 0.0)
|
||||
}
|
||||
};
|
||||
let fallback = match kind {
|
||||
NodeKind::Rectangle => "Rectangle",
|
||||
NodeKind::Ellipse { .. } => "Ellipse",
|
||||
NodeKind::Line => "Line",
|
||||
_ => "Vector",
|
||||
};
|
||||
let mut n = Node::new(cx.doc.alloc_id(), kind, layer_name(p.id(), fallback), w, h);
|
||||
n.transform = transform;
|
||||
n.corners.radii = [radius; 4];
|
||||
let target = Target { to_node: transform.inverse() * world, size: (w, h) };
|
||||
n.fills = fill.and_then(|f| paint::paint(cx, f.paint(), f.opacity().get(), target)).into_iter().collect();
|
||||
n.strokes.clear();
|
||||
if let Some(s) = stroke {
|
||||
n.strokes = paint::paint(cx, s.paint(), s.opacity().get(), target).into_iter().collect();
|
||||
n.stroke = stroke_props(s, length_scale(world));
|
||||
// A stroke painted under the fill shows only its outer half.
|
||||
if p.paint_order() == usvg::PaintOrder::StrokeAndFill && !n.fills.is_empty() && figcraft_doc::geometry::is_closed(&n) {
|
||||
n.stroke.align = StrokeAlign::Outside;
|
||||
n.stroke.weight /= 2.0;
|
||||
}
|
||||
}
|
||||
cx.add(n, parent);
|
||||
}
|
||||
|
||||
/// A recognised primitive placed by `world`: its kind, transform, size and corner radius. `None`
|
||||
/// when `world` would skew it (or stretch its rounded corners).
|
||||
fn primitive(prim: Prim, world: Affine) -> Option<(NodeKind, Affine, f64, f64, f64)> {
|
||||
match prim {
|
||||
Prim::Rect { rect, radius } => {
|
||||
let (t, w, h) = place(world, rect.origin(), rect.width(), rect.height())?;
|
||||
let (fx, fy) = (w / rect.width(), h / rect.height());
|
||||
if radius > 0.0 && (fx - fy).abs() > 1e-6 * fx.max(fy) {
|
||||
return None;
|
||||
}
|
||||
Some((NodeKind::Rectangle, t, w, h, radius * fx))
|
||||
}
|
||||
Prim::Ellipse(rect) => {
|
||||
let (t, w, h) = place(world, rect.origin(), rect.width(), rect.height())?;
|
||||
Some((NodeKind::Ellipse { start: 0.0, sweep: std::f64::consts::TAU, inner: 0.0 }, t, w, h, 0.0))
|
||||
}
|
||||
Prim::Line(a, b) => {
|
||||
let (a, b) = (world * a, world * b);
|
||||
let d = b - a;
|
||||
let len = d.hypot();
|
||||
(len > 1e-9 && len.is_finite()).then(|| (NodeKind::Line, Affine::translate(a.to_vec2()) * Affine::rotate(d.atan2()), len, 0.0, 0.0))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn stroke_props(s: &usvg::Stroke, k: f64) -> figcraft_doc::StrokeProps {
|
||||
let cap = match s.linecap() {
|
||||
usvg::LineCap::Round => StrokeCap::Round,
|
||||
usvg::LineCap::Square => StrokeCap::Square,
|
||||
usvg::LineCap::Butt => StrokeCap::None,
|
||||
};
|
||||
let mut dashes: Vec<f64> = s.dasharray().unwrap_or(&[]).iter().map(|v| f64::from(*v) * k).collect();
|
||||
if !dashes.is_empty() {
|
||||
dashes = dash_offset(&dashes, f64::from(s.dashoffset()) * k, cap == StrokeCap::None).unwrap_or(dashes);
|
||||
}
|
||||
figcraft_doc::StrokeProps {
|
||||
weight: f64::from(s.width().get()) * k,
|
||||
align: StrokeAlign::Center,
|
||||
cap_start: cap,
|
||||
cap_end: cap,
|
||||
join: match s.linejoin() {
|
||||
usvg::LineJoin::Round => StrokeJoin::Round,
|
||||
usvg::LineJoin::Bevel => StrokeJoin::Bevel,
|
||||
_ => StrokeJoin::Miter,
|
||||
},
|
||||
miter_limit: f64::from(s.miterlimit().get()),
|
||||
dashes,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// The dash pattern started `offset` into it (dash patterns have no offset of their own). `None`
|
||||
/// when it can't be expressed: an offset into a gap needs a zero-length dash first, which only
|
||||
/// draws nothing with butt caps.
|
||||
pub(crate) fn dash_offset(dashes: &[f64], offset: f64, butt: bool) -> Option<Vec<f64>> {
|
||||
let total: f64 = dashes.iter().sum();
|
||||
if !(total > 0.0 && offset.is_finite()) || dashes.len() % 2 == 1 || dashes.iter().any(|d| *d < 0.0) {
|
||||
return None;
|
||||
}
|
||||
let mut o = offset.rem_euclid(total);
|
||||
if o < 1e-9 {
|
||||
return Some(dashes.to_vec());
|
||||
}
|
||||
for (i, d) in dashes.iter().enumerate() {
|
||||
if o < *d {
|
||||
let mut out = Vec::with_capacity(dashes.len() + 2);
|
||||
if i % 2 == 1 {
|
||||
if !butt {
|
||||
return None;
|
||||
}
|
||||
out.push(0.0);
|
||||
}
|
||||
out.push(d - o);
|
||||
out.extend(dashes.get(i + 1..).unwrap_or(&[]));
|
||||
out.extend(dashes.get(..i).unwrap_or(&[]));
|
||||
out.push(o);
|
||||
if out.len() % 2 == 1 {
|
||||
out.push(0.0);
|
||||
}
|
||||
return Some(out);
|
||||
}
|
||||
o -= d;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn image(cx: &mut Ctx, img: &usvg::Image, id: &str, parent: NodeId, base: Affine, depth: usize) {
|
||||
let world = base * xf(img.abs_transform());
|
||||
if !img.is_visible() || !finite(world) {
|
||||
return;
|
||||
}
|
||||
let bytes = match img.kind() {
|
||||
usvg::ImageKind::PNG(b) | usvg::ImageKind::JPEG(b) | usvg::ImageKind::GIF(b) | usvg::ImageKind::WEBP(b) => b.clone(),
|
||||
usvg::ImageKind::SVG(tree) => {
|
||||
// A nested SVG document: imported in place.
|
||||
if let Some(g) = cx.group(parent, &layer_name(id, "Image"), id.is_empty()) {
|
||||
children(cx, tree.root(), g, world, depth + 1);
|
||||
finish(cx, g);
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
let size = img.size();
|
||||
let (iw, ih) = (f64::from(size.width()), f64::from(size.height()));
|
||||
let (transform, w, h) = place(world, Point::ZERO, iw, ih).unwrap_or((world, iw, ih));
|
||||
let hash = add_image(cx.doc, bytes, iw, ih);
|
||||
let mut n = Node::new(cx.doc.alloc_id(), NodeKind::Rectangle, layer_name(id, "Image"), w, h);
|
||||
n.transform = transform;
|
||||
n.fills = vec![Paint::image(hash, ImageScaleMode::Fill)];
|
||||
cx.add(n, parent);
|
||||
}
|
||||
|
||||
/// Store image `bytes` (`w × h` pixels) in `doc`; returns its key.
|
||||
pub(crate) fn add_image(doc: &mut Document, bytes: std::sync::Arc<Vec<u8>>, w: f64, h: f64) -> String {
|
||||
let hash = simple_hash(&bytes);
|
||||
doc.images.insert(hash.clone(), figcraft_doc::ImageData { bytes, width: w.round().max(1.0) as u32, height: h.round().max(1.0) as u32 });
|
||||
hash
|
||||
}
|
||||
|
||||
fn simple_hash(b: &[u8]) -> String {
|
||||
// FNV-1a 128 (two lanes); the format crate re-keys by sha256 on save if needed.
|
||||
let mut h1: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
let mut h2: u64 = 0x8422_2325_cbf2_9ce4;
|
||||
for (i, x) in b.iter().enumerate() {
|
||||
h1 ^= u64::from(*x);
|
||||
h1 = h1.wrapping_mul(0x0100_0000_01b3);
|
||||
h2 ^= u64::from(*x).wrapping_add(i as u64);
|
||||
h2 = h2.wrapping_mul(0x0100_0000_01b3);
|
||||
}
|
||||
format!("{h1:016x}{h2:016x}")
|
||||
}
|
||||
|
||||
/// Deepest element nesting the parser is given: deeper documents would exhaust the stack.
|
||||
const MAX_NESTING: usize = 512;
|
||||
|
||||
/// How deep elements nest in `data`: a scan of its tags (comments, CDATA, declarations and quoted
|
||||
/// attribute values skipped) that stops once past `limit`.
|
||||
pub(crate) fn nesting(data: &[u8], limit: usize) -> usize {
|
||||
let find = |from: usize, pat: &[u8]| -> usize {
|
||||
data.get(from..).and_then(|r| r.windows(pat.len()).position(|w| w == pat)).map_or(data.len(), |p| from + p + pat.len())
|
||||
};
|
||||
let (mut depth, mut max, mut i) = (0usize, 0usize, 0usize);
|
||||
while let Some(off) = data.get(i..).and_then(|r| r.iter().position(|b| *b == b'<')) {
|
||||
i += off;
|
||||
let rest = data.get(i..).unwrap_or(&[]);
|
||||
if rest.starts_with(b"<!--") {
|
||||
i = find(i + 4, b"-->");
|
||||
} else if rest.starts_with(b"<![CDATA[") {
|
||||
i = find(i + 9, b"]]>");
|
||||
} else if rest.starts_with(b"<?") || rest.starts_with(b"<!") {
|
||||
i = find(i + 2, b">");
|
||||
} else if rest.starts_with(b"</") {
|
||||
depth = depth.saturating_sub(1);
|
||||
i = find(i + 2, b">");
|
||||
} else {
|
||||
// A start tag: to its `>` outside quotes; `/>` closes it.
|
||||
let (mut quote, mut j, mut closed) = (None, i + 1, false);
|
||||
while let Some(&c) = data.get(j) {
|
||||
match quote {
|
||||
Some(q) if c == q => quote = None,
|
||||
Some(_) => {}
|
||||
None if c == b'"' || c == b'\'' => quote = Some(c),
|
||||
None if c == b'>' => {
|
||||
closed = data.get(j.wrapping_sub(1)) == Some(&b'/');
|
||||
break;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
j += 1;
|
||||
}
|
||||
if !closed {
|
||||
depth += 1;
|
||||
max = max.max(depth);
|
||||
if max > limit {
|
||||
return max;
|
||||
}
|
||||
}
|
||||
i = j + 1;
|
||||
}
|
||||
}
|
||||
max
|
||||
}
|
||||
|
||||
fn options(data: &[u8]) -> usvg::Options<'static> {
|
||||
let mut o = usvg::Options::default();
|
||||
// Only data URIs: an opened or pasted SVG never pulls other files off the disk.
|
||||
o.image_href_resolver.resolve_string = Box::new(|_, _| None);
|
||||
if data.windows(4).any(|w| w == b"text") {
|
||||
text::prepare(&mut o);
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
/// Whether `text` (from the clipboard, say) is an SVG document: `<svg` after an optional byte
|
||||
/// order mark, XML declaration, comments, doctype and whitespace.
|
||||
pub fn looks_like_svg(text: &str) -> bool {
|
||||
let mut s = text.trim_start_matches('\u{feff}').trim_start();
|
||||
for _ in 0..64 {
|
||||
if s.starts_with("<svg") {
|
||||
return true;
|
||||
}
|
||||
let end = if s.starts_with("<?") {
|
||||
s.find("?>").map(|i| i + 2)
|
||||
} else if s.starts_with("<!--") {
|
||||
s.find("-->").map(|i| i + 3)
|
||||
} else if s.starts_with("<!") {
|
||||
s.find('>').map(|i| i + 1)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
match end.and_then(|e| s.get(e..)) {
|
||||
Some(rest) => s = rest.trim_start(),
|
||||
None => return false,
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Import SVG bytes as a new frame under `parent` at `(x, y)`. Returns the frame id.
|
||||
pub fn import_svg(doc: &mut Document, data: &[u8], parent: NodeId, x: f64, y: f64, name: &str) -> Result<NodeId, ImportError> {
|
||||
import_svg_with_notes(doc, data, parent, x, y, name).map(|i| i.frame)
|
||||
}
|
||||
|
||||
/// [`import_svg`], with notes on what couldn't be represented.
|
||||
pub fn import_svg_with_notes(doc: &mut Document, data: &[u8], parent: NodeId, x: f64, y: f64, name: &str) -> Result<Imported, ImportError> {
|
||||
if nesting(data, MAX_NESTING) > MAX_NESTING {
|
||||
return Err(ImportError(format!("elements nested more than {MAX_NESTING} deep")));
|
||||
}
|
||||
let tree = usvg::Tree::from_data(data, &options(data)).map_err(|e| ImportError(e.to_string()))?;
|
||||
let size = tree.size();
|
||||
let frame =
|
||||
doc.create(parent, NodeKind::Frame, name, x, y, f64::from(size.width()), f64::from(size.height())).map_err(|e| ImportError(e.to_string()))?;
|
||||
if let Some(f) = doc.node_mut(frame) {
|
||||
f.fills.clear();
|
||||
}
|
||||
let mut cx = Ctx::new(doc);
|
||||
children(&mut cx, tree.root(), frame, Affine::IDENTITY, 0);
|
||||
Ok(Imported { frame, notes: cx.notes.into_iter().collect() })
|
||||
}
|
||||
|
|
@ -0,0 +1,264 @@
|
|||
//! SVG paints → FigCraft paints: colours; gradients as editable gradients with their handles
|
||||
//! placed exactly (any `gradientTransform`, skewed or not), `reflect` and `repeat` spreads baked
|
||||
//! into the stops across the layer's box and a focal point as the centre; patterns as image fills.
|
||||
//! A pattern of one image becomes that image (fill, fit or cropped when it shows once, tiled when
|
||||
//! it repeats); any other pattern is drawn into a tile image first.
|
||||
|
||||
use figcraft_color::{Color, ColorStop, Gradient, GradientKind, ImagePaint, ImageScaleMode, Paint, PaintKind};
|
||||
use figcraft_doc::{Document, NodeKind};
|
||||
use figcraft_geom::{Affine, Point, Rect, Vec2};
|
||||
|
||||
use super::shape::length_scale;
|
||||
use super::{Ctx, add_image, children, finite, xf};
|
||||
|
||||
/// Most periods a reflected or repeated gradient is unrolled into (more pads instead).
|
||||
const MAX_PERIODS: f64 = 32.0;
|
||||
|
||||
pub(crate) fn color(c: usvg::Color, alpha: f32) -> Color {
|
||||
Color::new(f32::from(c.red) / 255.0, f32::from(c.green) / 255.0, f32::from(c.blue) / 255.0, alpha.clamp(0.0, 1.0))
|
||||
}
|
||||
|
||||
/// Where a paint lands: `to_node` maps the paint's user space into the layer's local space, whose
|
||||
/// box is `size`.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct Target {
|
||||
pub to_node: Affine,
|
||||
pub size: (f64, f64),
|
||||
}
|
||||
|
||||
impl Target {
|
||||
/// Node-local → the unit box the gradient handles live in.
|
||||
fn unit(&self, p: Point) -> (f64, f64) {
|
||||
(p.x / self.size.0.max(1e-6), p.y / self.size.1.max(1e-6))
|
||||
}
|
||||
}
|
||||
|
||||
/// A usvg paint with `opacity`, for a layer at `t`. `None` when it paints nothing.
|
||||
pub(crate) fn paint(cx: &mut Ctx, p: &usvg::Paint, opacity: f32, t: Target) -> Option<Paint> {
|
||||
let mut out = match p {
|
||||
usvg::Paint::Color(c) => Paint::solid(color(*c, 1.0)),
|
||||
usvg::Paint::LinearGradient(g) => {
|
||||
let m = t.to_node * xf(g.transform());
|
||||
let (p1, p2) = (Point::new(f64::from(g.x1()), f64::from(g.y1())), Point::new(f64::from(g.x2()), f64::from(g.y2())));
|
||||
let d = p2 - p1;
|
||||
// Colour is constant across the perpendicular in gradient space: that's the third handle.
|
||||
let frame = [m * p1, m * p2, m * (p1 + Vec2::new(-d.y, d.x))];
|
||||
Paint::gradient(spread(GradientKind::Linear, frame, stops(g.stops()), g.spread_method(), t)?)
|
||||
}
|
||||
usvg::Paint::RadialGradient(g) => {
|
||||
let m = t.to_node * xf(g.transform());
|
||||
let r = f64::from(g.r().get());
|
||||
// The focal point is the brightest spot: centre the gradient there.
|
||||
let c = Point::new(f64::from(g.fx()), f64::from(g.fy()));
|
||||
let frame = [m * c, m * (c + Vec2::new(r, 0.0)), m * (c + Vec2::new(0.0, r))];
|
||||
Paint::gradient(spread(GradientKind::Radial, frame, stops(g.stops()), g.spread_method(), t)?)
|
||||
}
|
||||
usvg::Paint::Pattern(pat) => pattern(cx, pat, t)?,
|
||||
};
|
||||
out.opacity *= opacity.clamp(0.0, 1.0);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn stops(s: &[usvg::Stop]) -> Vec<ColorStop> {
|
||||
s.iter().map(|s| ColorStop { position: s.offset().get(), color: color(s.color(), s.opacity().get()) }).collect()
|
||||
}
|
||||
|
||||
/// The gradient with handles `frame` (node space: origin, the `u = 1` end, the `v = 1` end), its
|
||||
/// spread unrolled over the layer's box.
|
||||
fn spread(kind: GradientKind, frame: [Point; 3], stops: Vec<ColorStop>, method: usvg::SpreadMethod, t: Target) -> Option<Gradient> {
|
||||
let [o, a, b] = frame;
|
||||
if ![o, a, b].iter().all(|p| p.x.is_finite() && p.y.is_finite()) || stops.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let (lo, hi) = match method {
|
||||
usvg::SpreadMethod::Pad => (0.0, 1.0),
|
||||
_ => periods(kind, frame, t).unwrap_or((0.0, 1.0)),
|
||||
};
|
||||
let reflect = method == usvg::SpreadMethod::Reflect;
|
||||
let mut all = Vec::new();
|
||||
let n = hi - lo;
|
||||
let mut k = lo;
|
||||
while k < hi {
|
||||
let flip = reflect && (k as i64).rem_euclid(2) == 1;
|
||||
let period: Vec<ColorStop> =
|
||||
if flip { stops.iter().rev().map(|s| ColorStop { position: 1.0 - s.position, color: s.color }).collect() } else { stops.clone() };
|
||||
all.extend(period.into_iter().map(|s| ColorStop { position: ((k - lo + f64::from(s.position)) / n) as f32, color: s.color }));
|
||||
k += 1.0;
|
||||
}
|
||||
let (u, v) = (a - o, b - o);
|
||||
let (o2, a2, b2) = match kind {
|
||||
// Linear: the axis stretches over the periods; the perpendicular stays.
|
||||
GradientKind::Linear => (o + u * lo, o + u * hi, o + u * lo + v),
|
||||
_ => (o, o + u * hi, o + v * hi),
|
||||
};
|
||||
Some(Gradient { kind, stops: all, from: t.unit(o2), to: t.unit(a2), width: t.unit(b2) })
|
||||
}
|
||||
|
||||
/// The whole periods (`lo..hi`) a reflected or repeated gradient needs to cover the layer's box.
|
||||
fn periods(kind: GradientKind, [o, a, b]: [Point; 3], t: Target) -> Option<(f64, f64)> {
|
||||
let inv = Affine::new([a.x - o.x, a.y - o.y, b.x - o.x, b.y - o.y, o.x, o.y]);
|
||||
if inv.determinant().abs() < 1e-12 {
|
||||
return None;
|
||||
}
|
||||
let inv = inv.inverse();
|
||||
let (w, h) = t.size;
|
||||
let corners = [(0.0, 0.0), (w, 0.0), (0.0, h), (w, h)].map(|(x, y)| inv * Point::new(x, y));
|
||||
let (lo, hi) = match kind {
|
||||
GradientKind::Linear => corners.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(l, h), p| (l.min(p.x), h.max(p.x))),
|
||||
_ => (0.0, corners.iter().map(|p| p.to_vec2().hypot()).fold(0.0, f64::max)),
|
||||
};
|
||||
let (lo, hi) = (lo.floor().min(0.0), hi.ceil().max(1.0));
|
||||
(lo.is_finite() && hi.is_finite() && hi - lo <= MAX_PERIODS).then_some((lo, hi))
|
||||
}
|
||||
|
||||
/// Largest side of a pattern tile drawn into an image, in pixels.
|
||||
const MAX_TILE: f64 = 2048.0;
|
||||
|
||||
/// A pattern as an image paint (see the module docs). `None` when it shows nothing or can't be
|
||||
/// placed (a rotated, skewed or spaced-out repeating image).
|
||||
fn pattern(cx: &mut Ctx, pat: &usvg::Pattern, t: Target) -> Option<Paint> {
|
||||
let r = pat.rect();
|
||||
let (rw, rh) = (f64::from(r.width()), f64::from(r.height()));
|
||||
let tile = t.to_node * xf(pat.transform()) * Affine::translate((f64::from(r.x()), f64::from(r.y())));
|
||||
if !finite(tile) || tile.determinant().abs() < 1e-12 {
|
||||
return None;
|
||||
}
|
||||
let tile_rect = Rect::new(0.0, 0.0, rw, rh).inflate(1e-3, 1e-3);
|
||||
// One image inside the tile is used as it is; anything else is drawn into a tile image.
|
||||
let lone = lone_image(pat.root(), Affine::IDENTITY, 0).and_then(|(img, m)| {
|
||||
let bytes = match img.kind() {
|
||||
usvg::ImageKind::PNG(b) | usvg::ImageKind::JPEG(b) | usvg::ImageKind::GIF(b) | usvg::ImageKind::WEBP(b) => b.clone(),
|
||||
usvg::ImageKind::SVG(_) => return None,
|
||||
};
|
||||
let s = img.size();
|
||||
let (iw, ih) = (f64::from(s.width()), f64::from(s.height()));
|
||||
let b = figcraft_geom::transform_rect_bbox(m, Rect::new(0.0, 0.0, iw, ih));
|
||||
(tile_rect.contains(b.origin()) && tile_rect.contains(Point::new(b.x1, b.y1))).then(|| (add_image(cx.doc, bytes, iw, ih), iw, ih, m))
|
||||
});
|
||||
let (hash, iw, ih, content) = match lone {
|
||||
Some(l) => l,
|
||||
None => render_tile(cx, pat, tile)?,
|
||||
};
|
||||
let image = figcraft_geom::transform_rect_bbox(content, Rect::new(0.0, 0.0, iw, ih));
|
||||
let node = figcraft_geom::transform_rect_bbox(tile.inverse(), Rect::new(0.0, 0.0, t.size.0, t.size.1));
|
||||
// The copies of the image (one per tile) that show in the layer's box.
|
||||
let copies = |lo: f64, hi: f64, ilo: f64, ihi: f64, period: f64| -> (f64, f64) {
|
||||
let eps = 1e-6;
|
||||
(((lo - ihi) / period + eps).ceil(), ((hi - ilo) / period - eps).floor())
|
||||
};
|
||||
let (kx, ky) = (copies(node.x0, node.x1, image.x0, image.x1, rw), copies(node.y0, node.y1, image.y0, image.y1, rh));
|
||||
if !(kx.0 <= kx.1 && ky.0 <= ky.1) {
|
||||
return None;
|
||||
}
|
||||
let mut ip = ImagePaint { image: hash, scale_mode: ImageScaleMode::Fill, scale: 1.0, rotation: 0, crop: None, filters: Default::default() };
|
||||
if kx.0 == kx.1 && ky.0 == ky.1 {
|
||||
// Shows once: fill, fit or a crop placing it exactly.
|
||||
let m = tile * Affine::translate((kx.0 * rw, ky.0 * rh)) * content;
|
||||
let (w, h) = t.size;
|
||||
let same = |mode: ImageScaleMode| {
|
||||
let ts = figcraft_render::paint::image_transform(&ImagePaint { scale_mode: mode, ..ip.clone() }, iw, ih, w, h);
|
||||
let k = xf(ts).as_coeffs();
|
||||
m.as_coeffs().iter().zip(k).all(|(a, b)| (a - b).abs() <= 1e-3 * (1.0 + a.abs()))
|
||||
};
|
||||
if let Some(mode) = [ImageScaleMode::Fill, ImageScaleMode::Fit].into_iter().find(|m| same(*m)) {
|
||||
ip.scale_mode = mode;
|
||||
} else {
|
||||
let crop = Affine::scale_non_uniform(1.0 / iw.max(1e-9), 1.0 / ih.max(1e-9)) * m.inverse() * Affine::scale_non_uniform(w, h);
|
||||
ip.scale_mode = ImageScaleMode::Crop;
|
||||
ip.crop = Some(crop.as_coeffs());
|
||||
}
|
||||
} else {
|
||||
// Repeats: tiles, when the image is the whole tile, upright and evenly scaled.
|
||||
let m = tile * content;
|
||||
let [a, b, c, d, e, f] = m.as_coeffs();
|
||||
let whole = (image.width() - rw).abs() < 1e-3 * rw && (image.height() - rh).abs() < 1e-3 * rh;
|
||||
if !(whole && b.abs() < 1e-9 && c.abs() < 1e-9 && a > 0.0 && (a - d).abs() < 1e-6 * a) {
|
||||
cx.note("a rotated, skewed or spaced-out pattern was left out");
|
||||
return None;
|
||||
}
|
||||
let phase = |v: f64, period: f64| {
|
||||
let p = v.rem_euclid(period) / period;
|
||||
p.min(1.0 - p)
|
||||
};
|
||||
if phase(e, iw * a) > 1e-3 || phase(f, ih * a) > 1e-3 {
|
||||
cx.note("a pattern's offset was dropped: its tiles start at the layer's corner");
|
||||
}
|
||||
ip.scale_mode = ImageScaleMode::Tile;
|
||||
ip.scale = a;
|
||||
}
|
||||
let mut out = Paint::image(String::new(), ImageScaleMode::Fill);
|
||||
out.kind = PaintKind::Image(ip);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// The only thing under `g` when that's a raster image (through plain groups), with its transform
|
||||
/// (composed from the groups' own transforms: see [`unstale`]).
|
||||
fn lone_image(g: &usvg::Group, m: Affine, depth: usize) -> Option<(&usvg::Image, Affine)> {
|
||||
match g.children() {
|
||||
[usvg::Node::Image(i)] if i.is_visible() => Some((i.as_ref(), m)),
|
||||
[usvg::Node::Group(sub)]
|
||||
if depth < 16 && sub.opacity().get() >= 1.0 && sub.clip_path().is_none() && sub.mask().is_none() && sub.filters().is_empty() =>
|
||||
{
|
||||
lone_image(sub, m * xf(sub.transform()), depth + 1)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A pattern's content and the transform its absolute transforms miss. usvg wraps the content of
|
||||
/// a pattern in bounding-box or view box units in a group carrying that scale after the fact,
|
||||
/// without updating the absolute transforms beneath: unwrap such groups into the transform.
|
||||
fn unstale(root: &usvg::Group) -> (&usvg::Group, Affine) {
|
||||
let near = |a: Affine, b: Affine| a.as_coeffs().iter().zip(b.as_coeffs()).all(|(x, y)| (x - y).abs() <= 1e-4 * (1.0 + x.abs()));
|
||||
let (mut g, mut base) = (root, Affine::IDENTITY);
|
||||
for _ in 0..4 {
|
||||
let [usvg::Node::Group(w)] = g.children() else { break };
|
||||
let t = xf(w.transform());
|
||||
let stale = match w.children().first() {
|
||||
Some(usvg::Node::Group(c)) => near(xf(c.abs_transform()), xf(c.transform())),
|
||||
Some(c) => near(xf(c.abs_transform()), Affine::IDENTITY),
|
||||
None => false,
|
||||
};
|
||||
if t == Affine::IDENTITY || !stale || !near(xf(w.abs_transform()), t) {
|
||||
break;
|
||||
}
|
||||
base *= t;
|
||||
g = w;
|
||||
}
|
||||
(g, base)
|
||||
}
|
||||
|
||||
/// Draws pattern `pat`'s tile into an image (at twice its size on the page, at most [`MAX_TILE`]
|
||||
/// a side): the image's hash, its size and its pixels → pattern space transform.
|
||||
fn render_tile(cx: &mut Ctx, pat: &usvg::Pattern, tile: Affine) -> Option<(String, f64, f64, Affine)> {
|
||||
if cx.pattern_depth >= 2 {
|
||||
return None;
|
||||
}
|
||||
let r = pat.rect();
|
||||
let (rw, rh) = (f64::from(r.width()), f64::from(r.height()));
|
||||
let k = (length_scale(tile) * 2.0).max(0.01).min(MAX_TILE / rw.max(rh));
|
||||
let (pw, ph) = ((rw * k).ceil().max(1.0), (rh * k).ceil().max(1.0));
|
||||
if !(pw.is_finite() && ph.is_finite()) || pw > MAX_TILE || ph > MAX_TILE {
|
||||
return None;
|
||||
}
|
||||
let mut sub = Document::new("Pattern");
|
||||
let page = sub.first_page()?;
|
||||
let frame = sub.create(page, NodeKind::Frame, "Tile", 0.0, 0.0, rw, rh).ok()?;
|
||||
if let Some(f) = sub.node_mut(frame) {
|
||||
f.fills.clear();
|
||||
}
|
||||
let notes = {
|
||||
let mut scx = Ctx::new(&mut sub);
|
||||
scx.pattern_depth = cx.pattern_depth + 1;
|
||||
let (content, base) = unstale(pat.root());
|
||||
children(&mut scx, content, frame, base, 0);
|
||||
std::mem::take(&mut scx.notes)
|
||||
};
|
||||
cx.notes.extend(notes);
|
||||
let view = figcraft_render::View { origin: (0.0, 0.0), scale: k, width: pw as u32, height: ph as u32 };
|
||||
let mut pix = figcraft_render::Pixmap::new(pw as u32, ph as u32)?;
|
||||
figcraft_render::render_nodes_into(&sub, &[frame], &view, &mut pix);
|
||||
let png = figcraft_render::encode_png(&pix).ok()?;
|
||||
let hash = add_image(cx.doc, std::sync::Arc::new(png), pw, ph);
|
||||
Some((hash, pw, ph, Affine::scale_non_uniform(rw / pw, rh / ph)))
|
||||
}
|
||||
|
|
@ -0,0 +1,145 @@
|
|||
//! Paints: gradient handles exact under any transform, spreads unrolled, patterns as image fills.
|
||||
|
||||
use figcraft_color::{Gradient, GradientKind, ImageScaleMode, Paint, PaintKind};
|
||||
use figcraft_doc::{Document, Node};
|
||||
|
||||
use super::import_svg_with_notes;
|
||||
|
||||
/// The layers of `svg`'s import (frame first), and its notes.
|
||||
fn import(svg: &str) -> (Document, Vec<Node>, Vec<String>) {
|
||||
let mut d = Document::new("t");
|
||||
let p = d.first_page().unwrap();
|
||||
let i = import_svg_with_notes(&mut d, svg.as_bytes(), p, 0.0, 0.0, "x").unwrap();
|
||||
let nodes = d.subtree(i.frame).into_iter().filter_map(|id| d.node(id).cloned()).collect();
|
||||
(d, nodes, i.notes)
|
||||
}
|
||||
|
||||
fn fill(svg: &str) -> Paint {
|
||||
import(svg).1.get(1).and_then(|n| n.fills.first().cloned()).unwrap()
|
||||
}
|
||||
|
||||
fn gradient(svg: &str) -> Gradient {
|
||||
match fill(svg).kind {
|
||||
PaintKind::Gradient(g) => g,
|
||||
k => panic!("{k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn near(a: (f64, f64), b: (f64, f64)) -> bool {
|
||||
(a.0 - b.0).abs() < 1e-4 && (a.1 - b.1).abs() < 1e-4
|
||||
}
|
||||
|
||||
const DEFS: &str = r##"<stop offset="0" stop-color="#f00"/><stop offset="1" stop-color="#00f"/>"##;
|
||||
|
||||
#[test]
|
||||
fn linear_handles_land_in_the_layers_own_space() {
|
||||
// Bounding-box units: the handles span the unit box (perpendicular in bounding-box space, as
|
||||
// SVG has it).
|
||||
let g = gradient(&format!(
|
||||
r##"<svg xmlns="http://www.w3.org/2000/svg" width="300" height="200"><linearGradient id="g">{DEFS}</linearGradient><rect x="10" y="20" width="200" height="100" fill="url(#g)"/></svg>"##
|
||||
));
|
||||
assert_eq!(g.kind, GradientKind::Linear);
|
||||
assert!(near(g.from, (0.0, 0.0)) && near(g.to, (1.0, 0.0)) && near(g.width, (0.0, 1.0)), "{g:?}");
|
||||
assert_eq!(g.stops.len(), 2);
|
||||
// User space through a rotated group: the handles land in the rectangle's own space.
|
||||
let g = gradient(&format!(
|
||||
r##"<svg xmlns="http://www.w3.org/2000/svg" width="300" height="300"><linearGradient id="g" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="0" y2="50">{DEFS}</linearGradient><g transform="translate(100 100) rotate(90)"><rect width="100" height="50" fill="url(#g)"/></g></svg>"##
|
||||
));
|
||||
assert!(near(g.from, (0.0, 0.0)) && near(g.to, (0.0, 1.0)), "{g:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radial_gradients_center_on_their_focal_point() {
|
||||
let g = gradient(&format!(
|
||||
r##"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><radialGradient id="g" fx="0.25">{DEFS}</radialGradient><rect width="100" height="50" fill="url(#g)"/></svg>"##
|
||||
));
|
||||
assert_eq!(g.kind, GradientKind::Radial);
|
||||
assert!(near(g.from, (0.25, 0.5)) && near(g.to, (0.75, 0.5)) && near(g.width, (0.25, 1.0)), "{g:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reflect_and_repeat_unroll_over_the_box() {
|
||||
let svg = |m: &str| {
|
||||
format!(
|
||||
r##"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="10"><linearGradient id="g" x2="0.25" spreadMethod="{m}">{DEFS}</linearGradient><rect width="100" height="10" fill="url(#g)"/></svg>"##
|
||||
)
|
||||
};
|
||||
let r = gradient(&svg("reflect"));
|
||||
assert!(near(r.from, (0.0, 0.0)) && near(r.to, (1.0, 0.0)), "{r:?}");
|
||||
let pos: Vec<f32> = r.stops.iter().map(|s| s.position).collect();
|
||||
assert_eq!(pos, [0.0, 0.25, 0.25, 0.5, 0.5, 0.75, 0.75, 1.0]);
|
||||
// Every other period runs backwards.
|
||||
assert!(r.stops[2].color.b > 0.9 && r.stops[3].color.r > 0.9);
|
||||
let p = gradient(&svg("repeat"));
|
||||
assert!(p.stops[2].color.r > 0.9 && p.stops[3].color.b > 0.9, "a hard edge at each period");
|
||||
// Pad stays as it is.
|
||||
assert_eq!(gradient(&svg("pad")).stops.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opacity_and_stop_opacity_carry_over() {
|
||||
let p = fill(
|
||||
r##"<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><linearGradient id="g"><stop offset="0" stop-color="#000" stop-opacity="0.25"/><stop offset="1" stop-color="#000"/></linearGradient><rect width="10" height="10" fill="url(#g)" fill-opacity="0.5"/></svg>"##,
|
||||
);
|
||||
assert_eq!(p.opacity, 0.5);
|
||||
let PaintKind::Gradient(g) = p.kind else { panic!() };
|
||||
assert_eq!((g.stops[0].color.a, g.stops[1].color.a), (0.25, 1.0));
|
||||
}
|
||||
|
||||
const PNG: &str = "iVBORw0KGgoAAAANSUhEUgAAAAQAAAACCAIAAADwyuo0AAAAE0lEQVR4nGP4z8AARGDiPwMyBwBnsgf5Q2OaZwAAAABJRU5ErkJggg==";
|
||||
|
||||
fn image_paint(svg: &str) -> (figcraft_color::ImagePaint, Document) {
|
||||
let (d, nodes, _) = import(svg);
|
||||
match nodes.get(1).and_then(|n| n.fills.first()).map(|p| p.kind.clone()) {
|
||||
Some(PaintKind::Image(i)) => (i, d),
|
||||
k => panic!("{k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_image_pattern_shown_once_is_an_image_fill() {
|
||||
// The usual export of an image fill: bounding-box content units, the image scaled into it.
|
||||
let (i, d) = image_paint(&format!(
|
||||
r##"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><defs><pattern id="p" patternContentUnits="objectBoundingBox" width="1" height="1"><use href="#img" transform="scale(0.25 0.5)"/></pattern><image id="img" width="4" height="2" href="data:image/png;base64,{PNG}"/></defs><rect width="80" height="40" fill="url(#p)"/></svg>"##
|
||||
));
|
||||
assert_eq!(i.scale_mode, ImageScaleMode::Fill);
|
||||
assert_eq!(d.images.get(&i.image).map(|im| (im.width, im.height)), Some((4, 2)));
|
||||
// Placed off-centre: a crop that puts it exactly there.
|
||||
let (i, _) = image_paint(&format!(
|
||||
r##"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><defs><pattern id="p" patternUnits="userSpaceOnUse" width="1000" height="1000"><image x="10" width="4" height="2" href="data:image/png;base64,{PNG}"/></pattern></defs><rect width="80" height="40" fill="url(#p)"/></svg>"##
|
||||
));
|
||||
assert_eq!(i.scale_mode, ImageScaleMode::Crop);
|
||||
let c = i.crop.unwrap();
|
||||
assert!((c[0] - 20.0).abs() < 1e-6 && (c[4] + 2.5).abs() < 1e-6, "{c:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_repeating_pattern_tiles() {
|
||||
let (i, _) = image_paint(&format!(
|
||||
r##"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><defs><pattern id="p" patternUnits="userSpaceOnUse" width="8" height="4"><image width="8" height="4" href="data:image/png;base64,{PNG}"/></pattern></defs><rect width="80" height="40" fill="url(#p)"/></svg>"##
|
||||
));
|
||||
assert_eq!((i.scale_mode, i.scale), (ImageScaleMode::Tile, 2.0));
|
||||
// A vector pattern is drawn into a tile image.
|
||||
let (i, d) = image_paint(
|
||||
r##"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><defs><pattern id="dots" patternUnits="userSpaceOnUse" width="10" height="10"><circle cx="5" cy="5" r="3" fill="#000"/></pattern></defs><rect width="100" height="100" fill="url(#dots)"/></svg>"##,
|
||||
);
|
||||
assert_eq!((i.scale_mode, i.scale), (ImageScaleMode::Tile, 0.5));
|
||||
assert_eq!(d.images.get(&i.image).map(|im| (im.width, im.height)), Some((20, 20)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rotated_repeating_pattern_is_reported() {
|
||||
let (_, nodes, notes) = import(&format!(
|
||||
r##"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><defs><pattern id="p" patternUnits="userSpaceOnUse" width="8" height="4" patternTransform="rotate(30)"><image width="8" height="4" href="data:image/png;base64,{PNG}"/></pattern></defs><rect width="80" height="40" fill="url(#p)"/></svg>"##
|
||||
));
|
||||
assert!(nodes.get(1).is_some_and(|n| n.fills.is_empty()));
|
||||
assert!(notes.iter().any(|n| n.contains("pattern")), "{notes:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pattern_inside_its_own_tile_stops() {
|
||||
let (_, nodes, _) = import(
|
||||
r##"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><defs><pattern id="p" patternUnits="userSpaceOnUse" width="10" height="10"><rect width="5" height="5" fill="url(#p)"/><rect x="5" width="5" height="5" fill="#000"/></pattern></defs><rect width="100" height="100" fill="url(#p)"/></svg>"##,
|
||||
);
|
||||
assert!(nodes.len() >= 2);
|
||||
}
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
//! Recognising primitives in path data, so imported shapes stay editable: rectangles (with a
|
||||
//! uniform corner radius), ellipses and straight lines. `<rect>`, `<circle>`, `<ellipse>` and
|
||||
//! `<line>` reach us as paths; so do hand-written paths that trace the same outlines, and they're
|
||||
//! recognised the same way. The test is geometric: every sampled point of the outline lies on the
|
||||
//! candidate's outline and the lengths agree.
|
||||
|
||||
use figcraft_geom::kurbo::{ParamCurve, PathEl};
|
||||
use figcraft_geom::{Affine, BezPath, Point, Rect, Shape, Vec2};
|
||||
|
||||
/// A primitive found in a path, in the path's own coordinates.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(crate) enum Prim {
|
||||
Rect { rect: Rect, radius: f64 },
|
||||
Ellipse(Rect),
|
||||
Line(Point, Point),
|
||||
}
|
||||
|
||||
/// Relative tolerance of the geometric tests.
|
||||
const TOL: f64 = 2e-3;
|
||||
/// Most path elements a primitive is looked for in (they take a handful; this keeps big paths
|
||||
/// cheap).
|
||||
const MAX_ELEMENTS: usize = 40;
|
||||
|
||||
/// The primitive `path` traces. An unclosed path ending where it starts counts as closed when
|
||||
/// `unstroked` (with a stroke its ends would show as caps, not a corner).
|
||||
pub(crate) fn recognize(path: &BezPath, unstroked: bool) -> Option<Prim> {
|
||||
let els = path.elements();
|
||||
if els.len() > MAX_ELEMENTS || els.iter().skip(1).any(|e| matches!(e, PathEl::MoveTo(_))) {
|
||||
return None;
|
||||
}
|
||||
if let [PathEl::MoveTo(a), PathEl::LineTo(b)] = els {
|
||||
return (a.distance(*b) > 1e-9).then_some(Prim::Line(*a, *b));
|
||||
}
|
||||
let bb = path.bounding_box();
|
||||
let (w, h) = (bb.width(), bb.height());
|
||||
if !(w > 1e-6 && h > 1e-6 && w.is_finite() && h.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let tol = TOL * w.max(h);
|
||||
let ends = (els.first().and_then(PathEl::end_point), els.iter().rev().find_map(PathEl::end_point));
|
||||
let closed = matches!(els.last(), Some(PathEl::ClosePath)) || (unstroked && matches!(ends, (Some(a), Some(b)) if a.distance(b) <= tol * 1e-3));
|
||||
if !closed {
|
||||
return None;
|
||||
}
|
||||
let samples = samples(path);
|
||||
let length = || path.perimeter(1e-4);
|
||||
// An ellipse first: a circle also passes as a fully rounded square.
|
||||
let (c, rx, ry) = (bb.center(), w / 2.0, h / 2.0);
|
||||
let on_ellipse = samples.iter().all(|p| {
|
||||
let (dx, dy) = ((p.x - c.x) / rx, (p.y - c.y) / ry);
|
||||
((dx * dx + dy * dy).sqrt() - 1.0).abs() * rx.min(ry) <= tol
|
||||
});
|
||||
if on_ellipse && close(length(), ellipse_perimeter(rx, ry)) {
|
||||
return Some(Prim::Ellipse(bb));
|
||||
}
|
||||
// A rounded rectangle's area is w·h − (4 − π)·r²; where its straight edges start (the end
|
||||
// points nearest that) gives r exactly.
|
||||
let area = path.area().abs();
|
||||
let estimate = ((w * h - area) / (4.0 - std::f64::consts::PI)).max(0.0).sqrt();
|
||||
let mut radius = els
|
||||
.iter()
|
||||
.filter_map(PathEl::end_point)
|
||||
.flat_map(|p| [p.x - bb.x0, bb.x1 - p.x, p.y - bb.y0, bb.y1 - p.y])
|
||||
.filter(|r| (r - estimate).abs() <= 0.05 * estimate)
|
||||
.min_by(|a, b| (a - estimate).abs().total_cmp(&(b - estimate).abs()))
|
||||
.unwrap_or(estimate)
|
||||
.min(w.min(h) / 2.0);
|
||||
if radius < tol {
|
||||
radius = 0.0;
|
||||
}
|
||||
// Rounding noise off (7.999999999999998 → 8).
|
||||
radius = (radius * 1e6).round() / 1e6;
|
||||
let on_rect = samples.iter().all(|p| round_box_distance(*p, bb, radius).abs() <= tol);
|
||||
let perimeter = 2.0 * (w + h) - (8.0 - 2.0 * std::f64::consts::PI) * radius;
|
||||
(on_rect && close(length(), perimeter)).then_some(Prim::Rect { rect: bb, radius })
|
||||
}
|
||||
|
||||
fn close(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() <= 5e-3 * b.max(1e-9)
|
||||
}
|
||||
|
||||
/// Points along every segment (ends and between).
|
||||
fn samples(path: &BezPath) -> Vec<Point> {
|
||||
let mut out = Vec::new();
|
||||
for seg in path.segments() {
|
||||
for i in 0..8 {
|
||||
out.push(seg.eval(f64::from(i) / 8.0));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Ramanujan's approximation of an ellipse's perimeter.
|
||||
fn ellipse_perimeter(a: f64, b: f64) -> f64 {
|
||||
let h = ((a - b) / (a + b)).powi(2);
|
||||
std::f64::consts::PI * (a + b) * (1.0 + 3.0 * h / (10.0 + (4.0 - 3.0 * h).sqrt()))
|
||||
}
|
||||
|
||||
/// Signed distance from `p` to the outline of `rect` with corner `radius`.
|
||||
fn round_box_distance(p: Point, rect: Rect, radius: f64) -> f64 {
|
||||
let c = rect.center();
|
||||
let half = Vec2::new(rect.width() / 2.0 - radius, rect.height() / 2.0 - radius);
|
||||
let q = Vec2::new((p.x - c.x).abs() - half.x, (p.y - c.y).abs() - half.y);
|
||||
let outside = Vec2::new(q.x.max(0.0), q.y.max(0.0)).hypot();
|
||||
outside + q.x.max(q.y).min(0.0) - radius
|
||||
}
|
||||
|
||||
/// A `w × h` box at `origin` in user space, placed by `world`: the layer's transform (rotation and
|
||||
/// flips only) and its size. `None` when `world` skews the box (or squashes it to nothing).
|
||||
pub(crate) fn place(world: Affine, origin: Point, w: f64, h: f64) -> Option<(Affine, f64, f64)> {
|
||||
let m = world * Affine::translate(origin.to_vec2());
|
||||
let [a, b, c, d, e, f] = m.as_coeffs();
|
||||
let (sx, sy) = ((a * a + b * b).sqrt(), (c * c + d * d).sqrt());
|
||||
if !(sx > 1e-9 && sy > 1e-9 && [a, b, c, d, e, f].iter().all(|v| v.is_finite())) || (a * c + b * d).abs() > 1e-6 * sx * sy {
|
||||
return None;
|
||||
}
|
||||
Some((Affine::new([a / sx, b / sx, c / sy, d / sy, e, f]), w * sx, h * sy))
|
||||
}
|
||||
|
||||
/// How much `world` scales lengths (stroke widths, blur radii): the square root of its area scale.
|
||||
pub(crate) fn length_scale(world: Affine) -> f64 {
|
||||
let s = world.determinant().abs().sqrt();
|
||||
if s.is_finite() { s } else { 1.0 }
|
||||
}
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
//! Primitive recognition in path data, placement under transforms, dash offsets and layer names.
|
||||
|
||||
use figcraft_geom::{Affine, BezPath, Point, Rect};
|
||||
|
||||
use super::shape::{Prim, place, recognize};
|
||||
use super::{dash_offset, layer_name};
|
||||
|
||||
fn path(d: &str) -> BezPath {
|
||||
BezPath::from_svg(d).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rectangles_are_found_with_their_corner_radius() {
|
||||
assert_eq!(recognize(&path("M10 20 H60 V50 H10 Z"), false), Some(Prim::Rect { rect: Rect::new(10.0, 20.0, 60.0, 50.0), radius: 0.0 }));
|
||||
// Any direction, any starting corner.
|
||||
assert!(matches!(recognize(&path("M60 50 V20 H10 V50 Z"), false), Some(Prim::Rect { radius: 0.0, .. })));
|
||||
// Rounded: corners as arcs (as cubics).
|
||||
let rounded = figcraft_geom::rounded_rect(80.0, 40.0, [8.0; 4], 0.0);
|
||||
let Some(Prim::Rect { rect, radius }) = recognize(&rounded, false) else { panic!("{:?}", recognize(&rounded, false)) };
|
||||
assert_eq!(rect, Rect::new(0.0, 0.0, 80.0, 40.0));
|
||||
assert_eq!(radius, 8.0);
|
||||
// A pill: fully rounded ends.
|
||||
let pill = figcraft_geom::rounded_rect(80.0, 20.0, [10.0; 4], 0.0);
|
||||
assert!(matches!(recognize(&pill, false), Some(Prim::Rect { radius: 10.0, .. })));
|
||||
// Back at the start without closing: a corner only when there's no stroke to cap the ends.
|
||||
assert!(matches!(recognize(&path("M0 0 H40 V40 H0 V0"), true), Some(Prim::Rect { .. })));
|
||||
assert_eq!(recognize(&path("M0 0 H40 V40 H0 V0"), false), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn circles_and_ellipses_are_found() {
|
||||
// Closed by ending where it starts.
|
||||
let e = figcraft_geom::ellipse(60.0, 30.0, 0.0, std::f64::consts::TAU, 0.0);
|
||||
let Some(Prim::Ellipse(r)) = recognize(&e, true) else { panic!("{:?}", recognize(&e, true)) };
|
||||
assert!((r.width() - 60.0).abs() < 1e-9 && (r.height() - 30.0).abs() < 1e-9 && r.x0.abs() < 1e-9, "{r:?}");
|
||||
// A circle is an ellipse, not a fully rounded square.
|
||||
let c = figcraft_geom::ellipse(40.0, 40.0, 0.0, std::f64::consts::TAU, 0.0);
|
||||
assert!(matches!(recognize(&c, true), Some(Prim::Ellipse(_))));
|
||||
// SVG arcs.
|
||||
assert!(matches!(recognize(&path("M0 20 A20 20 0 0 1 40 20 A20 20 0 0 1 0 20 Z"), false), Some(Prim::Ellipse(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_outlines_are_not_primitives() {
|
||||
for d in [
|
||||
// A triangle, an open path, two subpaths.
|
||||
"M0 0 L40 0 L20 30 Z",
|
||||
"M0 0 H40 V40 H0",
|
||||
"M0 0 H40 V40 H0 Z M10 10 H30 V30 H10 Z",
|
||||
// Elliptical corners.
|
||||
"M10 0 H70 A10 5 0 0 1 80 5 V25 A10 5 0 0 1 70 30 H10 A10 5 0 0 1 0 25 V5 A10 5 0 0 1 10 0 Z",
|
||||
// Half a circle, a flat box, a square traced twice.
|
||||
"M0 20 A20 20 0 0 1 40 20 Z",
|
||||
"M0 0 H40 V0 H0 Z",
|
||||
"M0 0 L40 0 L40 40 L0 40 L0 0 L40 0 L40 40 L0 40 Z",
|
||||
] {
|
||||
assert!(!matches!(recognize(&path(d), false), Some(Prim::Rect { .. } | Prim::Ellipse(_))), "{d}: {:?}", recognize(&path(d), false));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_point_paths_are_lines() {
|
||||
assert_eq!(recognize(&path("M1 2 L30 40"), false), Some(Prim::Line(Point::new(1.0, 2.0), Point::new(30.0, 40.0))));
|
||||
assert_eq!(recognize(&path("M1 2 L1 2"), false), None);
|
||||
assert_eq!(recognize(&path("M0 0 L10 0 L20 0"), false), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placement_keeps_rotation_and_flips_but_not_skew() {
|
||||
let world = Affine::rotate(0.5) * Affine::scale_non_uniform(2.0, 3.0);
|
||||
let (t, w, h) = place(world, Point::new(1.0, 1.0), 10.0, 10.0).unwrap();
|
||||
assert!((w - 20.0).abs() < 1e-9 && (h - 30.0).abs() < 1e-9);
|
||||
let c = t.as_coeffs();
|
||||
assert!((c[1].atan2(c[0]) - 0.5).abs() < 1e-9 && (c[0] * c[0] + c[1] * c[1] - 1.0).abs() < 1e-9);
|
||||
// The box's origin lands where the transform puts it.
|
||||
assert!((t * Point::ZERO - world * Point::new(1.0, 1.0)).hypot() < 1e-9);
|
||||
assert!(place(Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 10.0]), Point::ZERO, 5.0, 5.0).is_some());
|
||||
assert!(place(Affine::skew(0.5, 0.0), Point::ZERO, 5.0, 5.0).is_none());
|
||||
assert!(place(Affine::scale(0.0), Point::ZERO, 5.0, 5.0).is_none());
|
||||
assert!(place(Affine::translate((f64::NAN, 0.0)), Point::ZERO, 5.0, 5.0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dash_offsets_rotate_the_pattern() {
|
||||
assert_eq!(dash_offset(&[20.0, 10.0], 0.0, true), Some(vec![20.0, 10.0]));
|
||||
assert_eq!(dash_offset(&[20.0, 10.0], 30.0, true), Some(vec![20.0, 10.0]));
|
||||
// Into the dash.
|
||||
assert_eq!(dash_offset(&[20.0, 10.0], 5.0, false), Some(vec![15.0, 10.0, 5.0, 0.0]));
|
||||
// Into the gap: a zero-length dash first, which only butt caps draw as nothing.
|
||||
assert_eq!(dash_offset(&[20.0, 10.0], 25.0, true), Some(vec![0.0, 5.0, 20.0, 5.0]));
|
||||
assert_eq!(dash_offset(&[20.0, 10.0], 25.0, false), None);
|
||||
// Backwards.
|
||||
assert_eq!(dash_offset(&[20.0, 10.0], -5.0, true), Some(vec![0.0, 5.0, 20.0, 5.0]));
|
||||
for bad in [f64::NAN, f64::INFINITY] {
|
||||
assert_eq!(dash_offset(&[20.0, 10.0], bad, true), None);
|
||||
}
|
||||
assert_eq!(dash_offset(&[0.0, 0.0], 3.0, true), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_names_decode_id_escapes() {
|
||||
assert_eq!(layer_name("My_x20_Layer", "Group"), "My Layer");
|
||||
assert_eq!(layer_name("a_x2C__x20_b", "Group"), "a, b");
|
||||
assert_eq!(layer_name("bad_xZZ_escape", "Group"), "bad_xZZ_escape");
|
||||
assert_eq!(layer_name("tail_x2", "Group"), "tail_x2");
|
||||
assert_eq!(layer_name("ctl_x07_", "Group"), "ctl_x07_");
|
||||
assert_eq!(layer_name("", "Group"), "Group");
|
||||
assert_eq!(layer_name("é_x41_", "Group"), "éA");
|
||||
}
|
||||
|
|
@ -0,0 +1,175 @@
|
|||
//! The layer tree an import makes: wrappers merged, names kept, masks and effects in the right
|
||||
//! order, files on disk never read.
|
||||
|
||||
use figcraft_color::BlendMode;
|
||||
use figcraft_doc::{Document, MaskType, Node, NodeId, StrokeAlign};
|
||||
|
||||
use super::import_svg_with_notes;
|
||||
|
||||
fn import(body: &str) -> (Document, NodeId) {
|
||||
let svg =
|
||||
format!(r##"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200">{body}</svg>"##);
|
||||
let mut d = Document::new("t");
|
||||
let p = d.first_page().unwrap();
|
||||
let f = import_svg_with_notes(&mut d, svg.as_bytes(), p, 0.0, 0.0, "x").unwrap().frame;
|
||||
assert!(d.validate().is_empty(), "{:?}", d.validate());
|
||||
(d, f)
|
||||
}
|
||||
|
||||
/// `(depth, type, name)` for every layer under the frame.
|
||||
fn tree(d: &Document, f: NodeId) -> Vec<(usize, &'static str, String)> {
|
||||
fn walk(d: &Document, id: NodeId, depth: usize, out: &mut Vec<(usize, &'static str, String)>) {
|
||||
let Some(n) = d.node(id) else { return };
|
||||
out.push((depth, n.kind.type_name(), n.name.clone()));
|
||||
for c in &n.children {
|
||||
walk(d, *c, depth + 1, out);
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for c in &d.node(f).unwrap().children {
|
||||
walk(d, *c, 0, &mut out);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn node<'a>(d: &'a Document, f: NodeId, name: &str) -> &'a Node {
|
||||
d.subtree(f).into_iter().filter_map(|i| d.node(i)).find(|n| n.name == name).unwrap()
|
||||
}
|
||||
|
||||
/// A 4 × 2 PNG.
|
||||
const PNG: &str = "iVBORw0KGgoAAAANSUhEUgAAAAQAAAACCAIAAADwyuo0AAAAE0lEQVR4nGP4z8AARGDiPwMyBwBnsgf5Q2OaZwAAAABJRU5ErkJggg==";
|
||||
|
||||
fn t(depth: usize, kind: &'static str, name: &str) -> (usize, &'static str, String) {
|
||||
(depth, kind, name.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrappers_merge_into_their_element() {
|
||||
// A transform, opacity and blend on one element: one layer carrying them.
|
||||
let (d, f) = import(r##"<rect id="r" width="10" height="10" transform="translate(5 5)" opacity="0.5" style="mix-blend-mode:darken"/>"##);
|
||||
assert_eq!(tree(&d, f), [t(0, "RECTANGLE", "r")]);
|
||||
let r = node(&d, f, "r");
|
||||
assert_eq!((r.opacity, r.blend), (0.5, BlendMode::Darken));
|
||||
assert_eq!(d.world_transform(d.node(f).unwrap().children[0]).translation().x, 5.0);
|
||||
// A named group stays, even around one shape; an unnamed one with several children stays too.
|
||||
let (d, f) = import(r##"<g id="logo"><circle r="5"/></g><g opacity="0.5"><rect width="1" height="1"/><rect x="2" width="1" height="1"/></g>"##);
|
||||
assert_eq!(
|
||||
tree(&d, f),
|
||||
[t(0, "GROUP", "logo"), t(1, "ELLIPSE", "Ellipse"), t(0, "GROUP", "Group"), t(1, "RECTANGLE", "Rectangle"), t(1, "RECTANGLE", "Rectangle")]
|
||||
);
|
||||
// Empty groups go.
|
||||
let (d, f) = import(r##"<g id="empty"/><g><g/></g>"##);
|
||||
assert!(tree(&d, f).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_opacity_and_layer_opacity_multiply_when_merged() {
|
||||
let (d, f) = import(r##"<g opacity="0.5"><rect id="r" width="10" height="10" opacity="0.5"/></g>"##);
|
||||
assert_eq!(tree(&d, f), [t(0, "RECTANGLE", "r")]);
|
||||
assert_eq!(node(&d, f, "r").opacity, 0.25);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_mask_and_filter_nest_in_svg_order() {
|
||||
// Filter first, then clip, then mask: mask group › clip, (mask group › mask, (content with the filter)).
|
||||
let (d, f) = import(
|
||||
r##"<clipPath id="c"><rect width="50" height="50"/></clipPath><mask id="m"><rect width="100" height="100" fill="white"/></mask><filter id="b"><feGaussianBlur stdDeviation="2"/></filter><g id="art" clip-path="url(#c)" mask="url(#m)" filter="url(#b)" opacity="0.5"><rect width="80" height="80"/><circle cx="20" cy="20" r="10"/></g>"##,
|
||||
);
|
||||
assert_eq!(
|
||||
tree(&d, f),
|
||||
[
|
||||
t(0, "GROUP", "art"),
|
||||
t(1, "RECTANGLE", "Rectangle"),
|
||||
t(1, "GROUP", "Mask group"),
|
||||
t(2, "RECTANGLE", "Rectangle"),
|
||||
t(2, "GROUP", "Group"),
|
||||
t(3, "RECTANGLE", "Rectangle"),
|
||||
t(3, "ELLIPSE", "Ellipse"),
|
||||
]
|
||||
);
|
||||
let art = node(&d, f, "art");
|
||||
assert_eq!(art.opacity, 0.5);
|
||||
let kids: Vec<&Node> = art.children.iter().filter_map(|c| d.node(*c)).collect();
|
||||
assert!(kids[0].is_mask && kids[0].mask_type == MaskType::Vector);
|
||||
let inner: Vec<&Node> = kids[1].children.iter().filter_map(|c| d.node(*c)).collect();
|
||||
assert!(inner[0].is_mask && inner[0].mask_type == MaskType::Luminance);
|
||||
assert_eq!(inner[1].effects.len(), 1, "the filter is on the content, under the clip and mask");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_clip_around_everything_is_dropped() {
|
||||
let (d, f) = import(
|
||||
r##"<clipPath id="c"><rect width="100" height="100"/></clipPath><g clip-path="url(#c)"><rect x="10" y="10" width="20" height="20"/></g>"##,
|
||||
);
|
||||
assert_eq!(tree(&d, f), [t(0, "RECTANGLE", "Rectangle")]);
|
||||
// One that cuts in stays.
|
||||
let (d, f) = import(
|
||||
r##"<clipPath id="c"><rect width="15" height="100"/></clipPath><g clip-path="url(#c)"><rect x="10" y="10" width="20" height="20"/></g>"##,
|
||||
);
|
||||
assert_eq!(tree(&d, f).len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_mask_hides_its_layer() {
|
||||
let (d, f) = import(r##"<mask id="m"/><rect width="10" height="10" mask="url(#m)"/><rect id="kept" width="5" height="5"/>"##);
|
||||
assert_eq!(tree(&d, f), [t(0, "RECTANGLE", "kept")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stroke_painted_under_the_fill_shows_its_outer_half() {
|
||||
let (d, f) = import(r##"<rect id="r" width="10" height="10" fill="red" stroke="blue" stroke-width="4" paint-order="stroke"/>"##);
|
||||
let r = node(&d, f, "r");
|
||||
assert_eq!((r.stroke.align, r.stroke.weight), (StrokeAlign::Outside, 2.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strokes_scale_with_their_layer() {
|
||||
let (d, f) = import(r##"<g transform="scale(3)"><line id="l" x2="10" stroke="black" stroke-width="2" stroke-dasharray="4 2"/></g>"##);
|
||||
let l = node(&d, f, "l");
|
||||
assert_eq!((l.width, l.stroke.weight, l.stroke.dashes.clone()), (30.0, 6.0, vec![12.0, 6.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linked_files_are_never_read() {
|
||||
let dir = std::env::temp_dir().join("figcraft-svg-linked");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let png = dir.join("secret.png");
|
||||
std::fs::write(&png, base64::Engine::decode(&base64::engine::general_purpose::STANDARD, PNG).unwrap()).unwrap();
|
||||
let href = png.to_string_lossy().replace('\\', "/");
|
||||
let (d, f) = import(&format!(r##"<image width="4" height="2" href="{href}"/><image width="4" height="2" href="file:///{href}"/>"##));
|
||||
assert!(tree(&d, f).is_empty() && d.images.is_empty());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_svg_image_is_imported_as_layers() {
|
||||
use base64::Engine as _;
|
||||
let inner = br##"<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><rect id="dot" width="10" height="10" fill="#0f0"/></svg>"##;
|
||||
let b64 = base64::engine::general_purpose::STANDARD.encode(inner);
|
||||
let (d, f) = import(&format!(r##"<image id="badge" x="20" y="30" width="40" height="40" href="data:image/svg+xml;base64,{b64}"/>"##));
|
||||
let dot = node(&d, f, "dot");
|
||||
assert_eq!((dot.kind.type_name(), dot.width), ("RECTANGLE", 40.0));
|
||||
let id = d.subtree(f).into_iter().find(|i| d.node(*i).is_some_and(|n| n.name == "dot")).unwrap();
|
||||
let o = d.world_transform(id).translation();
|
||||
assert_eq!((o.x, o.y), (20.0, 30.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_layers_are_named_by_their_text_or_id() {
|
||||
if figcraft_text::fontdb::find_face("Inter", "Regular").is_none() {
|
||||
return;
|
||||
}
|
||||
let (d, f) = import(r##"<text x="10" y="20">Hello there</text><text id="Caption_x20_1" x="10" y="60">Small</text>"##);
|
||||
assert_eq!(tree(&d, f), [t(0, "TEXT", "Hello there"), t(0, "TEXT", "Caption 1")]);
|
||||
// A chunk without its own x continues where the one before ends.
|
||||
let (d, f) = import(r##"<text x="10" y="20">Ab<tspan y="40">C</tspan>d</text>"##);
|
||||
assert_eq!(tree(&d, f), [t(0, "TEXT", "Ab"), t(0, "TEXT", "Cd")]);
|
||||
let x = |name: &str| d.world_transform(d.subtree(f).into_iter().find(|i| d.node(*i).is_some_and(|n| n.name == name)).unwrap()).translation().x;
|
||||
let advance = figcraft_text::layout(node(&d, f, "Ab").kind.text().unwrap(), None, None).width;
|
||||
assert!((x("Cd") - (10.0 + advance)).abs() < 1e-6, "{} vs {}", x("Cd"), 10.0 + advance);
|
||||
// A scaled text keeps its look in the font size.
|
||||
let (d, f) = import(r##"<text transform="scale(2)" x="10" y="20" font-size="10">Big</text>"##);
|
||||
let n = node(&d, f, "Big");
|
||||
assert_eq!(n.kind.text().map(|t| t.style.font_size), Some(20.0));
|
||||
assert_eq!(n.transform.as_coeffs()[0], 1.0);
|
||||
}
|
||||
|
|
@ -0,0 +1,290 @@
|
|||
//! SVG text → text layers. Each absolutely positioned chunk (`<text x y>`, `<tspan x y>`) is a
|
||||
//! line; lines stacked at a steady pitch with the same anchor and font become one multi-line
|
||||
//! layer with that line height. `<tspan>` styles become style runs. The layer is placed so its
|
||||
//! first baseline sits on the SVG's `y` and its anchor (start, middle, end) on `x`, with the font
|
||||
//! the SVG names when it's installed (else the first installed of its fallbacks or generic family).
|
||||
//!
|
||||
//! usvg lays text out before handing it over and drops what it can't lay out, so one face from
|
||||
//! FigCraft's font database stands in for every family while parsing; FigCraft then lays the text
|
||||
//! out with the real fonts. Text on a path, with per-glyph rotation or vertical comes in as outlines.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use figcraft_doc::{LetterSpacing, LineHeight, Node, NodeId, NodeKind, StyleRun, TextAlignH, TextCase, TextData, TextDecoration, TextStyle};
|
||||
use figcraft_geom::{Affine, Point};
|
||||
use figcraft_text::fontdb::FontBytes;
|
||||
use usvg::{FontFamily, TextAnchor};
|
||||
|
||||
use super::paint::{Target, paint};
|
||||
use super::shape::place;
|
||||
use super::{Ctx, children, finish, finite, layer_name, xf};
|
||||
|
||||
/// Family names FigCraft can draw (lower case).
|
||||
pub(crate) struct Fonts(HashSet<String>);
|
||||
|
||||
impl Fonts {
|
||||
fn load() -> Self {
|
||||
Fonts(figcraft_text::fontdb::family_names().into_iter().map(|f| f.to_lowercase()).collect())
|
||||
}
|
||||
fn has(&self, family: &str) -> bool {
|
||||
self.0.contains(&family.to_lowercase())
|
||||
}
|
||||
}
|
||||
|
||||
struct Shared(Arc<Vec<u8>>);
|
||||
|
||||
impl AsRef<[u8]> for Shared {
|
||||
fn as_ref(&self) -> &[u8] {
|
||||
self.0.as_slice()
|
||||
}
|
||||
}
|
||||
|
||||
/// Let usvg parse text: one face stands in for every family (see the module docs).
|
||||
pub(crate) fn prepare(o: &mut usvg::Options) {
|
||||
let Some(face) = figcraft_text::fontdb::find_face("Inter", "Regular") else { return };
|
||||
let source = match &face.bytes {
|
||||
FontBytes::Static(b) => usvg::fontdb::Source::Binary(Arc::new(*b)),
|
||||
FontBytes::Shared(v) => usvg::fontdb::Source::Binary(Arc::new(Shared(v.clone()))),
|
||||
};
|
||||
let mut db = usvg::fontdb::Database::new();
|
||||
db.load_font_source(source);
|
||||
o.fontdb = Arc::new(db);
|
||||
o.font_family = "serif".into();
|
||||
o.font_resolver.select_font = Box::new(|_, db| db.faces().next().map(|f| f.id));
|
||||
o.font_resolver.select_fallback = Box::new(|_, _, _| None);
|
||||
}
|
||||
|
||||
/// Installed stand-ins for the generic families, best first.
|
||||
fn generic(name: &str) -> Option<&'static [&'static str]> {
|
||||
const SANS: &[&str] = &["Inter", "Helvetica", "Arial", "Segoe UI", "Roboto", "Noto Sans", "DejaVu Sans", "Liberation Sans"];
|
||||
Some(match name.to_ascii_lowercase().as_str() {
|
||||
"serif" => &["Times New Roman", "Times", "Georgia", "Noto Serif", "DejaVu Serif", "Liberation Serif"],
|
||||
"monospace" => &["Menlo", "Consolas", "Courier New", "Noto Sans Mono", "DejaVu Sans Mono", "Liberation Mono"],
|
||||
"sans-serif" | "cursive" | "fantasy" | "system-ui" => SANS,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The family to use: the first the SVG lists that's installed, else the first it names, else the
|
||||
/// default.
|
||||
fn family(list: &[FontFamily], fonts: &Fonts) -> String {
|
||||
let name = |f: &FontFamily| match f {
|
||||
FontFamily::Serif => "serif".to_string(),
|
||||
FontFamily::SansSerif => "sans-serif".to_string(),
|
||||
FontFamily::Cursive => "cursive".to_string(),
|
||||
FontFamily::Fantasy => "fantasy".to_string(),
|
||||
FontFamily::Monospace => "monospace".to_string(),
|
||||
FontFamily::Named(n) => n.trim().to_string(),
|
||||
};
|
||||
for f in list {
|
||||
let n = name(f);
|
||||
match generic(&n) {
|
||||
Some(cands) => {
|
||||
if let Some(c) = cands.iter().find(|c| fonts.has(c)) {
|
||||
return (*c).to_string();
|
||||
}
|
||||
}
|
||||
None if fonts.has(&n) => return n,
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
list.iter().map(name).find(|n| generic(n).is_none() && !n.is_empty()).unwrap_or_else(|| TextStyle::default().font_family)
|
||||
}
|
||||
|
||||
/// A style name for a CSS weight and slant ("Semi Bold Italic").
|
||||
fn style_name(weight: u16, italic: bool) -> String {
|
||||
let w = match (weight + 50) / 100 {
|
||||
0 | 1 => "Thin",
|
||||
2 => "Extra Light",
|
||||
3 => "Light",
|
||||
4 => "Regular",
|
||||
5 => "Medium",
|
||||
6 => "Semi Bold",
|
||||
7 => "Bold",
|
||||
8 => "Extra Bold",
|
||||
_ => "Black",
|
||||
};
|
||||
match (w, italic) {
|
||||
("Regular", true) => "Italic".into(),
|
||||
(w, true) => format!("{w} Italic"),
|
||||
(w, false) => w.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn span_style(s: &usvg::TextSpan, scale: f64, fonts: &Fonts) -> TextStyle {
|
||||
let font = s.font();
|
||||
let deco = s.decoration();
|
||||
TextStyle {
|
||||
font_family: family(font.families(), fonts),
|
||||
font_style: style_name(font.weight(), font.style() != usvg::FontStyle::Normal),
|
||||
font_size: f64::from(s.font_size().get()) * scale,
|
||||
letter_spacing: {
|
||||
let v = f64::from(s.letter_spacing()) * scale;
|
||||
if v == 0.0 { LetterSpacing::default() } else { LetterSpacing::Pixels(v) }
|
||||
},
|
||||
decoration: if deco.underline().is_some() {
|
||||
TextDecoration::Underline
|
||||
} else if deco.line_through().is_some() {
|
||||
TextDecoration::Strikethrough
|
||||
} else {
|
||||
TextDecoration::None
|
||||
},
|
||||
case: if s.small_caps() { TextCase::SmallCaps } else { TextCase::Original },
|
||||
..TextStyle::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// One chunk: its anchor point (text space), characters and spans (char ranges) with their styles.
|
||||
struct Line<'a> {
|
||||
x: f64,
|
||||
y: f64,
|
||||
anchor: TextAnchor,
|
||||
text: String,
|
||||
spans: Vec<(usize, usize, &'a usvg::TextSpan, TextStyle)>,
|
||||
}
|
||||
|
||||
impl Line<'_> {
|
||||
/// The style of its first span.
|
||||
fn style(&self) -> Option<&TextStyle> {
|
||||
self.spans.first().map(|s| &s.3)
|
||||
}
|
||||
}
|
||||
|
||||
fn anchor_k(a: TextAnchor) -> f64 {
|
||||
match a {
|
||||
TextAnchor::Start => 0.0,
|
||||
TextAnchor::Middle => 0.5,
|
||||
TextAnchor::End => 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn text(cx: &mut Ctx, t: &usvg::Text, parent: NodeId, base: Affine, depth: usize) {
|
||||
let world = base * xf(t.abs_transform());
|
||||
if !finite(world) {
|
||||
return;
|
||||
}
|
||||
let along_path = t.chunks().iter().any(|c| matches!(c.text_flow(), usvg::TextFlow::Path(_)));
|
||||
if along_path || t.rotate().iter().any(|r| *r != 0.0) || t.writing_mode() == usvg::WritingMode::TopToBottom {
|
||||
cx.note("text on a path, vertical or with rotated glyphs came in as outlines");
|
||||
if let Some(id) = cx.group(parent, &layer_name(t.id(), "Text"), true) {
|
||||
children(cx, t.flattened(), id, base, depth + 1);
|
||||
finish(cx, id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Uniform scale goes into the font size; anything else stays in the layer's transform.
|
||||
let (m, s) = match place(world, Point::ZERO, 1.0, 1.0) {
|
||||
Some((m, sx, sy)) if (sx - sy).abs() <= 1e-6 * sx.max(sy) => (m, sx),
|
||||
_ => (world, 1.0),
|
||||
};
|
||||
let fonts = cx.fonts.get_or_insert_with(Fonts::load);
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
let (mut pen_x, mut pen_y, mut char_at) = (0.0, 0.0, 0usize);
|
||||
let chunks = t.chunks();
|
||||
for (i, chunk) in chunks.iter().enumerate() {
|
||||
let text = chunk.text();
|
||||
let count = text.chars().count();
|
||||
let d = |v: &[f32]| f64::from(v.get(char_at).copied().unwrap_or(0.0));
|
||||
let x = chunk.x().map_or(pen_x, f64::from) + d(t.dx());
|
||||
let y = chunk.y().map_or(pen_y, f64::from) + d(t.dy());
|
||||
char_at += count;
|
||||
let ci = |b: usize| text.get(..b).map_or(count, |p| p.chars().count());
|
||||
let spans: Vec<_> = chunk.spans().iter().map(|sp| (ci(sp.start()), ci(sp.end()), sp, span_style(sp, s, fonts))).collect();
|
||||
if spans.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let line = Line { x, y, anchor: chunk.anchor(), text: text.to_string(), spans };
|
||||
// The next chunk without its own x continues where this one ends.
|
||||
if chunks.get(i + 1).is_some_and(|c| c.x().is_none()) {
|
||||
let width = figcraft_text::layout(&text_data(std::slice::from_ref(&line), s), None, None).width / s;
|
||||
pen_x = x + (1.0 - anchor_k(line.anchor)) * width;
|
||||
}
|
||||
pen_y = y;
|
||||
lines.push(line);
|
||||
}
|
||||
let mut i = 0;
|
||||
while i < lines.len() {
|
||||
let mut j = i + 1;
|
||||
if let (Some(a), Some(b)) = (lines.get(i), lines.get(i + 1)) {
|
||||
let step = b.y - a.y;
|
||||
while let (Some(prev), Some(next)) = (lines.get(j - 1), lines.get(j)) {
|
||||
let same = next.anchor == a.anchor && (next.x - a.x).abs() < 0.01 && next.style() == a.style() && step > 0.0;
|
||||
if !same || (next.y - prev.y - step).abs() > 0.01 {
|
||||
break;
|
||||
}
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
if let Some(block) = lines.get(i..j) {
|
||||
layer(cx, t, block, m, s, world, parent);
|
||||
}
|
||||
i = j;
|
||||
}
|
||||
}
|
||||
|
||||
/// The text of `lines` (one per line, stacked at their pitch; `s`: their scale): characters, the
|
||||
/// first span's style and a run per span, fills still to come.
|
||||
fn text_data(lines: &[Line], s: f64) -> TextData {
|
||||
let mut td = TextData::new(lines.iter().map(|l| l.text.as_str()).collect::<Vec<_>>().join(
|
||||
"
|
||||
",
|
||||
));
|
||||
if let Some(style) = lines.first().and_then(Line::style) {
|
||||
td.style = style.clone();
|
||||
}
|
||||
if let (Some(a), Some(b)) = (lines.first(), lines.get(1)) {
|
||||
td.style.line_height = LineHeight::Pixels((b.y - a.y) * s);
|
||||
}
|
||||
let mut offset = 0;
|
||||
for l in lines {
|
||||
for (a, b, _, st) in &l.spans {
|
||||
td.runs.push(StyleRun { start: offset + a, end: offset + b, style: TextStyle { line_height: td.style.line_height, ..st.clone() } });
|
||||
}
|
||||
offset += l.text.chars().count() + 1;
|
||||
}
|
||||
td
|
||||
}
|
||||
|
||||
/// The text layer for `block` (lines sharing an anchor, x and pitch).
|
||||
fn layer(cx: &mut Ctx, t: &usvg::Text, block: &[Line], m: Affine, s: f64, world: Affine, parent: NodeId) {
|
||||
let Some(first) = block.first() else { return };
|
||||
let spans: Vec<&usvg::TextSpan> = block.iter().flat_map(|l| l.spans.iter().map(|sp| sp.2)).collect();
|
||||
if spans.iter().all(|sp| !sp.is_visible()) {
|
||||
return;
|
||||
}
|
||||
let mut td = text_data(block, s);
|
||||
td.align_h = match first.anchor {
|
||||
TextAnchor::Start => TextAlignH::Left,
|
||||
TextAnchor::Middle => TextAlignH::Center,
|
||||
TextAnchor::End => TextAlignH::Right,
|
||||
};
|
||||
// Placed by its layout (runs included): the anchor on x, the first baseline on y.
|
||||
let lay = figcraft_text::layout(&td, None, None);
|
||||
let (w, h) = (lay.width.ceil().max(1.0), lay.height.max(1.0));
|
||||
let baseline = lay.lines.first().map_or(0.0, |l| l.baseline);
|
||||
let transform = m * Affine::translate((s * first.x - anchor_k(first.anchor) * w, s * first.y - baseline));
|
||||
let target = Target { to_node: transform.inverse() * world, size: (w, h) };
|
||||
let mut fills = Vec::with_capacity(spans.len());
|
||||
for sp in &spans {
|
||||
let f: Vec<figcraft_color::Paint> =
|
||||
if sp.is_visible() { sp.fill().and_then(|f| paint(cx, f.paint(), f.opacity().get(), target)).into_iter().collect() } else { Vec::new() };
|
||||
fills.push(f);
|
||||
}
|
||||
let base_fill = fills.first().cloned().unwrap_or_default();
|
||||
for (run, f) in td.runs.iter_mut().zip(fills) {
|
||||
if f != base_fill {
|
||||
run.style.fills = Some(f);
|
||||
}
|
||||
}
|
||||
td.normalize();
|
||||
let name = if t.id().is_empty() { first.text.chars().take(64).collect() } else { layer_name(t.id(), "Text") };
|
||||
let mut n = Node::new(cx.doc.alloc_id(), NodeKind::Text(td), name, w, h);
|
||||
n.transform = transform;
|
||||
n.fills = base_fill;
|
||||
if let Some(st) = spans.first().and_then(|sp| sp.stroke()) {
|
||||
n.strokes = paint(cx, st.paint(), st.opacity().get(), target).into_iter().collect();
|
||||
n.stroke = super::stroke_props(st, s);
|
||||
}
|
||||
cx.add(n, parent);
|
||||
}
|
||||
|
|
@ -13,11 +13,12 @@
|
|||
//! mode), background blur and glass from a render of what lies under the layer among the exported
|
||||
//! layers, and layers with texture or a progressive layer blur whole.
|
||||
//!
|
||||
//! Import (via usvg) turns an SVG file into a frame of vector nodes.
|
||||
//! Import (via usvg) turns an SVG file into an editable frame: groups, shapes, text, gradients,
|
||||
//! masks and effects (see [`import_svg_with_notes`]).
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod import;
|
||||
pub use import::{ImportError, import_svg};
|
||||
pub use import::{ImportError, Imported, import_svg, import_svg_with_notes, looks_like_svg};
|
||||
|
||||
use std::fmt::Write as _;
|
||||
|
||||
|
|
@ -57,6 +58,8 @@ struct W<'a> {
|
|||
body: String,
|
||||
next: u32,
|
||||
opts: &'a SvgOptions,
|
||||
/// The mask layer being drawn as a mask's content (drawn like any layer, not skipped).
|
||||
mask: Option<NodeId>,
|
||||
}
|
||||
|
||||
fn num(v: f64) -> String {
|
||||
|
|
@ -270,7 +273,7 @@ impl W<'_> {
|
|||
return;
|
||||
}
|
||||
let Some(n) = self.doc.node(id) else { return };
|
||||
if !n.visible || n.is_mask {
|
||||
if !n.visible || (n.is_mask && self.mask != Some(id)) {
|
||||
return;
|
||||
}
|
||||
let xf = xf * n.transform;
|
||||
|
|
@ -342,17 +345,33 @@ impl W<'_> {
|
|||
while i < kids.len() {
|
||||
let Some(&c) = kids.get(i) else { break };
|
||||
if self.doc.node(c).is_some_and(|cn| cn.is_mask && cn.visible) {
|
||||
let mid = self.id("mask");
|
||||
let saved = std::mem::take(&mut self.body);
|
||||
if let Some(mn) = self.doc.node(c) {
|
||||
let mxf = xf * mn.transform;
|
||||
if let Some((mp, _)) = figcraft_render::node_outline(self.doc, mn) {
|
||||
let _ = write!(self.body, "<path transform=\"{}\" d=\"{}\" fill=\"white\"/>", matrix(mxf), path_d(&mp));
|
||||
}
|
||||
let kind = self.doc.node(c).map(|mn| mn.mask_type).unwrap_or_default();
|
||||
if kind == figcraft_doc::MaskType::Vector {
|
||||
// A vector mask is a clip path: its outline.
|
||||
let cid = self.id("clip");
|
||||
let outline =
|
||||
self.doc.node(c).and_then(|mn| Some((xf * mn.transform, figcraft_render::subtree_outline(self.doc, mn, depth + 1)?)));
|
||||
let (shape, cr) = match &outline {
|
||||
Some((mxf, (mp, rule))) => (
|
||||
format!(" transform=\"{}\" d=\"{}\"", matrix(*mxf), path_d(mp)),
|
||||
if *rule == figcraft_render::FillRule::EvenOdd { " clip-rule=\"evenodd\"" } else { "" },
|
||||
),
|
||||
None => (" d=\"\"".to_string(), ""),
|
||||
};
|
||||
let _ = write!(self.defs, "<clipPath id=\"{cid}\"><path{shape}{cr}/></clipPath>");
|
||||
let _ = write!(self.body, "<g clip-path=\"url(#{cid})\">");
|
||||
} else {
|
||||
// The layer as drawn: its alpha or luminance masks.
|
||||
let mid = self.id("mask");
|
||||
let saved = std::mem::take(&mut self.body);
|
||||
let outer = self.mask.replace(c);
|
||||
self.node(c, xf, depth + 1);
|
||||
self.mask = outer;
|
||||
let mask_body = std::mem::replace(&mut self.body, saved);
|
||||
let alpha = if kind == figcraft_doc::MaskType::Alpha { " style=\"mask-type:alpha\"" } else { "" };
|
||||
let _ = write!(self.defs, "<mask id=\"{mid}\" maskUnits=\"userSpaceOnUse\"{alpha}>{mask_body}</mask>");
|
||||
let _ = write!(self.body, "<g mask=\"url(#{mid})\">");
|
||||
}
|
||||
let mask_body = std::mem::replace(&mut self.body, saved);
|
||||
let _ = write!(self.defs, "<mask id=\"{mid}\" maskUnits=\"userSpaceOnUse\">{mask_body}</mask>");
|
||||
let _ = write!(self.body, "<g mask=\"url(#{mid})\">");
|
||||
let mut j = i + 1;
|
||||
while let Some(&k) = kids.get(j) {
|
||||
if self.doc.node(k).is_some_and(|kn| kn.is_mask) {
|
||||
|
|
@ -619,7 +638,7 @@ fn sniff_mime(b: &[u8]) -> &'static str {
|
|||
pub fn export_svg(doc: &Document, ids: &[NodeId], opts: &SvgOptions) -> Option<String> {
|
||||
let bounds: Rect = ids.iter().filter_map(|i| figcraft_render::export_bounds(doc, *i)).reduce(|a, b| a.union(b))?;
|
||||
let origin = Affine::translate((-bounds.x0, -bounds.y0));
|
||||
let mut w = W { doc, roots: ids, page: origin, defs: String::new(), body: String::new(), next: 0, opts };
|
||||
let mut w = W { doc, roots: ids, page: origin, defs: String::new(), body: String::new(), next: 0, opts, mask: None };
|
||||
for id in ids {
|
||||
let parent_xf = doc.node(*id).and_then(|n| n.parent).map(|p| doc.world_transform(p)).unwrap_or(Affine::IDENTITY);
|
||||
w.node(*id, origin * parent_xf, 0);
|
||||
|
|
@ -640,6 +659,8 @@ pub fn export_svg(doc: &Document, ids: &[NodeId], opts: &SvgOptions) -> Option<S
|
|||
#[cfg(test)]
|
||||
mod effects_tests;
|
||||
#[cfg(test)]
|
||||
mod mask_export_tests;
|
||||
#[cfg(test)]
|
||||
mod raster_fx_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
//! Masks in SVG export: vector masks as clip paths, alpha and luminance masks as the mask layer
|
||||
//! drawn (gradients and opacity kept), alpha ones marked `mask-type:alpha`.
|
||||
|
||||
use figcraft_color::{Color, ColorStop, Gradient, GradientKind, Paint};
|
||||
use figcraft_doc::{Document, MaskType, NodeId, NodeKind};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A group holding a mask of `kind` (a white → clear gradient over the left half) over a red square.
|
||||
fn masked(kind: MaskType) -> (Document, NodeId) {
|
||||
let mut d = Document::new("masks");
|
||||
let p = d.first_page().unwrap();
|
||||
let g = d.create(p, NodeKind::Group, "G", 0.0, 0.0, 100.0, 100.0).unwrap();
|
||||
let m = d.create(g, NodeKind::Rectangle, "M", 0.0, 0.0, 50.0, 100.0).unwrap();
|
||||
let stops = vec![ColorStop { position: 0.0, color: Color::WHITE }, ColorStop { position: 1.0, color: Color::new(0.0, 0.0, 0.0, 0.0) }];
|
||||
let node = d.get_mut(m).unwrap();
|
||||
node.fills = vec![Paint::gradient(Gradient { kind: GradientKind::Linear, stops, from: (0.0, 0.5), to: (1.0, 0.5), width: (0.0, 1.0) })];
|
||||
node.is_mask = true;
|
||||
node.mask_type = kind;
|
||||
let r = d.create(g, NodeKind::Rectangle, "R", 0.0, 0.0, 100.0, 100.0).unwrap();
|
||||
d.get_mut(r).unwrap().fills = vec![Paint::solid(Color::from_hex("FF0000").unwrap())];
|
||||
(d, g)
|
||||
}
|
||||
|
||||
fn mask_def(svg: &str) -> &str {
|
||||
let start = svg.find("<mask").unwrap();
|
||||
let end = svg[start..].find("</mask>").unwrap();
|
||||
&svg[start..start + end]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_mask_type_exports_as_it_masks() {
|
||||
let export = |kind| {
|
||||
let (d, g) = masked(kind);
|
||||
export_svg(&d, &[g], &SvgOptions::default()).unwrap()
|
||||
};
|
||||
let vector = export(MaskType::Vector);
|
||||
assert!(vector.contains("<clipPath") && vector.contains("clip-path=\"url(#clip") && !vector.contains("<mask"), "{vector}");
|
||||
let luminance = export(MaskType::Luminance);
|
||||
assert!(luminance.contains("<linearGradient") && mask_def(&luminance).contains("url(#"), "{luminance}");
|
||||
assert!(!mask_def(&luminance).contains("mask-type"), "{luminance}");
|
||||
let alpha = export(MaskType::Alpha);
|
||||
assert!(mask_def(&alpha).contains("style=\"mask-type:alpha\"") && mask_def(&alpha).contains("url(#"), "{alpha}");
|
||||
// The mask layer itself isn't drawn outside its mask.
|
||||
assert_eq!(alpha.matches("url(#paint").count(), 1, "{alpha}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masks_survive_export_and_import() {
|
||||
for kind in [MaskType::Vector, MaskType::Luminance, MaskType::Alpha] {
|
||||
let (d, g) = masked(kind);
|
||||
let svg = export_svg(&d, &[g], &SvgOptions::default()).unwrap();
|
||||
let mut d2 = Document::new("back");
|
||||
let p = d2.first_page().unwrap();
|
||||
let f = import_svg(&mut d2, svg.as_bytes(), p, 0.0, 0.0, "x").unwrap();
|
||||
let mask = d2.subtree(f).into_iter().filter_map(|i| d2.node(i)).find(|n| n.is_mask).unwrap();
|
||||
assert_eq!(mask.mask_type, kind);
|
||||
}
|
||||
}
|
||||
|
|
@ -41,5 +41,5 @@ fn import_gradient_and_groups() {
|
|||
let f = import_svg(&mut d, svg, p, 0.0, 0.0, "x").unwrap();
|
||||
let kinds: Vec<&str> = d.subtree(f).iter().map(|i| d.node(*i).unwrap().kind.type_name()).collect();
|
||||
assert!(kinds.contains(&"GROUP"));
|
||||
assert!(kinds.iter().filter(|k| **k == "VECTOR").count() >= 2);
|
||||
assert!(kinds.contains(&"RECTANGLE") && kinds.contains(&"ELLIPSE"), "{kinds:?}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,434 @@
|
|||
//! The SVG import corpus: synthetic files covering what design and illustration tools write.
|
||||
//!
|
||||
//! Each `tests/corpus/*.svg` carries its expected pixels as `<!-- probe X Y #RRGGBBAA ~TOL -->`
|
||||
//! comments, worked out by hand from the SVG. The import is rendered with FigCraft's renderer and
|
||||
//! every probe checked; the layer structure is checked per file; and every file must survive an
|
||||
//! export and a second import looking the same.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use figcraft_doc::{Document, NodeId, NodeKind};
|
||||
use figcraft_render::{View, to_rgba8};
|
||||
use figcraft_svg::{SvgOptions, export_svg, import_svg};
|
||||
|
||||
const DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/corpus");
|
||||
|
||||
fn corpus() -> Vec<(String, String)> {
|
||||
let mut v: Vec<(String, String)> = std::fs::read_dir(DIR)
|
||||
.unwrap()
|
||||
.map(|e| e.unwrap().path())
|
||||
.filter(|p| p.extension().is_some_and(|x| x == "svg"))
|
||||
.map(|p| (p.file_stem().unwrap().to_string_lossy().into_owned(), std::fs::read_to_string(&p).unwrap()))
|
||||
.collect();
|
||||
v.sort();
|
||||
assert!(v.len() >= 15, "corpus missing");
|
||||
v
|
||||
}
|
||||
|
||||
fn source(name: &str) -> String {
|
||||
std::fs::read_to_string(format!("{DIR}/{name}.svg")).unwrap()
|
||||
}
|
||||
|
||||
pub fn import(src: &[u8]) -> (Document, NodeId) {
|
||||
let mut d = Document::new("corpus");
|
||||
let p = d.first_page().unwrap();
|
||||
let f = import_svg(&mut d, src, p, 0.0, 0.0, "Imported").unwrap();
|
||||
assert!(d.validate().is_empty(), "{:?}", d.validate());
|
||||
(d, f)
|
||||
}
|
||||
|
||||
/// The frame's area rendered at 1x, straight RGBA.
|
||||
fn render(d: &Document, frame: NodeId) -> (Vec<u8>, u32, u32) {
|
||||
let n = d.node(frame).unwrap();
|
||||
let (w, h) = (n.width.ceil().max(1.0) as u32, n.height.ceil().max(1.0) as u32);
|
||||
let o = d.world_transform(frame).translation();
|
||||
let view = View { origin: (o.x, o.y), scale: 1.0, width: w, height: h };
|
||||
let mut pix = figcraft_render::Pixmap::new(w, h).unwrap();
|
||||
figcraft_render::render_nodes_into(d, &[frame], &view, &mut pix);
|
||||
(to_rgba8(&pix), w, h)
|
||||
}
|
||||
|
||||
struct Probe {
|
||||
x: u32,
|
||||
y: u32,
|
||||
rgba: [u8; 4],
|
||||
tol: u8,
|
||||
}
|
||||
|
||||
fn probes(src: &str) -> Vec<Probe> {
|
||||
src.lines()
|
||||
.filter_map(|l| {
|
||||
let l = l.trim().strip_prefix("<!-- probe ")?.strip_suffix("-->")?;
|
||||
let mut it = l.split_whitespace();
|
||||
let x = it.next()?.parse().ok()?;
|
||||
let y = it.next()?.parse().ok()?;
|
||||
let hex = it.next()?.strip_prefix('#')?;
|
||||
let b = |i: usize| u8::from_str_radix(hex.get(i..i + 2)?, 16).ok();
|
||||
let rgba = [b(0)?, b(2)?, b(4)?, b(6)?];
|
||||
let tol = it.next().and_then(|t| t.strip_prefix('~')).and_then(|t| t.parse().ok()).unwrap_or(8);
|
||||
Some(Probe { x, y, rgba, tol })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn pixel(px: &[u8], w: u32, x: u32, y: u32) -> [u8; 4] {
|
||||
let i = ((y * w + x) * 4) as usize;
|
||||
[px[i], px[i + 1], px[i + 2], px[i + 3]]
|
||||
}
|
||||
|
||||
/// Probe mismatches of `name` as messages.
|
||||
fn check(name: &str, src: &str, d: &Document, f: NodeId) -> Vec<String> {
|
||||
let (px, w, h) = render(d, f);
|
||||
let mut bad = Vec::new();
|
||||
for p in probes(src) {
|
||||
if p.x >= w || p.y >= h {
|
||||
bad.push(format!("{name}: probe {} {} outside {w}x{h}", p.x, p.y));
|
||||
continue;
|
||||
}
|
||||
let got = pixel(&px, w, p.x, p.y);
|
||||
let diff = |i: usize| got[i].abs_diff(p.rgba[i]);
|
||||
// Colour only means something where there's enough coverage.
|
||||
let ok = if p.rgba[3] < 32 { diff(3) <= p.tol } else { (0..4).all(|i| diff(i) <= p.tol) };
|
||||
if !ok {
|
||||
bad.push(format!("{name}: probe {} {} expected {:02x?} got {:02x?} (±{})", p.x, p.y, p.rgba, got, p.tol));
|
||||
}
|
||||
}
|
||||
bad
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corpus_renders_match_the_probes() {
|
||||
let mut bad = Vec::new();
|
||||
let mut count = 0;
|
||||
for (name, src) in corpus() {
|
||||
let (d, f) = import(src.as_bytes());
|
||||
count += probes(&src).len();
|
||||
bad.extend(check(&name, &src, &d, f));
|
||||
}
|
||||
assert!(count > 140, "{count} probes");
|
||||
assert!(bad.is_empty(), "{} of {count} probes failed:\n{}", bad.len(), bad.join("\n"));
|
||||
}
|
||||
|
||||
/// Mean absolute difference per channel between two renders of the same size.
|
||||
fn mean_diff(a: &[u8], b: &[u8]) -> f64 {
|
||||
let sum: u64 = a.iter().zip(b).map(|(x, y)| u64::from(x.abs_diff(*y))).sum();
|
||||
sum as f64 / a.len().max(1) as f64
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corpus_round_trips_through_export() {
|
||||
let mut bad = Vec::new();
|
||||
for (name, src) in corpus() {
|
||||
let (d, f) = import(src.as_bytes());
|
||||
let svg = export_svg(&d, &[f], &SvgOptions::default()).unwrap();
|
||||
let (d2, f2) = import(svg.as_bytes());
|
||||
let (a, w, h) = render(&d, f);
|
||||
let (b, w2, h2) = render(&d2, f2);
|
||||
if (w, h) != (w2, h2) {
|
||||
bad.push(format!("{name}: size {w}x{h} became {w2}x{h2}"));
|
||||
continue;
|
||||
}
|
||||
let m = mean_diff(&a, &b);
|
||||
if m > 1.5 {
|
||||
bad.push(format!("{name}: mean difference {m:.2} after export and import"));
|
||||
}
|
||||
// The probes still hold on the second import.
|
||||
bad.extend(check(&format!("{name} (round trip)"), &src, &d2, f2).into_iter().filter(|_| !name.starts_with("text")));
|
||||
// And exporting again gives the same file.
|
||||
let again = export_svg(&d2, &[f2], &SvgOptions::default()).unwrap();
|
||||
let (d3, f3) = import(again.as_bytes());
|
||||
let (c, ..) = render(&d3, f3);
|
||||
if mean_diff(&b, &c) > 0.5 {
|
||||
bad.push(format!("{name}: not stable on a second round trip"));
|
||||
}
|
||||
}
|
||||
assert!(bad.is_empty(), "{}", bad.join("\n"));
|
||||
}
|
||||
|
||||
/// Writes each import's render and layer tree to `$SVG_DUMP_DIR` (run with `--ignored`).
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn dump() {
|
||||
let Ok(dir) = std::env::var("SVG_DUMP_DIR") else { return };
|
||||
for (name, src) in corpus() {
|
||||
let (d, f) = import(src.as_bytes());
|
||||
let (px, w, h) = render(&d, f);
|
||||
let img = image_png(&px, w, h);
|
||||
std::fs::write(format!("{dir}/{name}.png"), img).unwrap();
|
||||
let mut tree = String::new();
|
||||
outline(&d, f, 0, &mut tree);
|
||||
std::fs::write(format!("{dir}/{name}.txt"), tree).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn image_png(px: &[u8], w: u32, h: u32) -> Vec<u8> {
|
||||
let mut pix = figcraft_render::Pixmap::new(w, h).unwrap();
|
||||
for (i, c) in px.chunks(4).enumerate() {
|
||||
let a = u32::from(c[3]);
|
||||
let pm = |v: u8| ((u32::from(v) * a + 127) / 255) as u8;
|
||||
let d = pix.data_mut();
|
||||
d[i * 4] = pm(c[0]);
|
||||
d[i * 4 + 1] = pm(c[1]);
|
||||
d[i * 4 + 2] = pm(c[2]);
|
||||
d[i * 4 + 3] = c[3];
|
||||
}
|
||||
figcraft_render::encode_png(&pix).unwrap()
|
||||
}
|
||||
|
||||
fn outline(d: &Document, id: NodeId, depth: usize, out: &mut String) {
|
||||
let n = d.node(id).unwrap();
|
||||
let c = n.transform.as_coeffs();
|
||||
let extra = match &n.kind {
|
||||
NodeKind::Text(t) => format!(" {:?} {} {} {:?}", t.characters, t.style.font_family, t.style.font_size, t.align_h),
|
||||
_ => String::new(),
|
||||
};
|
||||
out.push_str(&format!(
|
||||
"{}{} {:?} {:.1}x{:.1} [{:.2} {:.2} {:.2} {:.2} {:.1} {:.1}] fills={} strokes={} fx={} mask={} op={}{extra}\n",
|
||||
" ".repeat(depth),
|
||||
n.kind.type_name(),
|
||||
n.name,
|
||||
n.width,
|
||||
n.height,
|
||||
c[0],
|
||||
c[1],
|
||||
c[2],
|
||||
c[3],
|
||||
c[4],
|
||||
c[5],
|
||||
n.fills.len(),
|
||||
n.strokes.len(),
|
||||
n.effects.len(),
|
||||
n.is_mask,
|
||||
n.opacity
|
||||
));
|
||||
for k in &n.children {
|
||||
outline(d, *k, depth + 1, out);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Layer structure: editable and sensible ----
|
||||
|
||||
fn imported(name: &str) -> (Document, NodeId) {
|
||||
import(source(name).as_bytes())
|
||||
}
|
||||
|
||||
/// The first layer called `name` under `f`.
|
||||
fn layer<'a>(d: &'a Document, f: NodeId, name: &str) -> &'a figcraft_doc::Node {
|
||||
d.subtree(f).into_iter().filter_map(|i| d.node(i)).find(|n| n.name == name).unwrap_or_else(|| panic!("no layer {name:?}"))
|
||||
}
|
||||
|
||||
fn kinds(d: &Document, f: NodeId) -> Vec<&'static str> {
|
||||
d.subtree(f).into_iter().skip(1).filter_map(|i| d.node(i)).map(|n| n.kind.type_name()).collect()
|
||||
}
|
||||
|
||||
fn count(d: &Document, f: NodeId, kind: &str) -> usize {
|
||||
kinds(d, f).iter().filter(|k| **k == kind).count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn groups_keep_their_ids_and_transforms_bake_into_shapes() {
|
||||
let (d, f) = imported("transforms");
|
||||
for g in ["moved", "turned", "scaled", "skewed"] {
|
||||
assert_eq!(layer(&d, f, g).kind.type_name(), "GROUP");
|
||||
}
|
||||
// Rotated: still a 40×40 rectangle, turned 45°.
|
||||
let b = layer(&d, f, "b");
|
||||
assert_eq!(b.kind.type_name(), "RECTANGLE");
|
||||
assert!((b.width - 40.0).abs() < 1e-6 && (b.height - 40.0).abs() < 1e-6);
|
||||
let c = d.world_transform(d.subtree(f).into_iter().find(|i| d.node(*i).is_some_and(|n| n.name == "b")).unwrap()).as_coeffs();
|
||||
assert!((c[1].atan2(c[0]).to_degrees() - 45.0).abs() < 1e-6, "{c:?}");
|
||||
// Scaled 2 × 1.5: the size takes it.
|
||||
let sc = layer(&d, f, "c");
|
||||
assert_eq!((sc.kind.type_name(), sc.width.round(), sc.height.round()), ("RECTANGLE", 40.0, 30.0));
|
||||
// Skewed: only a vector can take it.
|
||||
assert_eq!(layer(&d, f, "d").kind.type_name(), "VECTOR");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn basic_shapes_stay_rectangles_ellipses_and_lines() {
|
||||
let (d, f) = imported("primitives");
|
||||
let card = layer(&d, f, "card");
|
||||
assert_eq!((card.kind.type_name(), card.width, card.height), ("RECTANGLE", 50.0, 30.0));
|
||||
assert_eq!(card.corners.radii, [8.0; 4]);
|
||||
assert_eq!(count(&d, f, "ELLIPSE"), 2);
|
||||
assert_eq!(count(&d, f, "LINE"), 2);
|
||||
// Elliptical corners, a polyline and a polygon are vectors.
|
||||
assert_eq!(count(&d, f, "VECTOR"), 3);
|
||||
let line = d.subtree(f).into_iter().filter_map(|i| d.node(i)).find(|n| n.kind.type_name() == "LINE").unwrap();
|
||||
assert_eq!((line.width, line.stroke.weight, line.strokes.len()), (180.0, 2.0, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strokes_keep_dashes_caps_and_joins() {
|
||||
use figcraft_doc::{StrokeCap, StrokeJoin};
|
||||
let (d, f) = imported("strokes");
|
||||
assert_eq!(layer(&d, f, "dashed").stroke.dashes, vec![20.0, 10.0]);
|
||||
assert_eq!(layer(&d, f, "round-cap").stroke.cap_end, StrokeCap::Round);
|
||||
assert_eq!(layer(&d, f, "square-cap").stroke.cap_start, StrokeCap::Square);
|
||||
assert_eq!(layer(&d, f, "butt-cap").stroke.cap_start, StrokeCap::None);
|
||||
assert_eq!(layer(&d, f, "round-join").stroke.join, StrokeJoin::Round);
|
||||
assert_eq!(layer(&d, f, "bevel").stroke.join, StrokeJoin::Bevel);
|
||||
assert_eq!(layer(&d, f, "miter").stroke.miter_limit, 4.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradients_stay_gradients() {
|
||||
use figcraft_color::{GradientKind, PaintKind};
|
||||
let (d, f) = imported("gradients");
|
||||
for (name, kind) in
|
||||
[("bbox-units", GradientKind::Linear), ("rotated", GradientKind::Linear), ("radial", GradientKind::Radial), ("focal", GradientKind::Radial)]
|
||||
{
|
||||
let n = layer(&d, f, name);
|
||||
assert!(matches!(&n.fills[0].kind, PaintKind::Gradient(g) if g.kind == kind), "{name}: {:?}", n.fills);
|
||||
}
|
||||
// Reflect over four periods: the stops are unrolled.
|
||||
let PaintKind::Gradient(g) = &layer(&d, f, "reflect").fills[0].kind else { panic!() };
|
||||
assert_eq!(g.stops.len(), 8, "{:?}", g.stops);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_paths_and_masks_become_mask_layers() {
|
||||
use figcraft_doc::MaskType;
|
||||
let (d, f) = imported("clip_mask");
|
||||
let mask_of = |name: &str| {
|
||||
let id = d.subtree(f).into_iter().find(|i| d.node(*i).is_some_and(|n| n.name == name)).unwrap();
|
||||
let parent = d.node(d.node(id).unwrap().parent.unwrap()).unwrap();
|
||||
let m = d.node(parent.children[0]).unwrap();
|
||||
assert!(m.is_mask, "{name}: {parent:?}");
|
||||
(m.kind.type_name(), m.mask_type, parent.name.clone())
|
||||
};
|
||||
assert_eq!(mask_of("clipped"), ("ELLIPSE", MaskType::Vector, "Mask group".into()));
|
||||
assert_eq!(mask_of("masked"), ("GROUP", MaskType::Luminance, "Mask group".into()));
|
||||
assert_eq!(mask_of("faded").1, MaskType::Luminance);
|
||||
assert_eq!(mask_of("alpha-masked").1, MaskType::Alpha);
|
||||
// The clipped group keeps its name, with the clip at the bottom.
|
||||
let g = layer(&d, f, "clipped-group");
|
||||
assert!(d.node(g.children[0]).unwrap().is_mask && g.children.len() == 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filters_become_effects() {
|
||||
use figcraft_doc::EffectKind;
|
||||
let (d, f) = imported("filters");
|
||||
let fx = |name: &str| layer(&d, f, name).effects.iter().map(|e| e.kind.clone()).collect::<Vec<_>>();
|
||||
assert!(matches!(&fx("blurred")[..], [EffectKind::LayerBlur(b)] if (b.radius - 8.0).abs() < 1e-6));
|
||||
let [EffectKind::DropShadow(s)] = &fx("dropped")[..] else { panic!("{:?}", fx("dropped")) };
|
||||
assert_eq!((s.offset, s.radius, s.color.a), ((4.0, 6.0), 4.0, 0.5));
|
||||
let [EffectKind::DropShadow(s)] = &fx("classic-shadow")[..] else { panic!("{:?}", fx("classic-shadow")) };
|
||||
assert_eq!((s.offset, s.radius), ((0.0, 4.0), 6.0));
|
||||
assert!((s.color.r - 1.0).abs() < 1e-6 && (s.color.a - 0.25).abs() < 1e-6);
|
||||
let [EffectKind::DropShadow(drop), EffectKind::InnerShadow(inner)] = &fx("matrixed")[..] else { panic!("{:?}", fx("matrixed")) };
|
||||
assert_eq!((drop.offset, drop.radius, inner.offset, inner.radius), ((2.0, 2.0), 2.0, (0.0, -3.0), 3.0));
|
||||
assert!(!drop.show_behind, "cut out under the layer");
|
||||
assert!(fx("unsupported").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_parts_are_reported() {
|
||||
let mut d = Document::new("t");
|
||||
let p = d.first_page().unwrap();
|
||||
let imported = figcraft_svg::import_svg_with_notes(&mut d, source("filters").as_bytes(), p, 0.0, 0.0, "x").unwrap();
|
||||
assert!(imported.notes.iter().any(|n| n.contains("noise")), "{:?}", imported.notes);
|
||||
let clean = figcraft_svg::import_svg_with_notes(&mut d, source("primitives").as_bytes(), p, 0.0, 0.0, "x").unwrap();
|
||||
assert!(clean.notes.is_empty(), "{:?}", clean.notes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blend_modes_opacity_and_hidden_layers() {
|
||||
use figcraft_color::BlendMode;
|
||||
let (d, f) = imported("blend_visibility");
|
||||
assert_eq!(layer(&d, f, "multiplied").blend, BlendMode::Multiply);
|
||||
assert_eq!(layer(&d, f, "screened").blend, BlendMode::Screen);
|
||||
for gone in ["gone", "hidden"] {
|
||||
assert!(d.subtree(f).into_iter().all(|i| d.node(i).is_some_and(|n| n.name != gone)), "{gone}");
|
||||
}
|
||||
assert_eq!(layer(&d, f, "shown").kind.type_name(), "RECTANGLE");
|
||||
let (d, f) = imported("opacity");
|
||||
assert_eq!(layer(&d, f, "faded").opacity, 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sizes_follow_units_and_view_boxes() {
|
||||
let size = |name: &str| {
|
||||
let (d, f) = imported(name);
|
||||
let n = d.node(f).unwrap();
|
||||
(n.width.round(), n.height.round())
|
||||
};
|
||||
assert_eq!(size("units"), (100.0, 96.0));
|
||||
assert_eq!(size("viewbox_meet"), (200.0, 100.0));
|
||||
assert_eq!(size("illustration"), (120.0, 80.0));
|
||||
assert_eq!(size("icon"), (48.0, 48.0));
|
||||
// The icon's 2 px strokes on a 24 grid drawn at 48 px are 4 px.
|
||||
let (d, f) = imported("icon");
|
||||
let ring = d.subtree(f).into_iter().filter_map(|i| d.node(i)).find(|n| n.kind.type_name() == "ELLIPSE").unwrap();
|
||||
assert_eq!((ring.width, ring.stroke.weight, ring.fills.len()), (36.0, 4.0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn use_and_symbols_resolve_without_extra_wrappers() {
|
||||
let (d, f) = imported("use_symbol");
|
||||
assert_eq!(kinds(&d, f), ["ELLIPSE", "RECTANGLE", "RECTANGLE"]);
|
||||
let (d, f) = imported("illustration");
|
||||
assert_eq!(layer(&d, f, "Walls").kind.type_name(), "GROUP");
|
||||
assert!(d.subtree(f).into_iter().all(|i| !d.node(i).unwrap().is_mask));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn images_are_image_fills() {
|
||||
use figcraft_color::{ImageScaleMode, PaintKind};
|
||||
let (d, f) = imported("image");
|
||||
let photo = layer(&d, f, "photo");
|
||||
assert_eq!((photo.kind.type_name(), photo.width, photo.height), ("RECTANGLE", 40.0, 20.0));
|
||||
assert!(matches!(&photo.fills[0].kind, PaintKind::Image(i) if i.scale_mode == ImageScaleMode::Fill));
|
||||
assert_eq!(d.images.len(), 1, "the same image is stored once");
|
||||
assert_eq!(d.images.values().next().map(|i| (i.width, i.height)), Some((4, 2)));
|
||||
// Patterns: a repeating tile, and images (drawn or embedded) shown once.
|
||||
let (d, f) = imported("patterns");
|
||||
let mode = |name: &str| match &layer(&d, f, name).fills[0].kind {
|
||||
PaintKind::Image(i) => i.scale_mode,
|
||||
k => panic!("{name}: {k:?}"),
|
||||
};
|
||||
assert_eq!((mode("tiled"), mode("spotted"), mode("pictured")), (ImageScaleMode::Tile, ImageScaleMode::Fill, ImageScaleMode::Fill));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_becomes_text_layers() {
|
||||
use figcraft_doc::{LetterSpacing, LineHeight, TextAlignH};
|
||||
let (d, f) = imported("text");
|
||||
let texts: Vec<&figcraft_doc::Node> = d.subtree(f).into_iter().filter_map(|i| d.node(i)).filter(|n| n.kind.text().is_some()).collect();
|
||||
if figcraft_text::fontdb::find_face("Inter", "Regular").is_none() {
|
||||
// Without any font usvg can't lay text out (CI without fonts).
|
||||
return;
|
||||
}
|
||||
assert_eq!(texts.len(), 5, "{:?}", texts.iter().map(|t| &t.name).collect::<Vec<_>>());
|
||||
let t = |name: &str| layer(&d, f, name).kind.text().unwrap().clone();
|
||||
let title = t("title");
|
||||
assert_eq!((title.characters.as_str(), title.style.font_size, title.style.font_style.as_str()), ("Hello", 24.0, "Bold"));
|
||||
let sans = ["Inter", "Helvetica", "Arial", "Segoe UI", "Roboto", "Noto Sans", "DejaVu Sans", "Liberation Sans"];
|
||||
assert!(sans.contains(&title.style.font_family.as_str()), "{}", title.style.font_family);
|
||||
let centered = t("Centered");
|
||||
assert_eq!((centered.align_h, centered.style.letter_spacing), (TextAlignH::Center, LetterSpacing::Pixels(2.0)));
|
||||
assert_eq!(t("End").align_h, TextAlignH::Right);
|
||||
assert_eq!(t("End").style.font_style, "Italic");
|
||||
// Two tspans 20 apart: one layer, two lines.
|
||||
let lines = t("Line one");
|
||||
assert_eq!((lines.characters.as_str(), lines.style.line_height), ("Line one\nLine two", LineHeight::Pixels(20.0)));
|
||||
// A bold green tspan: a run.
|
||||
let run = t("Plain bold run");
|
||||
assert_eq!(run.runs.len(), 1, "{:?}", run.runs);
|
||||
assert_eq!((run.runs[0].start, run.runs[0].end, run.runs[0].style.font_style.as_str()), (6, 10, "Bold"));
|
||||
assert!(run.runs[0].style.fills.is_some());
|
||||
// The first baseline sits on y; the anchor on x.
|
||||
for (name, x, y) in [("title", 10.0, 40.0), ("Centered", 150.0, 80.0), ("End", 290.0, 120.0)] {
|
||||
let id = d.subtree(f).into_iter().find(|i| d.node(*i).is_some_and(|n| n.name == name)).unwrap();
|
||||
let n = d.node(id).unwrap();
|
||||
let (l, _) = figcraft_text::layout_node(n).unwrap();
|
||||
let o = d.world_transform(id).translation();
|
||||
let k = match n.kind.text().unwrap().align_h {
|
||||
TextAlignH::Center => 0.5,
|
||||
TextAlignH::Right => 1.0,
|
||||
_ => 0.0,
|
||||
};
|
||||
assert!((o.x + k * n.width - x).abs() < 0.01, "{name}: x {}", o.x);
|
||||
assert!((o.y + l.lines[0].baseline - y).abs() < 0.01, "{name}: y {}", o.y);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100">
|
||||
<!-- Arcs (sweep flags, relative), H/V and smooth curves in path data. -->
|
||||
<path d="M10 50 A40 40 0 0 1 90 50 Z" fill="#ff0000"/>
|
||||
<path d="m110 50 a40 40 0 0 0 80 0 z" fill="#0000ff"/>
|
||||
<path d="M10 90 H50 V96 H10 Z" fill="#000000"/>
|
||||
<path d="M60 96 Q70 86 80 96 T100 96 S120 86 130 96 Z" fill="#00ff00"/>
|
||||
<!-- probe 50 20 #ff0000ff -->
|
||||
<!-- probe 50 70 #00000000 -->
|
||||
<!-- probe 150 80 #0000ffff -->
|
||||
<!-- probe 150 20 #00000000 -->
|
||||
<!-- probe 30 92 #000000ff -->
|
||||
<!-- probe 70 94 #00ff00ff ~40 -->
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 592 B |
|
|
@ -0,0 +1,18 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100">
|
||||
<!-- mix-blend-mode on shapes and groups; display:none, visibility hidden and a visible child. -->
|
||||
<rect width="100" height="100" fill="#ffff00"/>
|
||||
<rect id="multiplied" x="10" y="10" width="30" height="30" fill="#00ffff" style="mix-blend-mode:multiply"/>
|
||||
<g id="screened" style="mix-blend-mode:screen"><rect x="50" y="10" width="30" height="30" fill="#0000ff"/></g>
|
||||
<rect id="gone" x="110" y="10" width="30" height="30" fill="#ff0000" style="display:none"/>
|
||||
<g visibility="hidden">
|
||||
<rect id="hidden" x="150" y="10" width="30" height="30" fill="#ff0000"/>
|
||||
<rect id="shown" x="150" y="50" width="30" height="30" fill="#00ff00" visibility="visible"/>
|
||||
</g>
|
||||
<g display="none"><rect x="110" y="50" width="30" height="30" fill="#ff0000"/></g>
|
||||
<!-- probe 25 25 #00ff00ff -->
|
||||
<!-- probe 65 25 #ffffffff -->
|
||||
<!-- probe 125 25 #00000000 -->
|
||||
<!-- probe 165 25 #00000000 -->
|
||||
<!-- probe 165 65 #00ff00ff -->
|
||||
<!-- probe 125 65 #00000000 -->
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
|
|
@ -0,0 +1,35 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="300" height="200">
|
||||
<defs>
|
||||
<clipPath id="c1"><circle cx="50" cy="50" r="30"/></clipPath>
|
||||
<clipPath id="c2" clipPathUnits="objectBoundingBox"><rect width="0.5" height="1"/></clipPath>
|
||||
<mask id="m1">
|
||||
<rect y="100" width="50" height="100" fill="#ffffff"/>
|
||||
<rect x="50" y="100" width="50" height="100" fill="#000000"/>
|
||||
</mask>
|
||||
<linearGradient id="mg"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#000000"/></linearGradient>
|
||||
<mask id="m2"><rect x="100" y="100" width="100" height="100" fill="url(#mg)"/></mask>
|
||||
<mask id="m3" style="mask-type:alpha"><rect x="200" width="100" height="100" fill="#000000" fill-opacity="0.5"/></mask>
|
||||
</defs>
|
||||
<!-- A clip path on a shape, an objectBoundingBox clip path on a group. -->
|
||||
<rect id="clipped" width="100" height="100" fill="#ff0000" clip-path="url(#c1)"/>
|
||||
<g id="clipped-group" clip-path="url(#c2)">
|
||||
<rect x="100" width="100" height="50" fill="#0000ff"/>
|
||||
<rect x="100" y="50" width="100" height="50" fill="#00ff00"/>
|
||||
</g>
|
||||
<!-- Luminance masks (hard and a gradient), an alpha mask. -->
|
||||
<rect id="masked" y="100" width="100" height="100" fill="#0000ff" mask="url(#m1)"/>
|
||||
<rect id="faded" x="100" y="100" width="100" height="100" fill="#ff0000" mask="url(#m2)"/>
|
||||
<rect id="alpha-masked" x="200" width="100" height="100" fill="#00ff00" mask="url(#m3)"/>
|
||||
<!-- probe 50 50 #ff0000ff -->
|
||||
<!-- probe 25 25 #00000000 -->
|
||||
<!-- probe 85 50 #00000000 -->
|
||||
<!-- probe 120 25 #0000ffff -->
|
||||
<!-- probe 170 25 #00000000 -->
|
||||
<!-- probe 120 75 #00ff00ff -->
|
||||
<!-- probe 170 75 #00000000 -->
|
||||
<!-- probe 25 150 #0000ffff -->
|
||||
<!-- probe 75 150 #00000000 -->
|
||||
<!-- probe 101 150 #ff0000fc ~10 -->
|
||||
<!-- probe 150 150 #ff00007f ~14 -->
|
||||
<!-- probe 250 50 #00ff0080 ~6 -->
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
|
|
@ -0,0 +1,19 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="60">
|
||||
<!-- currentColor, named, hsl(), rgba(), #rgba, transparent, percentages, a named colour in style. -->
|
||||
<g color="#ff8000"><rect width="20" height="20" fill="currentColor"/></g>
|
||||
<rect x="20" width="20" height="20" fill="darkorchid"/>
|
||||
<rect x="40" width="20" height="20" fill="hsl(120, 100%, 25%)"/>
|
||||
<rect x="60" width="20" height="20" fill="rgba(0, 0, 255, 0.5)"/>
|
||||
<rect x="80" width="20" height="20" fill="#0f08"/>
|
||||
<rect y="30" width="20" height="20" fill="transparent"/>
|
||||
<rect x="20" y="30" width="20" height="20" fill="rgb(100%, 0%, 50%)"/>
|
||||
<rect x="40" y="30" width="20" height="20" style="fill: CornflowerBlue"/>
|
||||
<!-- probe 10 10 #ff8000ff -->
|
||||
<!-- probe 30 10 #9932ccff -->
|
||||
<!-- probe 50 10 #008000ff ~2 -->
|
||||
<!-- probe 70 10 #0000ff80 -->
|
||||
<!-- probe 90 10 #00ff0088 -->
|
||||
<!-- probe 10 40 #00000000 -->
|
||||
<!-- probe 30 40 #ff0080ff ~2 -->
|
||||
<!-- probe 50 40 #6495edff -->
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 977 B |
|
|
@ -0,0 +1,18 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
|
||||
<style>
|
||||
rect { fill: #808080; }
|
||||
.red { fill: red; }
|
||||
#special { fill: #00ff00; }
|
||||
[data-kind="stroke"] { stroke: #0000ff; stroke-width: 4px; }
|
||||
</style>
|
||||
<!-- Element, class, id and attribute selectors; an inline style wins over them all. -->
|
||||
<rect x="0" y="0" width="40" height="40"/>
|
||||
<rect class="red" x="50" y="0" width="40" height="40"/>
|
||||
<rect id="special" class="red" x="0" y="50" width="40" height="40"/>
|
||||
<rect class="red" style="fill:#ffff00" data-kind="stroke" x="50" y="50" width="40" height="40"/>
|
||||
<!-- probe 20 20 #808080ff -->
|
||||
<!-- probe 70 20 #ff0000ff -->
|
||||
<!-- probe 20 70 #00ff00ff -->
|
||||
<!-- probe 70 70 #ffff00ff -->
|
||||
<!-- probe 49 70 #0000ffff -->
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 772 B |
|
|
@ -0,0 +1,8 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100">
|
||||
<!-- Two squares, one inside the other, both clockwise: evenodd makes a hole, nonzero doesn't. -->
|
||||
<path id="evenodd" fill-rule="evenodd" fill="#000000" d="M10 10 H90 V90 H10 Z M30 30 H70 V70 H30 Z"/>
|
||||
<path id="nonzero" fill="#000000" d="M110 10 H190 V90 H110 Z M130 30 H170 V70 H130 Z"/>
|
||||
<!-- probe 20 50 #000000ff -->
|
||||
<!-- probe 50 50 #00000000 -->
|
||||
<!-- probe 150 50 #000000ff -->
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 468 B |
|
|
@ -0,0 +1,45 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="400" height="120">
|
||||
<defs>
|
||||
<filter id="blur"><feGaussianBlur stdDeviation="4"/></filter>
|
||||
<filter id="drop" x="-50%" y="-50%" width="200%" height="200%"><feDropShadow dx="4" dy="6" stdDeviation="2" flood-color="#000000" flood-opacity="0.5"/></filter>
|
||||
<filter id="classic" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feOffset in="SourceAlpha" dx="0" dy="4"/>
|
||||
<feGaussianBlur stdDeviation="3" result="b"/>
|
||||
<feFlood flood-color="#ff0000" flood-opacity="0.25"/>
|
||||
<feComposite in2="b" operator="in"/>
|
||||
<feMerge><feMergeNode/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||
</filter>
|
||||
<filter id="matrix" x="-50%" y="-50%" width="200%" height="200%" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="bg"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hard"/>
|
||||
<feOffset dx="2" dy="2"/>
|
||||
<feGaussianBlur stdDeviation="1"/>
|
||||
<feComposite in2="hard" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0.4 0"/>
|
||||
<feBlend mode="normal" in2="bg" result="shadow"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="shadow" result="shape"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hard2"/>
|
||||
<feOffset dy="-3"/>
|
||||
<feGaussianBlur stdDeviation="1.5"/>
|
||||
<feComposite in2="hard2" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.3 0"/>
|
||||
<feBlend mode="normal" in2="shape" result="inner"/>
|
||||
</filter>
|
||||
<filter id="noise"><feTurbulence baseFrequency="0.05"/></filter>
|
||||
</defs>
|
||||
<!-- Layer blur, feDropShadow, the offset/blur/flood/composite/merge shadow, matrix shadows. -->
|
||||
<rect id="blurred" x="10" y="10" width="60" height="60" fill="#ff0000" filter="url(#blur)"/>
|
||||
<rect id="dropped" x="100" y="10" width="60" height="60" fill="#0000ff" filter="url(#drop)"/>
|
||||
<rect id="classic-shadow" x="200" y="10" width="60" height="60" fill="#00ff00" filter="url(#classic)"/>
|
||||
<rect id="matrixed" x="300" y="10" width="60" height="60" fill="#ffff00" filter="url(#matrix)"/>
|
||||
<rect id="unsupported" x="10" y="90" width="20" height="20" fill="#000000" filter="url(#noise)"/>
|
||||
<!-- probe 40 40 #ff0000ff ~6 -->
|
||||
<!-- probe 10 40 #ff000080 ~40 -->
|
||||
<!-- probe 130 40 #0000ffff -->
|
||||
<!-- probe 162 40 #00000062 ~45 -->
|
||||
<!-- probe 230 40 #00ff00ff -->
|
||||
<!-- probe 230 74 #ff000030 ~30 -->
|
||||
<!-- probe 330 40 #ffff00ff -->
|
||||
<!-- probe 330 69 #ffff49ff ~30 -->
|
||||
<!-- probe 20 100 #000000ff -->
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
|
|
@ -0,0 +1,54 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200">
|
||||
<defs>
|
||||
<linearGradient id="lin"><stop offset="0" stop-color="#ff0000"/><stop offset="1" stop-color="#0000ff"/></linearGradient>
|
||||
<linearGradient id="us" gradientUnits="userSpaceOnUse" x1="50" y1="0" x2="100" y2="0">
|
||||
<stop offset="0" stop-color="#00ff00"/><stop offset="1" stop-color="#0000ff"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="refl" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="25" y2="0" spreadMethod="reflect">
|
||||
<stop offset="0" stop-color="#ff0000"/><stop offset="1" stop-color="#ffffff"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="rep" href="#refl" spreadMethod="repeat"/>
|
||||
<linearGradient id="rot" gradientTransform="rotate(90 0.5 0.5)">
|
||||
<stop offset="0" stop-color="#ff0000"/><stop offset="1" stop-color="#0000ff"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="base"><stop offset="0" stop-color="#ffff00"/><stop offset="1" stop-color="#000000"/></linearGradient>
|
||||
<linearGradient id="child" xlink:href="#base" x1="0" y1="0" x2="0" y2="1"/>
|
||||
<radialGradient id="rad"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#ff0000"/></radialGradient>
|
||||
<radialGradient id="foc" fx="0.25" fy="0.5"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#ff0000"/></radialGradient>
|
||||
<linearGradient id="fade"><stop offset="0" stop-color="#000000" stop-opacity="0"/><stop offset="1" stop-color="#000000"/></linearGradient>
|
||||
</defs>
|
||||
<rect id="bbox-units" width="100" height="20" fill="url(#lin)"/>
|
||||
<rect id="user-space" y="30" width="100" height="20" fill="url(#us)"/>
|
||||
<rect id="reflect" y="60" width="100" height="20" fill="url(#refl)"/>
|
||||
<rect id="repeat" y="90" width="100" height="20" fill="url(#rep)"/>
|
||||
<rect id="rotated" x="110" width="80" height="80" fill="url(#rot)"/>
|
||||
<rect id="href-chain" x="110" y="90" width="80" height="40" fill="url(#child)"/>
|
||||
<circle id="radial" cx="50" cy="160" r="30" fill="url(#rad)"/>
|
||||
<circle id="focal" cx="150" cy="170" r="25" fill="url(#foc)"/>
|
||||
<rect id="stop-opacity" x="110" y="132" width="80" height="10" fill="url(#fade)"/>
|
||||
<!-- probe 0 10 #ff0000ff ~10 -->
|
||||
<!-- probe 50 10 #7e0081ff -->
|
||||
<!-- probe 10 40 #00ff00ff -->
|
||||
<!-- probe 75 40 #007f80ff -->
|
||||
<!-- probe 12 70 #ff8080ff -->
|
||||
<!-- probe 37 70 #ff8080ff -->
|
||||
<!-- probe 49 70 #ff0505ff ~12 -->
|
||||
<!-- probe 24 70 #fffafaff ~12 -->
|
||||
<!-- probe 37 100 #ff8080ff -->
|
||||
<!-- probe 49 100 #fffafaff ~12 -->
|
||||
<!-- probe 26 100 #ff0f0fff ~12 -->
|
||||
<!-- probe 150 1 #fc0003ff ~10 -->
|
||||
<!-- probe 150 78 #0300fcff ~10 -->
|
||||
<!-- probe 112 40 #7e0081ff -->
|
||||
<!-- probe 188 40 #7e0081ff -->
|
||||
<!-- probe 150 91 #f9f900ff ~10 -->
|
||||
<!-- probe 150 110 #7c7c00ff -->
|
||||
<!-- probe 50 160 #ffffffff ~12 -->
|
||||
<!-- probe 50 145 #ff8484ff ~12 -->
|
||||
<!-- probe 75 160 #ff2626ff ~12 -->
|
||||
<!-- probe 137 170 #fff0f0ff ~24 -->
|
||||
<!-- probe 172 170 #ff0000ff ~24 -->
|
||||
<!-- probe 111 137 #00000003 ~6 -->
|
||||
<!-- probe 150 137 #00000080 ~8 -->
|
||||
<!-- probe 189 137 #000000fc ~6 -->
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.0 KiB |
|
|
@ -0,0 +1,9 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" color="#333333">
|
||||
<!-- A typical outline icon: a 24 grid scaled to 48 px, inherited stroke, currentColor. -->
|
||||
<circle cx="12" cy="12" r="9"/>
|
||||
<path d="M12 7v5l3 3"/>
|
||||
<!-- probe 24 6 #333333ff ~30 -->
|
||||
<!-- probe 24 18 #333333ff ~30 -->
|
||||
<!-- probe 16 16 #00000000 -->
|
||||
<!-- probe 2 24 #00000000 -->
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 497 B |
|
|
@ -0,0 +1,27 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generator: a synthetic illustration in the style vector tools write. -->
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 120 80" style="enable-background:new 0 0 120 80;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#2B6CB0;}
|
||||
.st1{fill:#FFFFFF;stroke:#1A202C;stroke-width:2;stroke-miterlimit:10;}
|
||||
.st2{opacity:0.5;fill:#F6AD55;}
|
||||
</style>
|
||||
<g id="Background">
|
||||
<rect class="st0" width="120" height="80"/>
|
||||
</g>
|
||||
<g id="Sun">
|
||||
<circle class="st2" cx="95" cy="20" r="12"/>
|
||||
</g>
|
||||
<g id="House">
|
||||
<g id="Walls" transform="translate(20 30)">
|
||||
<rect class="st1" width="40" height="30"/>
|
||||
</g>
|
||||
<polygon class="st1" points="16,30 40,12 64,30"/>
|
||||
</g>
|
||||
<!-- probe 5 5 #2b6cb0ff -->
|
||||
<!-- probe 95 20 #918d83ff ~6 -->
|
||||
<!-- probe 40 45 #ffffffff -->
|
||||
<!-- probe 20 45 #1a202cff ~40 -->
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
|
|
@ -0,0 +1,11 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100" height="100">
|
||||
<!-- A 4x2 PNG (left half red, right half blue) as a data URI: exact fit, then letterboxed. -->
|
||||
<image id="photo" x="10" y="10" width="40" height="20" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAACCAIAAADwyuo0AAAAE0lEQVR4nGP4z8AARGDiPwMyBwBnsgf5Q2OaZwAAAABJRU5ErkJggg=="/>
|
||||
<image x="10" y="50" width="80" height="20" preserveAspectRatio="xMidYMid meet" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAACCAIAAADwyuo0AAAAE0lEQVR4nGP4z8AARGDiPwMyBwBnsgf5Q2OaZwAAAABJRU5ErkJggg=="/>
|
||||
<!-- probe 15 20 #ff0000ff -->
|
||||
<!-- probe 45 20 #0000ffff -->
|
||||
<!-- probe 20 60 #00000000 -->
|
||||
<!-- probe 35 60 #ff0000ff -->
|
||||
<!-- probe 65 60 #0000ffff -->
|
||||
<!-- probe 80 60 #00000000 -->
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 829 B |
|
|
@ -0,0 +1,13 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="200">
|
||||
<!-- Group opacity composites the group once; fill-opacity applies per shape. -->
|
||||
<g id="faded" opacity="0.5">
|
||||
<rect width="60" height="60" fill="#ff0000"/>
|
||||
<rect x="20" y="20" width="60" height="60" fill="#0000ff"/>
|
||||
</g>
|
||||
<rect y="100" width="60" height="60" fill="#ff0000" fill-opacity="0.5"/>
|
||||
<rect x="20" y="120" width="60" height="60" fill="#0000ff" fill-opacity="0.5"/>
|
||||
<!-- probe 10 10 #ff000080 -->
|
||||
<!-- probe 40 40 #0000ff80 -->
|
||||
<!-- probe 10 110 #ff000080 -->
|
||||
<!-- probe 40 140 #5500aabf -->
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 600 B |
|
|
@ -0,0 +1,24 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="300" height="100">
|
||||
<defs>
|
||||
<pattern id="checks" patternUnits="userSpaceOnUse" width="20" height="20"><rect width="10" height="10" fill="#0000ff"/></pattern>
|
||||
<pattern id="spot" patternContentUnits="objectBoundingBox" width="1" height="1"><circle cx="0.5" cy="0.5" r="0.25" fill="#ff0000"/></pattern>
|
||||
<pattern id="photo" patternContentUnits="objectBoundingBox" width="1" height="1"><use href="#img" transform="scale(0.25 0.5)"/></pattern>
|
||||
<image id="img" width="4" height="2" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAACCAIAAADwyuo0AAAAE0lEQVR4nGP4z8AARGDiPwMyBwBnsgf5Q2OaZwAAAABJRU5ErkJggg=="/>
|
||||
</defs>
|
||||
<!-- A repeating vector tile, bounding-box content units, an image fill written as a pattern. -->
|
||||
<rect id="tiled" width="100" height="100" fill="url(#checks)"/>
|
||||
<rect id="spotted" x="100" width="100" height="100" fill="url(#spot)"/>
|
||||
<rect id="pictured" x="200" y="20" width="80" height="40" fill="url(#photo)"/>
|
||||
<!-- probe 5 5 #0000ffff -->
|
||||
<!-- probe 15 5 #00000000 -->
|
||||
<!-- probe 25 25 #0000ffff -->
|
||||
<!-- probe 35 45 #00000000 -->
|
||||
<!-- probe 95 95 #00000000 -->
|
||||
<!-- probe 85 85 #0000ffff -->
|
||||
<!-- probe 150 50 #ff0000ff -->
|
||||
<!-- probe 110 10 #00000000 -->
|
||||
<!-- probe 172 50 #ff0000ff -->
|
||||
<!-- probe 178 50 #00000000 -->
|
||||
<!-- probe 210 40 #ff0000ff -->
|
||||
<!-- probe 270 40 #0000ffff -->
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
|
|
@ -0,0 +1,23 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200">
|
||||
<!-- Basic shapes: a rounded rect, elliptical corners, circle, ellipse, lines, polyline, polygon. -->
|
||||
<rect id="card" x="10" y="10" width="50" height="30" rx="8" fill="#ff0000"/>
|
||||
<rect x="70" y="10" width="50" height="30" rx="10" ry="5" fill="#ff8000"/>
|
||||
<circle cx="40" cy="80" r="20" fill="#008000"/>
|
||||
<ellipse cx="100" cy="80" rx="30" ry="15" fill="#0000ff"/>
|
||||
<line x1="10" y1="120" x2="190" y2="120" stroke="#000000" stroke-width="2"/>
|
||||
<line x1="10" y1="130" x2="60" y2="180" stroke="#000000" stroke-width="4"/>
|
||||
<polyline points="80,180 100,140 120,180" fill="none" stroke="#800080" stroke-width="4"/>
|
||||
<polygon points="140,180 160,140 180,180" fill="#008080"/>
|
||||
<!-- probe 35 25 #ff0000ff -->
|
||||
<!-- probe 11 11 #00000000 -->
|
||||
<!-- probe 71 11 #00000000 -->
|
||||
<!-- probe 75 13 #ff8000ff ~40 -->
|
||||
<!-- probe 40 80 #008000ff -->
|
||||
<!-- probe 100 80 #0000ffff -->
|
||||
<!-- probe 128 80 #0000ffff -->
|
||||
<!-- probe 100 96 #00000000 ~40 -->
|
||||
<!-- probe 100 120 #000000ff -->
|
||||
<!-- probe 35 155 #000000ff -->
|
||||
<!-- probe 90 160 #800080ff ~40 -->
|
||||
<!-- probe 160 170 #008080ff -->
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
|
|
@ -0,0 +1,22 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="240" height="120">
|
||||
<!-- Dashes; butt, round and square caps; miter, round and bevel joins. -->
|
||||
<line id="dashed" x1="10" y1="10" x2="190" y2="10" stroke="#000000" stroke-width="4" stroke-dasharray="20 10"/>
|
||||
<line id="round-cap" x1="20" y1="40" x2="60" y2="40" stroke="#ff0000" stroke-width="10" stroke-linecap="round"/>
|
||||
<line id="butt-cap" x1="80" y1="40" x2="120" y2="40" stroke="#0000ff" stroke-width="10"/>
|
||||
<line id="square-cap" x1="140" y1="40" x2="180" y2="40" stroke="#00ff00" stroke-width="10" stroke-linecap="square"/>
|
||||
<polyline id="miter" points="20,100 50,70 80,100" fill="none" stroke="#000000" stroke-width="10" stroke-miterlimit="4"/>
|
||||
<polyline id="round-join" points="90,100 120,70 150,100" fill="none" stroke="#000000" stroke-width="10" stroke-linejoin="round"/>
|
||||
<polyline id="bevel" points="160,100 190,70 220,100" fill="none" stroke="#000000" stroke-width="10" stroke-linejoin="bevel"/>
|
||||
<!-- probe 15 10 #000000ff -->
|
||||
<!-- probe 35 10 #00000000 -->
|
||||
<!-- probe 45 10 #000000ff -->
|
||||
<!-- probe 16 40 #ff0000ff -->
|
||||
<!-- probe 63 40 #ff0000ff -->
|
||||
<!-- probe 78 40 #00000000 -->
|
||||
<!-- probe 100 40 #0000ffff -->
|
||||
<!-- probe 137 40 #00ff00ff -->
|
||||
<!-- probe 50 64 #000000ff ~40 -->
|
||||
<!-- probe 120 64 #00000000 ~40 -->
|
||||
<!-- probe 120 66 #000000ff ~40 -->
|
||||
<!-- probe 190 65 #00000000 ~40 -->
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
|
|
@ -0,0 +1,8 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="300" height="200">
|
||||
<!-- Text: font props, letter spacing, anchors, tspan lines and a styled tspan run. -->
|
||||
<text id="title" x="10" y="40" font-family="Inter, Helvetica, Arial, sans-serif" font-size="24" font-weight="700" fill="#ff0000">Hello</text>
|
||||
<text x="150" y="80" text-anchor="middle" font-size="16" letter-spacing="2" fill="#0000ff">Centered</text>
|
||||
<text x="290" y="120" text-anchor="end" font-size="12" font-style="italic">End</text>
|
||||
<text font-size="14"><tspan x="10" y="150">Line one</tspan><tspan x="10" y="170">Line two</tspan></text>
|
||||
<text x="150" y="190" font-size="14">Plain <tspan font-weight="bold" fill="#00ff00">bold</tspan> run</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 721 B |
|
|
@ -0,0 +1,25 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200">
|
||||
<!-- Nested group transforms: translate, rotate about a point, matrix, skew. -->
|
||||
<g id="moved" transform="translate(20 20)">
|
||||
<rect id="a" width="40" height="40" fill="#ff0000"/>
|
||||
<g id="turned" transform="translate(100 0) rotate(45 20 20)">
|
||||
<rect id="b" width="40" height="40" fill="#00ff00"/>
|
||||
</g>
|
||||
</g>
|
||||
<g id="scaled" transform="matrix(2 0 0 1.5 10 100)">
|
||||
<rect id="c" width="20" height="20" fill="#0000ff"/>
|
||||
</g>
|
||||
<g id="skewed" transform="translate(120 120) skewX(30)">
|
||||
<rect id="d" width="40" height="40" fill="#ffff00"/>
|
||||
</g>
|
||||
<!-- probe 40 40 #ff0000ff -->
|
||||
<!-- probe 140 40 #00ff00ff -->
|
||||
<!-- probe 160 40 #00ff00ff -->
|
||||
<!-- probe 125 15 #00000000 -->
|
||||
<!-- probe 30 115 #0000ffff -->
|
||||
<!-- probe 45 125 #0000ffff -->
|
||||
<!-- probe 55 115 #00000000 -->
|
||||
<!-- probe 150 140 #ffff00ff -->
|
||||
<!-- probe 125 158 #00000000 -->
|
||||
<!-- probe 180 158 #ffff00ff -->
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 984 B |
|
|
@ -0,0 +1,13 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="75pt" height="1in" font-size="10">
|
||||
<!-- 75pt = 100 px wide, 1in = 96 px tall; lengths in mm, pt, em and %. -->
|
||||
<rect x="0" y="0" width="10mm" height="10mm" fill="#ff0000"/>
|
||||
<rect x="45pt" y="0" width="2em" height="2em" fill="#0000ff"/>
|
||||
<rect x="0" y="50%" width="50%" height="25%" fill="#00ff00"/>
|
||||
<!-- probe 20 20 #ff0000ff -->
|
||||
<!-- probe 38 20 #00000000 -->
|
||||
<!-- probe 70 10 #0000ffff -->
|
||||
<!-- probe 85 10 #00000000 -->
|
||||
<!-- probe 25 55 #00ff00ff -->
|
||||
<!-- probe 25 75 #00000000 -->
|
||||
<!-- probe 55 55 #00000000 -->
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 591 B |
|
|
@ -0,0 +1,16 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100" height="100">
|
||||
<defs>
|
||||
<symbol id="dot" viewBox="0 0 10 10"><circle cx="5" cy="5" r="5" fill="#ff0000"/></symbol>
|
||||
<rect id="sq" width="10" height="10" fill="#0000ff"/>
|
||||
<use id="sq2" href="#sq" x="5"/>
|
||||
</defs>
|
||||
<!-- A symbol scaled into the use box, an xlink:href use, and a use of a use. -->
|
||||
<use href="#dot" width="40" height="40"/>
|
||||
<use xlink:href="#sq" x="60"/>
|
||||
<use href="#sq2" x="20" y="30" transform="scale(2)"/>
|
||||
<!-- probe 20 20 #ff0000ff -->
|
||||
<!-- probe 3 3 #00000000 -->
|
||||
<!-- probe 65 5 #0000ffff -->
|
||||
<!-- probe 60 70 #0000ffff -->
|
||||
<!-- probe 45 70 #00000000 -->
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 704 B |
|
|
@ -0,0 +1,10 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100" viewBox="0 0 100 100">
|
||||
<!-- The default xMidYMid meet: scale 1, centred horizontally (50 px each side). -->
|
||||
<rect width="100" height="100" fill="#ff0000"/>
|
||||
<circle cx="50" cy="50" r="10" fill="#0000ff"/>
|
||||
<!-- probe 60 50 #ff0000ff -->
|
||||
<!-- probe 140 50 #ff0000ff -->
|
||||
<!-- probe 100 50 #0000ffff -->
|
||||
<!-- probe 20 50 #00000000 -->
|
||||
<!-- probe 160 50 #00000000 -->
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 450 B |
|
|
@ -0,0 +1,9 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="200" viewBox="0 0 100 100" preserveAspectRatio="xMinYMin slice">
|
||||
<!-- Slice: scale 2 to cover, pinned top-left; the right half falls outside. -->
|
||||
<rect width="50" height="50" fill="#ff0000"/>
|
||||
<rect y="50" width="50" height="50" fill="#00ff00"/>
|
||||
<rect x="50" y="50" width="50" height="50" fill="#0000ff"/>
|
||||
<!-- probe 50 50 #ff0000ff -->
|
||||
<!-- probe 50 150 #00ff00ff -->
|
||||
<!-- probe 99 150 #00ff00ff -->
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 481 B |
|
|
@ -48,10 +48,10 @@ pub fn paste(app: &mut FigApp, event_text: Option<&str>) {
|
|||
// A comment's link opens its thread.
|
||||
if text.trim().starts_with("figcraft://comment/") {
|
||||
app.run("comment.open", json!({"link": text.trim()}));
|
||||
} else if text.trim_start().starts_with("<svg") || text.trim_start().starts_with("<?xml") {
|
||||
} else if figcraft_engine::looks_like_svg(&text) {
|
||||
use base64::Engine as _;
|
||||
let b64 = base64::engine::general_purpose::STANDARD.encode(text.as_bytes());
|
||||
app.run("file.import", json!({"data": b64, "name": "pasted.svg", "x": cx, "y": cy}));
|
||||
app.run("file.import", json!({"data": b64, "name": "Pasted SVG.svg", "x": cx, "y": cy, "center": true}));
|
||||
} else {
|
||||
app.run("text.create", json!({"text": text, "x": cx, "y": cy}));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,7 +47,8 @@ figcraft-cli export app.figcraft -o screens.pdf --page "Screens"
|
|||
|
||||
### `convert <in> <out>`
|
||||
|
||||
By extension. Inputs: `.figcraft`, `.json` (FigCraft or REST API file JSON), `.svg` (becomes vectors), `.png/.jpg/.webp/.gif/.bmp`
|
||||
By extension. Inputs: `.figcraft`, `.json` (FigCraft or REST API file JSON), `.svg` (an editable frame: groups,
|
||||
shapes, text, gradients, masks and effects; `info` lists what it left out as `note:` lines), `.png/.jpg/.webp/.gif/.bmp`
|
||||
(an image layer). Outputs: `.figcraft`, `.json` (FigCraft JSON, pretty-printed), `.svg`,
|
||||
`.png/.jpg/.webp` (the current page rendered at 1x with its background), `.pdf` (the current page's
|
||||
frames, one page each).
|
||||
|
|
|
|||
22
docs/mcp.md
|
|
@ -135,7 +135,7 @@ Or in a project's `.mcp.json`:
|
|||
| `select {ids}` | Replace the selection (`[]` deselects). |
|
||||
| `export {ids, format, scale \| size, path?}` | png, jpg, svg, webp, pdf; `size` is `2x`, `512w` or `512h`. PDF: frames one page each (sized in points), anything else together on one page; `run_command` `file.exportFramesPdf {page?, path?}` writes every top-level frame of a page. Without `path`, images come back as MCP image content, SVG as text. Layers' own export settings: `run_command` with `export.add` / `export.update {index, size?, suffix?, format?}` / `export.remove` and `file.exportLayers {ids?, dir?}` (every layer once per setting, named `<layer><suffix>.<format>`). |
|
||||
| `screenshot {ids?, scale?, maxSize?, window?}` | PNG of the page (with background) or of layers, scaled to fit `maxSize` (default 1600). `window: true` captures the app window when attached. |
|
||||
| `open_file {path, variables?}` / `save_file {path?}` | Open `.figcraft`, `.json` (FigCraft JSON or REST API file JSON with its variables, see below), `.svg` or an image; save as `.figcraft`. |
|
||||
| `open_file {path, variables?}` / `save_file {path?}` | Open `.figcraft`, `.json` (FigCraft JSON or REST API file JSON with its variables, see below), `.svg` (see Importing SVG below) or an image; save as `.figcraft`. |
|
||||
| `undo` / `redo` | Step through history. |
|
||||
| `pointer {kind, x, y, to?, steps?, tool?, shift, alt, cmd}` | Mouse gestures in page coordinates with the current tool: `click`, `double`, `drag`, `down`, `move`, `up`, `hover`. |
|
||||
| `key {key, shift, alt, cmd}` | A shortcut or key as if typed on the canvas (every default shortcut, tool keys, Escape, Delete, arrows, number keys for opacity; while editing text, the text shortcuts and then text editing keys). Keys are names like `A`, `0`, `Enter`, `ArrowLeft`, `=`, `/`, or egui's (`Plus`, `Questionmark`); `cmd` is Cmd on macOS and Ctrl elsewhere. |
|
||||
|
|
@ -352,6 +352,26 @@ click or drag across regions with `pointer` to merge, add `alt` to remove; `shap
|
|||
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"run_command","arguments":{"command":"shapeBuilder.merge","params":{"ids":[2,3],"points":[[120,80],[160,80]]}}}}
|
||||
```
|
||||
|
||||
### Importing SVG
|
||||
|
||||
`open_file {path}` opens an SVG as a new document; `run_command {command: "file.import", params:
|
||||
{path | data + name, x?, y?, center?}}` adds it to the current page as a frame (top-left at `x, y`,
|
||||
or centred there with `center: true`, as pasting does). It comes in editable: groups named from
|
||||
their `id`, transforms baked into the layers, `<rect>`/`<circle>`/`<ellipse>`/`<line>` (and paths
|
||||
tracing those outlines) as rectangles with their corner radius, ellipses and lines, everything else
|
||||
as vectors; strokes with their width, dashes, caps, joins and miter limit; linear and radial
|
||||
gradients with their exact handles (`gradientTransform`, `reflect`/`repeat` unrolled, a focal
|
||||
point as the centre); patterns as image fills (fill, fit, crop or tile); clip paths as vector masks
|
||||
and masks as luminance or alpha masks; shadow and blur filters (`feDropShadow`, the offset / blur /
|
||||
flood / composite chains, colour-matrix shadows, inner shadows, `feMorphology` spread) as drop
|
||||
shadows, inner shadows and layer blurs; opacity and `mix-blend-mode`; text as text layers (fonts
|
||||
the SVG names when installed, anchors, letter spacing, styled `<tspan>` runs, stacked lines as one
|
||||
layer); images as image fills, and SVG images imported in place. CSS, `<use>`/`<symbol>`, units
|
||||
and `viewBox`/`preserveAspectRatio` are resolved. The result's `warnings` list what was left out
|
||||
(filters that aren't shadows or blurs, rotated repeating patterns, a clip path's own clip path).
|
||||
Linked files are never read, only data URIs; documents nested more than 512 elements deep are
|
||||
refused.
|
||||
|
||||
### Importing REST API file JSON
|
||||
|
||||
The JSON returned by the public REST API's file endpoint (`GET /v1/files/:key`, best with
|
||||
|
|
|
|||