diff --git a/Cargo.lock b/Cargo.lock index e3f79dc..79f5efd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1483,6 +1483,7 @@ dependencies = [ "figcraft-geom", "figcraft-pathops", "figcraft-render", + "figcraft-text", "usvg", ] diff --git a/ROADMAP.md b/ROADMAP.md index 51d60fb..9a38880 100644 --- a/ROADMAP.md +++ b/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 | diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index df2b923..670182e 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -59,7 +59,7 @@ pub fn specs() -> Vec { 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 { } 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 { 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); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 73c5171..cc51555 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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; diff --git a/crates/engine/src/svg_import_tests.rs b/crates/engine/src/svg_import_tests.rs new file mode 100644 index 0000000..090849e --- /dev/null +++ b/crates/engine/src/svg_import_tests.rs @@ -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##""##; + +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("", + " \n", + "\u{feff}\n\n\n", + ] { + assert!(crate::looks_like_svg(yes), "{yes}"); + } + for no in ["", "svg", "", "", "<g><g>", 99), 1); + assert_eq!(nesting(b"".repeat(5000), "".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()); +} diff --git a/crates/svg/src/import/mod.rs b/crates/svg/src/import/mod.rs new file mode 100644 index 0000000..91af5f0 --- /dev/null +++ b/crates/svg/src/import/mod.rs @@ -0,0 +1,650 @@ +//! SVG import: usvg's normalised tree (CSS, ``, 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, +} + +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 ``: merged into their only child. + anonymous: HashSet, + notes: BTreeSet, + fonts: Option, + /// 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) { + self.notes.insert(s.into()); + } + + /// Insert `n` on top of `parent`'s children. + pub(crate) fn add(&mut self, n: Node, parent: NodeId) -> Option { + 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 { + 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 { + 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; + } + // `` 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 = 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 = 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> { + 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>, 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""); + } else if rest.starts_with(b"") { + i = find(i + 9, b""); + } else if rest.starts_with(b""); + } else if rest.starts_with(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: ` bool { + let mut s = text.trim_start_matches('\u{feff}').trim_start(); + for _ in 0..64 { + if s.starts_with("").map(|i| i + 2) + } else if s.starts_with("").map(|i| i + 3) + } else if s.starts_with("').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 { + 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 { + 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() }) +} diff --git a/crates/svg/src/import/paint.rs b/crates/svg/src/import/paint.rs new file mode 100644 index 0000000..ebee669 --- /dev/null +++ b/crates/svg/src/import/paint.rs @@ -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 { + 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 { + 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, method: usvg::SpreadMethod, t: Target) -> Option { + 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 = + 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 { + 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))) +} diff --git a/crates/svg/src/import/paint_tests.rs b/crates/svg/src/import/paint_tests.rs new file mode 100644 index 0000000..c1ddb78 --- /dev/null +++ b/crates/svg/src/import/paint_tests.rs @@ -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, Vec) { + 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##""##; + +#[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##"{DEFS}"## + )); + 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##"{DEFS}"## + )); + 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##"{DEFS}"## + )); + 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##"{DEFS}"## + ) + }; + let r = gradient(&svg("reflect")); + assert!(near(r.from, (0.0, 0.0)) && near(r.to, (1.0, 0.0)), "{r:?}"); + let pos: Vec = 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##""##, + ); + 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##""## + )); + 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##""## + )); + 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##""## + )); + 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##""##, + ); + 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##""## + )); + 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##""##, + ); + assert!(nodes.len() >= 2); +} diff --git a/crates/svg/src/import/shape.rs b/crates/svg/src/import/shape.rs new file mode 100644 index 0000000..91c5a5a --- /dev/null +++ b/crates/svg/src/import/shape.rs @@ -0,0 +1,124 @@ +//! Recognising primitives in path data, so imported shapes stay editable: rectangles (with a +//! uniform corner radius), ellipses and straight lines. ``, ``, `` and +//! `` 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 { + 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 { + 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 } +} diff --git a/crates/svg/src/import/shape_tests.rs b/crates/svg/src/import/shape_tests.rs new file mode 100644 index 0000000..5bad142 --- /dev/null +++ b/crates/svg/src/import/shape_tests.rs @@ -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"); +} diff --git a/crates/svg/src/import/structure_tests.rs b/crates/svg/src/import/structure_tests.rs new file mode 100644 index 0000000..adeedb0 --- /dev/null +++ b/crates/svg/src/import/structure_tests.rs @@ -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##"{body}"##); + 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##""##); + 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##""##); + 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##""##); + assert!(tree(&d, f).is_empty()); +} + +#[test] +fn group_opacity_and_layer_opacity_multiply_when_merged() { + let (d, f) = import(r##""##); + 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##""##, + ); + 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##""##, + ); + assert_eq!(tree(&d, f), [t(0, "RECTANGLE", "Rectangle")]); + // One that cuts in stays. + let (d, f) = import( + r##""##, + ); + assert_eq!(tree(&d, f).len(), 3); +} + +#[test] +fn an_empty_mask_hides_its_layer() { + let (d, f) = import(r##""##); + 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##""##); + 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##""##); + 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##""##)); + 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##""##; + let b64 = base64::engine::general_purpose::STANDARD.encode(inner); + let (d, f) = import(&format!(r##""##)); + 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##"Hello thereSmall"##); + 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##"AbCd"##); + 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##"Big"##); + 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); +} diff --git a/crates/svg/src/import/text.rs b/crates/svg/src/import/text.rs new file mode 100644 index 0000000..4b3ed90 --- /dev/null +++ b/crates/svg/src/import/text.rs @@ -0,0 +1,290 @@ +//! SVG text → text layers. Each absolutely positioned chunk (``, ``) is a +//! line; lines stacked at a steady pitch with the same anchor and font become one multi-line +//! layer with that line height. `` 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); + +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>); + +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 = 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::>().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 = + 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); +} diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index 13ca130..d0fad3f 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -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, } 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, "", 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, ""); + let _ = write!(self.body, ""); + } 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_body}"); + let _ = write!(self.body, ""); } - let mask_body = std::mem::replace(&mut self.body, saved); - let _ = write!(self.defs, "{mask_body}"); - let _ = write!(self.body, ""); 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 { 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 (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("").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(" = 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:?}"); } diff --git a/crates/svg/tests/corpus.rs b/crates/svg/tests/corpus.rs new file mode 100644 index 0000000..35f6f9f --- /dev/null +++ b/crates/svg/tests/corpus.rs @@ -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 `` +//! 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, 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 { + src.lines() + .filter_map(|l| { + let l = l.trim().strip_prefix("")?; + 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 { + 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 { + 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::>(); + 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::>()); + 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); + } +} diff --git a/crates/svg/tests/corpus/arcs.svg b/crates/svg/tests/corpus/arcs.svg new file mode 100644 index 0000000..378ca82 --- /dev/null +++ b/crates/svg/tests/corpus/arcs.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/crates/svg/tests/corpus/blend_visibility.svg b/crates/svg/tests/corpus/blend_visibility.svg new file mode 100644 index 0000000..248d263 --- /dev/null +++ b/crates/svg/tests/corpus/blend_visibility.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/crates/svg/tests/corpus/clip_mask.svg b/crates/svg/tests/corpus/clip_mask.svg new file mode 100644 index 0000000..b80cd63 --- /dev/null +++ b/crates/svg/tests/corpus/clip_mask.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/svg/tests/corpus/colors.svg b/crates/svg/tests/corpus/colors.svg new file mode 100644 index 0000000..01efca6 --- /dev/null +++ b/crates/svg/tests/corpus/colors.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/crates/svg/tests/corpus/css.svg b/crates/svg/tests/corpus/css.svg new file mode 100644 index 0000000..e83bc7b --- /dev/null +++ b/crates/svg/tests/corpus/css.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + diff --git a/crates/svg/tests/corpus/fill_rule.svg b/crates/svg/tests/corpus/fill_rule.svg new file mode 100644 index 0000000..13a2366 --- /dev/null +++ b/crates/svg/tests/corpus/fill_rule.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/crates/svg/tests/corpus/filters.svg b/crates/svg/tests/corpus/filters.svg new file mode 100644 index 0000000..e7fdfc6 --- /dev/null +++ b/crates/svg/tests/corpus/filters.svg @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/svg/tests/corpus/gradients.svg b/crates/svg/tests/corpus/gradients.svg new file mode 100644 index 0000000..2c2eef9 --- /dev/null +++ b/crates/svg/tests/corpus/gradients.svg @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/svg/tests/corpus/icon.svg b/crates/svg/tests/corpus/icon.svg new file mode 100644 index 0000000..3425a36 --- /dev/null +++ b/crates/svg/tests/corpus/icon.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/crates/svg/tests/corpus/illustration.svg b/crates/svg/tests/corpus/illustration.svg new file mode 100644 index 0000000..94bd52c --- /dev/null +++ b/crates/svg/tests/corpus/illustration.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/svg/tests/corpus/image.svg b/crates/svg/tests/corpus/image.svg new file mode 100644 index 0000000..fd7b4dd --- /dev/null +++ b/crates/svg/tests/corpus/image.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/crates/svg/tests/corpus/opacity.svg b/crates/svg/tests/corpus/opacity.svg new file mode 100644 index 0000000..3d2131a --- /dev/null +++ b/crates/svg/tests/corpus/opacity.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/crates/svg/tests/corpus/patterns.svg b/crates/svg/tests/corpus/patterns.svg new file mode 100644 index 0000000..4663bd9 --- /dev/null +++ b/crates/svg/tests/corpus/patterns.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/svg/tests/corpus/primitives.svg b/crates/svg/tests/corpus/primitives.svg new file mode 100644 index 0000000..9f94ea6 --- /dev/null +++ b/crates/svg/tests/corpus/primitives.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/svg/tests/corpus/strokes.svg b/crates/svg/tests/corpus/strokes.svg new file mode 100644 index 0000000..75217e0 --- /dev/null +++ b/crates/svg/tests/corpus/strokes.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/svg/tests/corpus/text.svg b/crates/svg/tests/corpus/text.svg new file mode 100644 index 0000000..cbeb993 --- /dev/null +++ b/crates/svg/tests/corpus/text.svg @@ -0,0 +1,8 @@ + + + Hello + Centered + End + Line oneLine two + Plain bold run + diff --git a/crates/svg/tests/corpus/transforms.svg b/crates/svg/tests/corpus/transforms.svg new file mode 100644 index 0000000..4d0f1ee --- /dev/null +++ b/crates/svg/tests/corpus/transforms.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/svg/tests/corpus/units.svg b/crates/svg/tests/corpus/units.svg new file mode 100644 index 0000000..bb1824a --- /dev/null +++ b/crates/svg/tests/corpus/units.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/crates/svg/tests/corpus/use_symbol.svg b/crates/svg/tests/corpus/use_symbol.svg new file mode 100644 index 0000000..33f0ef4 --- /dev/null +++ b/crates/svg/tests/corpus/use_symbol.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/crates/svg/tests/corpus/viewbox_meet.svg b/crates/svg/tests/corpus/viewbox_meet.svg new file mode 100644 index 0000000..25e2829 --- /dev/null +++ b/crates/svg/tests/corpus/viewbox_meet.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/crates/svg/tests/corpus/viewbox_slice.svg b/crates/svg/tests/corpus/viewbox_slice.svg new file mode 100644 index 0000000..839eecf --- /dev/null +++ b/crates/svg/tests/corpus/viewbox_slice.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 464e130..2397f9d 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -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(" ` -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). diff --git a/docs/mcp.md b/docs/mcp.md index c2dfec9..b233408 100644 --- a/docs/mcp.md +++ b/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 `.`). | | `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, ``/``/``/`` (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 `` runs, stacked lines as one +layer); images as image fills, and SVG images imported in place. CSS, ``/``, 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