From e27f2751d4dcca35eefaf35129d4bce66b5c44eb Mon Sep 17 00:00:00 2001 From: BFlat Date: Sat, 10 Oct 2026 20:36:55 +0700 Subject: [PATCH] M6: SVG import fidelity (#90) SVG import comes in editable and looking right: groups named from their ids with transforms baked into the layers (rotated, scaled and skewed groups, viewBox scaling and offsets used to be lost); rectangles, ellipses and lines recognised and kept as those shapes; gradients with exact handles, reflect/repeat and focal points; patterns as image fills; clip paths and masks as vector, luminance and alpha masks; shadow and blur filter chains as drop shadows, inner shadows and layer blurs; blend modes; text as text layers (it used to vanish); images and nested SVG images. What can't be represented is reported as import warnings (file.import / file.open warnings, figcraft-cli info notes). Linked files are never read, and documents nested too deep for the parser are refused instead of crashing. Pasted SVG is recognised after XML declarations, comments and doctypes and lands centred in the view (file.import center). SVG export writes vector masks as clip paths and alpha/luminance masks as the mask layer drawn, so masks survive a round trip. Tests: a corpus of 21 synthetic SVG files with hand-worked expected pixels (158 probes), structure checks and export round trips; unit tests for shape recognition, paints, filters, structure, mask export; hostile input and byte-mangled corpus fuzzing; engine file command tests. Co-authored-by: Claude Opus 5.5 --- Cargo.lock | 1 + ROADMAP.md | 14 +- crates/engine/src/cmd/file.rs | 29 +- crates/engine/src/lib.rs | 3 + crates/engine/src/svg_import_tests.rs | 67 ++ crates/svg/Cargo.toml | 1 + crates/svg/src/import.rs | 238 ------- crates/svg/src/import/fx.rs | 257 ++++++++ crates/svg/src/import/fx_tests.rs | 130 ++++ crates/svg/src/import/hostile_tests.rs | 168 +++++ crates/svg/src/import/mod.rs | 650 +++++++++++++++++++ crates/svg/src/import/paint.rs | 264 ++++++++ crates/svg/src/import/paint_tests.rs | 145 +++++ crates/svg/src/import/shape.rs | 124 ++++ crates/svg/src/import/shape_tests.rs | 109 ++++ crates/svg/src/import/structure_tests.rs | 175 +++++ crates/svg/src/import/text.rs | 290 +++++++++ crates/svg/src/lib.rs | 49 +- crates/svg/src/mask_export_tests.rs | 59 ++ crates/svg/src/tests.rs | 2 +- crates/svg/tests/corpus.rs | 434 +++++++++++++ crates/svg/tests/corpus/arcs.svg | 13 + crates/svg/tests/corpus/blend_visibility.svg | 18 + crates/svg/tests/corpus/clip_mask.svg | 35 + crates/svg/tests/corpus/colors.svg | 19 + crates/svg/tests/corpus/css.svg | 18 + crates/svg/tests/corpus/fill_rule.svg | 8 + crates/svg/tests/corpus/filters.svg | 45 ++ crates/svg/tests/corpus/gradients.svg | 54 ++ crates/svg/tests/corpus/icon.svg | 9 + crates/svg/tests/corpus/illustration.svg | 27 + crates/svg/tests/corpus/image.svg | 11 + crates/svg/tests/corpus/opacity.svg | 13 + crates/svg/tests/corpus/patterns.svg | 24 + crates/svg/tests/corpus/primitives.svg | 23 + crates/svg/tests/corpus/strokes.svg | 22 + crates/svg/tests/corpus/text.svg | 8 + crates/svg/tests/corpus/transforms.svg | 25 + crates/svg/tests/corpus/units.svg | 13 + crates/svg/tests/corpus/use_symbol.svg | 16 + crates/svg/tests/corpus/viewbox_meet.svg | 10 + crates/svg/tests/corpus/viewbox_slice.svg | 9 + crates/ui-egui/src/menus.rs | 4 +- docs/cli.md | 3 +- docs/mcp.md | 22 +- 45 files changed, 3391 insertions(+), 267 deletions(-) create mode 100644 crates/engine/src/svg_import_tests.rs delete mode 100644 crates/svg/src/import.rs create mode 100644 crates/svg/src/import/fx.rs create mode 100644 crates/svg/src/import/fx_tests.rs create mode 100644 crates/svg/src/import/hostile_tests.rs create mode 100644 crates/svg/src/import/mod.rs create mode 100644 crates/svg/src/import/paint.rs create mode 100644 crates/svg/src/import/paint_tests.rs create mode 100644 crates/svg/src/import/shape.rs create mode 100644 crates/svg/src/import/shape_tests.rs create mode 100644 crates/svg/src/import/structure_tests.rs create mode 100644 crates/svg/src/import/text.rs create mode 100644 crates/svg/src/mask_export_tests.rs create mode 100644 crates/svg/tests/corpus.rs create mode 100644 crates/svg/tests/corpus/arcs.svg create mode 100644 crates/svg/tests/corpus/blend_visibility.svg create mode 100644 crates/svg/tests/corpus/clip_mask.svg create mode 100644 crates/svg/tests/corpus/colors.svg create mode 100644 crates/svg/tests/corpus/css.svg create mode 100644 crates/svg/tests/corpus/fill_rule.svg create mode 100644 crates/svg/tests/corpus/filters.svg create mode 100644 crates/svg/tests/corpus/gradients.svg create mode 100644 crates/svg/tests/corpus/icon.svg create mode 100644 crates/svg/tests/corpus/illustration.svg create mode 100644 crates/svg/tests/corpus/image.svg create mode 100644 crates/svg/tests/corpus/opacity.svg create mode 100644 crates/svg/tests/corpus/patterns.svg create mode 100644 crates/svg/tests/corpus/primitives.svg create mode 100644 crates/svg/tests/corpus/strokes.svg create mode 100644 crates/svg/tests/corpus/text.svg create mode 100644 crates/svg/tests/corpus/transforms.svg create mode 100644 crates/svg/tests/corpus/units.svg create mode 100644 crates/svg/tests/corpus/use_symbol.svg create mode 100644 crates/svg/tests/corpus/viewbox_meet.svg create mode 100644 crates/svg/tests/corpus/viewbox_slice.svg 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