Files
photocraft/crates/engine/tests/svg_smart_object.rs
T
f41eb4f70d SVG opens as shape layers and places as a vector smart object (FILE-215-9, part of #215) (#1305)
* SVG opens as shape layers and places as a vector smart object (FILE-215-9, part of #215)

crates/io/src/svg.rs: usvg parses (already in the tree through egui_extras); paths become
ShapeLayer knots with the transform chain baked in, quadratics raised to cubics and every
subpath after the first joining the component so the fill rule spans the path as SVG does;
groups keep opacity and blend mode (transform-only and id-only wrapper groups vanish);
text is shaped into outline shapes with the craft fonts plus the system's; gradients become
the layer's gradient fill (stops and angle, with a warning). Clip paths, masks, filters,
pattern paints and images are rasterised by resvg into pixel layers over their bounding box
rather than dropped. Limits: 16384 px canvas, 2000 elements, 24 M px per raster, bounded
recursion; malformed input is IoError::Svg.

smart_cmds::render rasterises an SVG source at the placement's axis scale (1/16 steps,
cached per scale) and divides the placement by it, affine and projective alike; place_bytes
renders that cache up front. svg/svgz join the openable lists; book page; scorecard row done.

Co-Authored-By: Claude Mythos 5.1 <noreply@anthropic.com>

* SVG import: never read local files from <image href>

usvg's default image resolver reads any absolute or relative path in an
<image href>, so opening an untrusted SVG could embed local files
(/etc/..., ~/.ssh/...) into the document. Parse with a resolver that
only accepts data: URLs (resources_dir None, string hrefs ignored).
Embedded sub-SVGs already get no <image> resolution from usvg. The font
database (and its system-font scan) is now only loaded when the drawing
may contain text. Regression test: a PNG on disk referenced by absolute
path, file:// URL and relative path is not loaded; a data: URL still is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: leon <claude.udder842@passmail.net>
Co-authored-by: Claude Mythos 5.1 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-08 17:04:21 -04:00

65 lines
3.5 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! An SVG placed as a smart object is a vector smart object: it is rasterised at its placement
//! scale (sharp at 10×, where a 20×10 px raster would blur), re-rendered when the placement
//! changes, and its contents open as shape layers.
use photocraft_doc::LayerContent;
use photocraft_engine::Session;
use photocraft_geom::Rect;
use serde_json::json;
const SVG: &str = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"20\" height=\"10\">\
<rect width=\"20\" height=\"10\" fill=\"#ff0000\"/>\
<rect x=\"0\" y=\"4\" width=\"20\" height=\"0.5\" fill=\"#000000\"/>\
</svg>";
fn alpha(s: &photocraft_raster::Surface, x: i32, y: i32) -> f32 {
let px = s.read_region(Rect::new(x, y, x + 1, y + 1));
*px.last().unwrap()
}
fn red(s: &photocraft_raster::Surface, x: i32, y: i32) -> f32 {
let px = s.read_region(Rect::new(x, y, x + 1, y + 1));
px[0]
}
#[test]
fn placed_svg_is_sharp_at_its_placement_scale_and_opens_as_shapes() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 400, "height": 400, "background": "white"})).unwrap();
let r = photocraft_engine::file_cmds::place_bytes(&mut s, "logo.svg", SVG.as_bytes().to_vec(), None, &json!({"scale": 1000})).unwrap();
assert_eq!(r["bounds"], json!([100.0, 150.0, 300.0, 250.0]), "{r}");
let doc = s.active().unwrap().doc.clone();
let layer = doc.layers.last().unwrap();
let LayerContent::Smart(sm) = &layer.content else { panic!("{}", layer.content.kind_name()) };
let surf = layer.surface().expect("rendered");
// A pixel-exact edge at the top of the 200 × 100 placement, not a bicubic ramp.
assert!(alpha(surf, 200, 148) < 0.01 && alpha(surf, 200, 151) > 0.99, "{} {}", alpha(surf, 200, 148), alpha(surf, 200, 151));
// The half-unit black line is a crisp 5 px band (rows 190..195) rather than a faint smear.
assert!(red(surf, 200, 192) < 0.02, "black inside the line: {}", red(surf, 200, 192));
assert!(red(surf, 200, 187) > 0.98 && red(surf, 200, 198) > 0.98, "red just outside it");
assert!(red(surf, 200, 189) > 0.9 || red(surf, 200, 189) < 0.1, "edge rows are not half-covered");
// Edit Contents on this smart object: the embedded source opens as shape layers.
let photocraft_doc::SmartSource::Embedded { file_name, bytes } = &sm.source else { panic!("embedded") };
let inner = photocraft_engine::smart_cmds::decode_source(file_name, bytes).unwrap();
assert_eq!(inner.layers.len(), 2);
assert!(inner.layers.iter().all(|l| matches!(l.content, LayerContent::Shape(_))));
}
#[test]
fn re_placing_at_another_scale_re_renders_the_vector_source() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 300, "height": 300, "background": "white"})).unwrap();
photocraft_engine::file_cmds::place_bytes(&mut s, "logo.svg", SVG.as_bytes().to_vec(), None, &json!({"scale": 100})).unwrap();
let doc = s.active().unwrap().doc.clone();
let layer = doc.layers.last().unwrap();
let LayerContent::Smart(sm) = &layer.content else { panic!() };
let mut big = sm.clone();
let [a, b, c, d, _, _] = big.transform.m;
big.transform.m = [a * 12.0, b * 12.0, c * 12.0, d * 12.0, 30.0, 30.0];
let surf = photocraft_engine::smart_cmds::render(&doc, &big).unwrap().expect("source available");
// 20 × 10 units at 12× from (30, 30): a hard edge at x = 270 and the thin line at rows 78..84.
assert!(alpha(&surf, 268, 60) > 0.99 && alpha(&surf, 271, 60) < 0.01);
assert!(red(&surf, 150, 81) < 0.02 && red(&surf, 150, 75) > 0.98 && red(&surf, 150, 87) > 0.98);
}