diff --git a/Cargo.lock b/Cargo.lock
index 00c2e506bb..86a22b278a 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -973,6 +973,15 @@ dependencies = [
"libc",
]
+[[package]]
+name = "core_maths"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30"
+dependencies = [
+ "libm",
+]
+
[[package]]
name = "cpufeatures"
version = "0.3.1"
@@ -1653,6 +1662,28 @@ dependencies = [
"serde",
]
+[[package]]
+name = "fontconfig-parser"
+version = "0.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646"
+dependencies = [
+ "roxmltree",
+]
+
+[[package]]
+name = "fontdb"
+version = "0.23.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905"
+dependencies = [
+ "fontconfig-parser",
+ "log",
+ "slotmap",
+ "tinyvec",
+ "ttf-parser",
+]
+
[[package]]
name = "fontique"
version = "0.11.1"
@@ -1848,6 +1879,16 @@ dependencies = [
"r-efi 6.0.0",
]
+[[package]]
+name = "gif"
+version = "0.13.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b"
+dependencies = [
+ "color_quant",
+ "weezl",
+]
+
[[package]]
name = "gif"
version = "0.14.2"
@@ -2227,7 +2268,7 @@ dependencies = [
"bytemuck",
"byteorder-lite",
"color_quant",
- "gif",
+ "gif 0.14.2",
"moxcms",
"num-traits",
"png 0.18.1",
@@ -2235,8 +2276,8 @@ dependencies = [
"ravif",
"rgb",
"tiff",
- "zune-core",
- "zune-jpeg",
+ "zune-core 0.5.3",
+ "zune-jpeg 0.5.15",
]
[[package]]
@@ -3561,8 +3602,8 @@ dependencies = [
"rayon",
"thiserror 2.0.21",
"tiff",
- "zune-core",
- "zune-jpeg",
+ "zune-core 0.5.3",
+ "zune-jpeg 0.5.15",
]
[[package]]
@@ -3698,6 +3739,7 @@ dependencies = [
"photocraft-text",
"photocraft-vector",
"proptest",
+ "resvg",
"serde_json",
"thiserror 2.0.21",
]
@@ -4404,12 +4446,15 @@ version = "0.45.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8928798c0a55e03c9ca6c4c6846f76377427d2c1e1f7e6de3c06ae57942df43"
dependencies = [
+ "gif 0.13.3",
+ "image-webp",
"log",
"pico-args",
"rgb",
"svgtypes",
"tiny-skia",
"usvg",
+ "zune-jpeg 0.4.21",
]
[[package]]
@@ -4579,6 +4624,24 @@ dependencies = [
"wait-timeout",
]
+[[package]]
+name = "rustybuzz"
+version = "0.20.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702"
+dependencies = [
+ "bitflags 2.13.2",
+ "bytemuck",
+ "core_maths",
+ "log",
+ "smallvec",
+ "ttf-parser",
+ "unicode-bidi-mirroring",
+ "unicode-ccc",
+ "unicode-properties",
+ "unicode-script",
+]
+
[[package]]
name = "ruzstd"
version = "0.9.0"
@@ -5076,7 +5139,7 @@ dependencies = [
"half",
"quick-error 2.0.1",
"weezl",
- "zune-jpeg",
+ "zune-jpeg 0.5.15",
]
[[package]]
@@ -5126,6 +5189,12 @@ dependencies = [
"serde_json",
]
+[[package]]
+name = "tinyvec"
+version = "1.13.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
+
[[package]]
name = "tokio"
version = "1.53.1"
@@ -5271,6 +5340,15 @@ dependencies = [
"petgraph",
]
+[[package]]
+name = "ttf-parser"
+version = "0.25.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
+dependencies = [
+ "core_maths",
+]
+
[[package]]
name = "twox-hash"
version = "2.1.4"
@@ -5309,6 +5387,24 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
+[[package]]
+name = "unicode-bidi"
+version = "0.3.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
+
+[[package]]
+name = "unicode-bidi-mirroring"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe"
+
+[[package]]
+name = "unicode-ccc"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e"
+
[[package]]
name = "unicode-general-category"
version = "1.1.0"
@@ -5321,12 +5417,30 @@ version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
+[[package]]
+name = "unicode-properties"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
+
+[[package]]
+name = "unicode-script"
+version = "0.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee"
+
[[package]]
name = "unicode-segmentation"
version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
+[[package]]
+name = "unicode-vo"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94"
+
[[package]]
name = "unicode-width"
version = "0.2.2"
@@ -5342,16 +5456,21 @@ dependencies = [
"base64",
"data-url",
"flate2",
+ "fontdb",
"imagesize",
"kurbo 0.11.3",
"log",
"pico-args",
"roxmltree",
+ "rustybuzz",
"simplecss",
"siphasher",
"strict-num",
"svgtypes",
"tiny-skia-path",
+ "unicode-bidi",
+ "unicode-script",
+ "unicode-vo",
"xmlwriter",
]
@@ -6645,6 +6764,12 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+[[package]]
+name = "zune-core"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a"
+
[[package]]
name = "zune-core"
version = "0.5.3"
@@ -6660,13 +6785,22 @@ dependencies = [
"simd-adler32",
]
+[[package]]
+name = "zune-jpeg"
+version = "0.4.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713"
+dependencies = [
+ "zune-core 0.4.12",
+]
+
[[package]]
name = "zune-jpeg"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
dependencies = [
- "zune-core",
+ "zune-core 0.5.3",
]
[[package]]
diff --git a/README.md b/README.md
index 3bfd30e8c7..709b00daad 100644
--- a/README.md
+++ b/README.md
@@ -199,7 +199,7 @@ Every screenshot here is the real app at work on public-domain art, rendered off
🗂️ Formats
- PSD and PSB, layered TIFF (Photoshop's layer data in the TIFF, read and written in either byte order), plus flat PNG, JPEG, TIFF, WebP (lossy and lossless), GIF, BMP, TGA, ICO, QOI, PNM, OpenEXR, Radiance HDR and AVIF, with symmetric read and write at 8, 16 and 32 bits, HEIC photos from iPhone and Mac (read; in official builds, an optional --features heif build feature), and the native .pcraft format.
+ PSD and PSB, layered TIFF (Photoshop's layer data in the TIFF, read and written in either byte order), SVG (opens as shape layers, places as a vector Smart Object), plus flat PNG, JPEG, TIFF, WebP (lossy and lossless), GIF, BMP, TGA, ICO, QOI, PNM, OpenEXR, Radiance HDR and AVIF, with symmetric read and write at 8, 16 and 32 bits, HEIC photos from iPhone and Mac (read; in official builds, an optional --features heif build feature), and the native .pcraft format.
🪄 The everyday essentials
diff --git a/apps/photocraft-web/src/web.rs b/apps/photocraft-web/src/web.rs
index 95e31faacd..fa543d099d 100644
--- a/apps/photocraft-web/src/web.rs
+++ b/apps/photocraft-web/src/web.rs
@@ -15,7 +15,7 @@ type Inbox = Arc)>>>;
/// brushes (.abr) and gradients (.grd), which go to the preset libraries.
const OPEN_EXTS: &[&str] = &[
"pcraft", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam",
- "pfm", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd",
+ "pfm", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz",
];
const CANVAS_ID: &str = "photocraft_canvas";
diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs
index e377302f10..9b05eefee3 100644
--- a/apps/photocraft/src/services.rs
+++ b/apps/photocraft/src/services.rs
@@ -15,7 +15,7 @@ use std::sync::Arc;
/// brushes (.abr) and gradients (.grd), which go to the preset libraries.
const OPEN_EXTS: &[&str] = &[
"pcraft", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam",
- "pfm", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd",
+ "pfm", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz",
];
/// File › Save As formats: (filter name, extensions). The filter matching the suggested name's
diff --git a/book/src/SUMMARY.md b/book/src/SUMMARY.md
index d3ef25c8c8..a141aded96 100644
--- a/book/src/SUMMARY.md
+++ b/book/src/SUMMARY.md
@@ -21,6 +21,7 @@
- [Overview](formats/overview.md)
- [PSD and PSB](formats/psd-psb.md)
- [Raster formats](formats/raster-formats.md)
+- [SVG](formats/svg.md)
- [The `.pcraft` format](formats/pcraft.md)
# Automation
diff --git a/book/src/formats/overview.md b/book/src/formats/overview.md
index 7c36352a50..bb8108c082 100644
--- a/book/src/formats/overview.md
+++ b/book/src/formats/overview.md
@@ -10,4 +10,4 @@ External bytes enter PhotoCraft through three main paths:
Formats are a primary security boundary. File extension is not a trust signal, metadata can be attacker-controlled, and small compressed inputs can declare very large decoded outputs. Callers should use the bounded default APIs and preserve errors rather than retrying with unlimited limits.
-See [PSD and PSB](psd-psb.md), [Raster formats](raster-formats.md), and [The `.pcraft` format](pcraft.md). Cross-format hardening guidance is in [Parser hardening](../security/parser-hardening.md).
+See [PSD and PSB](psd-psb.md), [Raster formats](raster-formats.md), [SVG](svg.md), and [The `.pcraft` format](pcraft.md). Cross-format hardening guidance is in [Parser hardening](../security/parser-hardening.md).
diff --git a/book/src/formats/svg.md b/book/src/formats/svg.md
new file mode 100644
index 0000000000..7db504916f
--- /dev/null
+++ b/book/src/formats/svg.md
@@ -0,0 +1,23 @@
+# SVG
+
+An SVG opens as a document of shape layers, and places (File › Place Embedded / Place Linked) as a vector smart object. Both go through `photocraft-io`'s `svg` module; there is no SVG export.
+
+## Opening
+
+`usvg` parses the file (it is the same crate the UI's icons go through): CSS, `use`, units, nested transforms and `viewBox` are resolved, and text is shaped into outlines with the bundled craft fonts plus, on the desktop, the system's fonts. The resulting tree becomes layers in paint order:
+
+| SVG | Layer |
+|---|---|
+| `path`, `rect`, `circle`, `ellipse`, `line`, `polyline`, `polygon` | A shape layer: cubic Bézier knots with the element's transform baked in, fill (colour and opacity, fill rule), stroke (width, colour, caps, joins, miter limit, dashes). |
+| `g` with an `id`, opacity or blend mode | A layer group with that opacity and blend mode. Groups that only carry a transform are not layers. |
+| `text` | A group of outline shapes named after the text. |
+| Linear and radial gradient paints | The layer's own gradient fill with the stops and direction; the exact gradient geometry is not kept (a warning says so). |
+| `clip-path`, `mask`, `filter`, pattern paints, `image` | Rasterised by `resvg` into a pixel layer over the element's bounding box, so the picture looks right while those parts are not editable as shapes (a warning names each kind). |
+
+The canvas is the drawing's size in CSS px (from `width`/`height`, else the `viewBox`), RGB 8-bit, transparent where nothing is drawn. A drawing larger than 16 384 px on a side is scaled down to fit. Past 2 000 elements the drawing opens as a single pixel layer rather than thousands of shapes. Malformed SVG is an error, never a panic; `.svgz` is accepted by its extension and plain SVG is also recognised by content when the extension is missing.
+
+## Placing
+
+A placed SVG keeps its bytes as the smart object's source. Whenever the smart object is rendered (at placement, after Free Transform, when a smart filter changes) the drawing is rasterised at the placement's scale and then positioned, so it stays sharp at 1 000 % where a resampled raster would blur. Edit Contents opens the source as the shape-layer document above; saving it back stores the edited document, as for any smart object.
+
+Limits: a single render is capped at 24 million pixels; warped smart objects and stacks render from the drawing's own size.
diff --git a/crates/engine/src/file_cmds.rs b/crates/engine/src/file_cmds.rs
index 4b4d6269e8..74326ef4f9 100644
--- a/crates/engine/src/file_cmds.rs
+++ b/crates/engine/src/file_cmds.rs
@@ -117,7 +117,7 @@ pub(crate) fn list_images(dir: &str) -> Result> {
/// Extensions the batch commands pick up from a folder.
const OPENABLE: &[&str] = &[
"psd", "psb", "pcraft", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "exr", "hdr", "qoi", "ico", "pnm", "ppm", "pgm", "heic", "heif",
- "hif", "dng", "cr2", "nef", "nrw", "arw", "pef",
+ "hif", "dng", "cr2", "nef", "nrw", "arw", "pef", "svg", "svgz",
];
pub(crate) fn file_name(path: &str) -> String {
@@ -365,6 +365,7 @@ pub fn place_bytes(s: &mut Session, name: &str, bytes: Vec, linked: Option, linked: Option SmartSource::Linked { path },
None => SmartSource::Embedded { file_name: file_name(name), bytes: Arc::new(bytes) },
};
- let so = SmartObject::new(source, Affine { m: [scale, 0.0, 0.0, scale, dx, dy] }, Some(px));
+ let mut so = SmartObject::new(source, Affine { m: [scale, 0.0, 0.0, scale, dx, dy] }, Some(px));
+ // A vector source (SVG) renders at its placement scale instead of as a resampled raster.
+ if vector && let Some(sharp) = crate::smart_cmds::render(&d.doc, &so)? {
+ so.cache = Some(sharp);
+ }
let layer_name = stem(name);
let label = if matches!(so.source, SmartSource::Linked { .. }) { "Place Linked" } else { PLACE_EMBEDDED };
let id = s.edit(label, |doc, active| {
diff --git a/crates/engine/src/smart_cmds.rs b/crates/engine/src/smart_cmds.rs
index ca097122c0..73bfe3a4e6 100644
--- a/crates/engine/src/smart_cmds.rs
+++ b/crates/engine/src/smart_cmds.rs
@@ -156,6 +156,33 @@ fn buffer_to_surface(buf: &photocraft_compose::Buffer, fmt: PixelFormat) -> Surf
s
}
+/// An SVG source rendered at `scale` times its own size (cached per scale), so a vector smart
+/// object stays sharp however it is placed.
+pub fn svg_source_image(bytes: &[u8], fmt: PixelFormat, scale: f64) -> Result {
+ let mut h = blake3::Hasher::new();
+ h.update(bytes);
+ h.update(&scale.to_bits().to_le_bytes());
+ let key = (*h.finalize().as_bytes(), fmt);
+ if let Some(img) = cache_get(&key) {
+ return Ok(img);
+ }
+ let buf = photocraft_io::svg::rasterize(bytes, scale as f32).map_err(|e| other(format!("can't render the SVG smart object: {e}")))?;
+ let img = SourceImage { surface: Arc::new(buffer_to_surface(&buf, fmt)), bounds: buf.rect };
+ cache_put(key, img.clone());
+ Ok(img)
+}
+
+/// The scale a vector source is rasterised at for its placement: the larger axis scale of the
+/// transform in 1/16 steps (so nudges reuse the cache), between 1/16 and 64.
+fn vector_scale(sm: &SmartObject) -> f64 {
+ let [a, b, c, d, ..] = sm.transform.m;
+ let k = (a * a + b * b).sqrt().max((c * c + d * d).sqrt());
+ if !k.is_finite() {
+ return 1.0;
+ }
+ ((k * 16.0).ceil() / 16.0).clamp(1.0 / 16.0, 64.0)
+}
+
/// The composited source image of `bytes` in pixel format `fmt` (decoded once, then cached).
pub fn source_image(file_name: &str, bytes: &[u8], fmt: PixelFormat) -> Result {
let key = cache_key(bytes, fmt);
@@ -295,6 +322,25 @@ pub fn apply_smart_filters(placed: &Surface, sm: &SmartObject, canvas: Rect) ->
/// linked file, or a PSD placed layer without embedded data): callers keep the existing cache.
pub fn render(doc: &Document, sm: &SmartObject) -> Result> {
let Some((name, bytes)) = source_bytes(&doc.metadata, &sm.source) else { return Ok(None) };
+ // A vector source is rendered at the placement's scale, and the placement is divided by
+ // that scale so the rendered pixels land where the source units would.
+ if sm.stack_mode.is_none() && sm.warp.is_none() && photocraft_io::svg::is_svg(&bytes) {
+ let k = vector_scale(sm);
+ let img = svg_source_image(&bytes, doc.pixel_format(), k)?;
+ let placed = match &sm.perspective {
+ Some(p) => {
+ let [h0, h1, h2, h3, h4, h5, h6, h7, h8] = *p;
+ let h = Homography([h0 / k, h1 / k, h2, h3 / k, h4 / k, h5, h6 / k, h7 / k, h8]);
+ photocraft_algo::warp::place_source_projective(&img.surface, img.bounds, &h, None)
+ }
+ None => {
+ let [a, b, c, d, e, f] = sm.transform.m;
+ let t = Affine { m: [a / k, b / k, c / k, d / k, e, f] };
+ photocraft_algo::warp::place_source(&img.surface, img.bounds, &t, None)
+ }
+ };
+ return Ok(Some(apply_smart_filters(&placed, sm, doc.bounds())));
+ }
let img = match sm.stack_mode {
Some(mode) => stack_image(&name, &bytes, doc.pixel_format(), mode)?,
None => source_image(&name, &bytes, doc.pixel_format())?,
diff --git a/crates/engine/tests/svg_smart_object.rs b/crates/engine/tests/svg_smart_object.rs
new file mode 100644
index 0000000000..16d8f3d459
--- /dev/null
+++ b/crates/engine/tests/svg_smart_object.rs
@@ -0,0 +1,64 @@
+//! 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 = "\
+ \
+ \
+ ";
+
+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);
+}
diff --git a/crates/io/Cargo.toml b/crates/io/Cargo.toml
index eb8bc833cd..eb917910c4 100644
--- a/crates/io/Cargo.toml
+++ b/crates/io/Cargo.toml
@@ -33,6 +33,13 @@ photocraft-format = { path = "../format" }
thiserror = { workspace = true }
serde_json = { workspace = true }
blake3 = { version = "1.8", default-features = false, features = ["pure"] }
+# SVG import (svg.rs): usvg parses, resvg rasterises what the shape model can't hold. Both are
+# already in the tree through the UI's icon loader; text shaping needs the fonts feature set.
+[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
+resvg = { version = "0.45", default-features = false, features = ["text", "system-fonts", "raster-images"] }
+
+[target.'cfg(target_arch = "wasm32")'.dependencies]
+resvg = { version = "0.45", default-features = false, features = ["text", "raster-images"] }
[dev-dependencies]
photocraft-psd = { workspace = true, features = ["testgen"] }
diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs
index beb91a1e83..48fb32e632 100644
--- a/crates/io/src/lib.rs
+++ b/crates/io/src/lib.rs
@@ -41,6 +41,7 @@ mod psd_import;
pub mod raw;
pub mod slices_map;
pub mod smart_map;
+pub mod svg;
pub mod text_styles_map;
pub mod tiff_layers;
pub mod vector_map;
@@ -75,6 +76,9 @@ pub enum IoError {
/// Camera raw decode failure.
#[error("{0}")]
Raw(#[from] photocraft_raw::RawError),
+ /// An SVG that does not parse (or is too large to rasterise).
+ #[error("SVG: {0}")]
+ Svg(String),
/// A background import was cancelled ([`import_with`]).
#[error("cancelled")]
Cancelled,
@@ -176,6 +180,9 @@ fn import_stages(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt) -
if raw::is_raw(bytes) {
return raw::import_raw(name, bytes);
}
+ if has_extension(name, "svg") || has_extension(name, "svgz") || svg::is_svg(bytes) {
+ return svg::import_svg(name, bytes);
+ }
flat::import_flat(name, bytes)
}
diff --git a/crates/io/src/svg.rs b/crates/io/src/svg.rs
new file mode 100644
index 0000000000..6b7abe76fd
--- /dev/null
+++ b/crates/io/src/svg.rs
@@ -0,0 +1,562 @@
+//! SVG import: a drawing opens as a document of shape layers, and places as a vector smart
+//! object that is re-rasterised at its placement scale ([`rasterize`]).
+//!
+//! Parsing is `usvg`'s (the crate the UI's icons already go through): it resolves CSS, `use`,
+//! units, nested transforms and turns text into outlines, handing over a tree of groups, paths,
+//! images and text. Each path becomes a [`ShapeLayer`] (cubic Bézier knots, fill, stroke) and
+//! each group a layer group with its opacity and blend mode. What the shape model cannot hold,
+//! clip paths, masks, filters, pattern paints and embedded images, is rasterised by `resvg` into
+//! a pixel layer so the picture still looks right, with a warning; gradient paints keep their
+//! stops and direction in the layer's own gradient fill, with a warning that the exact geometry
+//! is not kept.
+
+use std::sync::{Arc, OnceLock};
+
+use photocraft_color::blend::BlendMode;
+use photocraft_color::{Color, ColorMode, PixelFormat, SampleType};
+use photocraft_doc::effects::GradientStyle;
+use photocraft_doc::vector::{FillRule, Knot, LineCap, LineJoin, Path, PathOp, ShapeLayer, ShapeStroke, StrokeAlign, Subpath};
+use photocraft_doc::{Document, Fill, Group, Layer, LayerContent, MAX_GROUP_DEPTH};
+use photocraft_geom::{Point, Rect, Size};
+use photocraft_raster::Surface;
+use resvg::tiny_skia;
+use resvg::usvg;
+
+use crate::{ImportResult, IoError};
+
+/// Largest canvas side an SVG opens at; a larger drawing is scaled down to fit.
+pub const MAX_SIDE: u32 = 16_384;
+/// A rasterised fallback layer or a smart-object render never exceeds this many pixels.
+const MAX_RASTER_PIXELS: u64 = 24_000_000;
+/// Past this many drawable elements the whole drawing opens as one pixel layer.
+const MAX_NODES: usize = 2_000;
+/// Nesting past which a subtree is rasterised instead of walked (bounded recursion).
+const MAX_RECURSION: usize = 200;
+
+/// Does this look like an SVG document (plain XML; `.svgz` is recognised by its extension)?
+pub fn is_svg(bytes: &[u8]) -> bool {
+ let head = bytes.get(..bytes.len().min(4096)).unwrap_or(bytes);
+ let text = String::from_utf8_lossy(head);
+ let t = text.trim_start_matches('\u{feff}').trim_start();
+ if t.starts_with(" Arc {
+ static DB: OnceLock> = OnceLock::new();
+ DB.get_or_init(|| {
+ let mut db = usvg::fontdb::Database::new();
+ for f in photocraft_text::CRAFT_FONTS {
+ db.load_font_data(f.bytes.to_vec());
+ }
+ #[cfg(not(target_arch = "wasm32"))]
+ db.load_system_fonts();
+ Arc::new(db)
+ })
+ .clone()
+}
+
+/// Parse options for an untrusted drawing. `` resolves only `data:` URLs: a file path
+/// or relative href is never read, so opening an SVG cannot pull local files (`/etc/...`,
+/// `~/.ssh/...`) into the document. Embedded SVG images get no `` resolution at all
+/// (`usvg` already blanks it for sub-SVGs). The font database (and its one-off scan of the system
+/// fonts) is only loaded when the drawing may contain text.
+fn options(bytes: &[u8]) -> usvg::Options<'static> {
+ // `.svgz` is gzip: it can't be scanned before usvg inflates it, so assume it has text.
+ let has_text = bytes.starts_with(&[0x1f, 0x8b]) || bytes.windows(4).any(|w| w == b"text");
+ usvg::Options {
+ resources_dir: None,
+ image_href_resolver: usvg::ImageHrefResolver { resolve_data: usvg::ImageHrefResolver::default_data_resolver(), resolve_string: Box::new(|_, _| None) },
+ fontdb: if has_text { fonts() } else { Arc::new(usvg::fontdb::Database::new()) },
+ ..usvg::Options::default()
+ }
+}
+
+fn parse(bytes: &[u8]) -> Result {
+ let opt = options(bytes);
+ usvg::Tree::from_data(bytes, &opt).map_err(|e| IoError::Svg(e.to_string()))
+}
+
+/// The document canvas for a drawing: its size in px, scaled down to [`MAX_SIDE`] when needed.
+struct Canvas {
+ size: Size,
+ /// Drawing units → canvas pixels (1 unless the drawing had to shrink).
+ scale: f32,
+}
+
+fn canvas(tree: &usvg::Tree, warnings: &mut Vec) -> Canvas {
+ let (w, h) = (tree.size().width().max(1.0), tree.size().height().max(1.0));
+ let limit = MAX_SIDE as f32;
+ let scale = (limit / w).min(limit / h).min(1.0);
+ if scale < 1.0 {
+ warnings.push(format!("SVG: the {w:.0} × {h:.0} px drawing was scaled down to fit {MAX_SIDE} px"));
+ }
+ let side = |v: f32| ((v * scale).round() as u32).clamp(1, MAX_SIDE);
+ Canvas { size: Size::new(side(w), side(h)), scale }
+}
+
+/// Opens an SVG as a document of shape layers (see the module docs for what is rasterised).
+pub fn import_svg(name: &str, bytes: &[u8]) -> Result {
+ let tree = parse(bytes)?;
+ let mut warnings = Vec::new();
+ let cv = canvas(&tree, &mut warnings);
+ let mut doc = Document::new(name, cv.size, ColorMode::Rgb, SampleType::U8);
+ let fmt = doc.pixel_format();
+ let global = tiny_skia::Transform::from_scale(cv.scale, cv.scale);
+ let mut conv = Conv { fmt, canvas: doc.bounds(), global, warnings, noted: Vec::new(), counts: [0; 4] };
+ if count_nodes(tree.root()) > MAX_NODES {
+ conv.warnings.push(format!("SVG: more than {MAX_NODES} elements; opened as one pixel layer instead of shapes"));
+ let (w, h) = (cv.size.width, cv.size.height);
+ if let Some(mut pixmap) = pixmap(w, h) {
+ resvg::render(&tree, global, &mut pixmap.as_mut());
+ doc.layers.push(Layer::new("Background", LayerContent::Raster(surface(&pixmap, doc.bounds(), fmt))));
+ }
+ } else {
+ doc.layers = conv.convert_children(tree.root().children(), 0, 0, tree.root().abs_transform());
+ }
+ if doc.layers.is_empty() {
+ conv.warnings.push("SVG: nothing to draw (an empty drawing)".to_string());
+ }
+ Ok(ImportResult { document: doc, warnings: conv.warnings })
+}
+
+/// The whole drawing rendered at `scale` (1 = its own size), as a straight-alpha RGBA buffer at
+/// the origin. Smart objects use it to stay sharp at any placement.
+pub fn rasterize(bytes: &[u8], scale: f32) -> Result {
+ let tree = parse(bytes)?;
+ let cv = canvas(&tree, &mut Vec::new());
+ let k = cv.scale * scale.clamp(1.0 / 64.0, 64.0);
+ let side = |v: f32| ((v * k).ceil() as u32).max(1);
+ let (w, h) = (side(tree.size().width()), side(tree.size().height()));
+ let mut pixmap = pixmap(w, h).ok_or_else(|| IoError::Svg(format!("{w} × {h} px is too large to rasterise")))?;
+ resvg::render(&tree, tiny_skia::Transform::from_scale(k, k), &mut pixmap.as_mut());
+ let rect = Rect::new(0, 0, w as i32, h as i32);
+ let px = pixmap.pixels().iter().map(|p| rgba(p.demultiply())).collect();
+ Ok(photocraft_compose::Buffer { rect, px })
+}
+
+/// A transparent pixmap, or `None` past [`MAX_RASTER_PIXELS`].
+fn pixmap(w: u32, h: u32) -> Option {
+ if u64::from(w) * u64::from(h) > MAX_RASTER_PIXELS {
+ return None;
+ }
+ tiny_skia::Pixmap::new(w, h)
+}
+
+fn rgba(c: tiny_skia::ColorU8) -> [f32; 4] {
+ [f32::from(c.red()) / 255.0, f32::from(c.green()) / 255.0, f32::from(c.blue()) / 255.0, f32::from(c.alpha()) / 255.0]
+}
+
+/// A pixmap as a surface covering `rect` in `fmt`.
+fn surface(pixmap: &tiny_skia::Pixmap, rect: Rect, fmt: PixelFormat) -> Surface {
+ let n = fmt.channels();
+ let mut data = vec![0.0f32; pixmap.pixels().len() * n];
+ for (p, out) in pixmap.pixels().iter().zip(data.chunks_exact_mut(n)) {
+ photocraft_raster::from_rgba_into(&fmt, rgba(p.demultiply()), out);
+ }
+ let mut s = Surface::new(fmt);
+ if !rect.is_empty() {
+ s.write_region(rect, &data);
+ }
+ s.prune();
+ s
+}
+
+/// Drawable elements in the tree (iterative: the nesting can be anything).
+fn count_nodes(root: &usvg::Group) -> usize {
+ let mut n = 0;
+ let mut stack: Vec<&usvg::Group> = vec![root];
+ while let Some(g) = stack.pop() {
+ for node in g.children() {
+ n += 1;
+ if let usvg::Node::Group(child) = node {
+ stack.push(child);
+ }
+ if n > MAX_NODES {
+ return n;
+ }
+ }
+ }
+ n
+}
+
+/// Converts a parsed tree into layers.
+struct Conv {
+ fmt: PixelFormat,
+ canvas: Rect,
+ /// Drawing units → canvas pixels.
+ global: tiny_skia::Transform,
+ warnings: Vec,
+ /// Features already warned about (one warning each).
+ noted: Vec<&'static str>,
+ /// Unnamed shapes, groups, images and texts so far, for "Shape 3"-style names.
+ counts: [usize; 4],
+}
+
+impl Conv {
+ fn note(&mut self, what: &'static str, msg: String) {
+ if !self.noted.contains(&what) {
+ self.noted.push(what);
+ self.warnings.push(msg);
+ }
+ }
+
+ /// The element's `id`, or a numbered name by kind (0 shape, 1 group, 2 image, 3 text).
+ fn name(&mut self, id: &str, kind: usize) -> String {
+ if !id.is_empty() {
+ return id.to_string();
+ }
+ let label = ["Shape", "Group", "Image", "Text"].get(kind).copied().unwrap_or("Layer");
+ let n = self.counts.get_mut(kind).map(|c| {
+ *c += 1;
+ *c
+ });
+ format!("{label} {}", n.unwrap_or(0))
+ }
+
+ /// `nodes` in paint order (first = bottom), as layers in the same order. `parent` is the
+ /// absolute transform of the group holding them (what resvg needs to render one of them
+ /// alone, since a node's own data is in its parent's space).
+ fn convert_children(&mut self, nodes: &[usvg::Node], depth: usize, rec: usize, parent: tiny_skia::Transform) -> Vec {
+ let mut out = Vec::new();
+ for node in nodes {
+ self.convert_node(node, &mut out, depth, rec, parent);
+ }
+ out
+ }
+
+ fn convert_node(&mut self, node: &usvg::Node, out: &mut Vec, depth: usize, rec: usize, parent: tiny_skia::Transform) {
+ match node {
+ usvg::Node::Group(g) => {
+ let dropped: [(bool, &'static str); 3] =
+ [(!g.filters().is_empty(), "filter"), (g.clip_path().is_some(), "clip-path"), (g.mask().is_some(), "mask")];
+ let too_deep = depth >= MAX_GROUP_DEPTH || rec >= MAX_RECURSION;
+ if dropped.iter().any(|d| d.0) || too_deep {
+ for (yes, what) in dropped {
+ if yes {
+ self.note(what, format!("SVG: a group with a {what} was rasterised, so it is not editable as shapes"));
+ }
+ }
+ if too_deep {
+ self.note("depth", "SVG: groups nested too deeply were rasterised".to_string());
+ }
+ let name = self.name(g.id(), 1);
+ out.extend(self.raster_layer(node, name, parent));
+ return;
+ }
+ // A group that only carries a transform (baked into its paths by usvg) or that
+ // usvg inserted for its own bookkeeping adds nothing to the layer tree.
+ let plain = g.id().is_empty() && g.opacity().get() >= 1.0 && g.blend_mode() == usvg::BlendMode::Normal;
+ let children = self.convert_children(g.children(), if plain { depth } else { depth + 1 }, rec + 1, g.abs_transform());
+ if children.is_empty() {
+ return;
+ }
+ if plain {
+ out.extend(children);
+ return;
+ }
+ let only_id = g.opacity().get() >= 1.0 && g.blend_mode() == usvg::BlendMode::Normal;
+ if only_id && children.len() == 1 {
+ if let Some(mut child) = children.into_iter().next() {
+ child.name = g.id().to_string();
+ out.push(child);
+ }
+ return;
+ }
+ let mut l = Layer::new(self.name(g.id(), 1), LayerContent::Group(Group { children, expanded: false, artboard: None }));
+ l.opacity = g.opacity().get();
+ l.blend = blend(g.blend_mode());
+ out.push(l);
+ }
+ usvg::Node::Path(p) => out.extend(self.shape_layer(node, p, parent)),
+ usvg::Node::Image(i) => {
+ self.note("image", "SVG: embedded images are pixel layers".to_string());
+ let name = self.name(i.id(), 2);
+ out.extend(self.raster_layer(node, name, parent));
+ }
+ usvg::Node::Text(t) => {
+ let children = self.convert_children(t.flattened().children(), depth + 1, rec + 1, t.flattened().abs_transform());
+ if children.is_empty() {
+ self.note("text", "SVG: text needs a font the drawing can use; none matched, so it was dropped".to_string());
+ return;
+ }
+ let name = if t.id().is_empty() {
+ let content: String = t.chunks().iter().map(|c| c.text()).collect::>().join("");
+ let content = content.trim();
+ if content.is_empty() { self.name("", 3) } else { content.chars().take(31).collect() }
+ } else {
+ t.id().to_string()
+ };
+ out.push(Layer::new(name, LayerContent::Group(Group { children, expanded: false, artboard: None })));
+ }
+ }
+ }
+
+ /// A path as a shape layer, or a pixel layer when its paint is a pattern.
+ fn shape_layer(&mut self, node: &usvg::Node, p: &usvg::Path, parent: tiny_skia::Transform) -> Option {
+ if !p.is_visible() {
+ return None;
+ }
+ let is_pattern = |paint: &usvg::Paint| matches!(paint, usvg::Paint::Pattern(_));
+ if p.fill().is_some_and(|f| is_pattern(f.paint())) || p.stroke().is_some_and(|s| is_pattern(s.paint())) {
+ self.note("pattern", "SVG: pattern paints were rasterised, so those shapes are not editable".to_string());
+ let name = self.name(p.id(), 0);
+ return self.raster_layer(node, name, parent);
+ }
+ let ts = p.abs_transform().post_concat(self.global);
+ let data = (*p.data()).clone().transform(ts)?;
+ let subpaths = knots(&data);
+ if subpaths.is_empty() {
+ return None;
+ }
+ let fill = p.fill().map(|f| self.paint(f.paint(), f.opacity().get(), ts));
+ let stroke = p.stroke().map(|s| {
+ // Stroke widths are in drawing units; under a non-uniform scale they take the
+ // geometric mean, the one width the shape model has.
+ let (kx, ky) = ((ts.sx * ts.sx + ts.ky * ts.ky).sqrt(), (ts.kx * ts.kx + ts.sy * ts.sy).sqrt());
+ if (kx - ky).abs() > 0.01 * kx.max(ky) {
+ self.note("stroke-scale", "SVG: strokes under a non-uniform scale keep one averaged width".to_string());
+ }
+ let k = (ts.sx * ts.sy - ts.kx * ts.ky).abs().sqrt().max(1e-6);
+ let width = (s.width().get() * k).max(0.01);
+ ShapeStroke {
+ width,
+ paint: self.paint(s.paint(), 1.0, ts),
+ opacity: s.opacity().get(),
+ align: StrokeAlign::Center,
+ cap: match s.linecap() {
+ usvg::LineCap::Butt => LineCap::Butt,
+ usvg::LineCap::Round => LineCap::Round,
+ usvg::LineCap::Square => LineCap::Square,
+ },
+ join: match s.linejoin() {
+ usvg::LineJoin::Round => LineJoin::Round,
+ usvg::LineJoin::Bevel => LineJoin::Bevel,
+ usvg::LineJoin::Miter | usvg::LineJoin::MiterClip => LineJoin::Miter,
+ },
+ miter_limit: s.miterlimit().get(),
+ dashes: s.dasharray().map(|d| d.iter().map(|v| v * k / width).collect()).unwrap_or_default(),
+ dash_offset: s.dashoffset() * k / width,
+ }
+ });
+ let fill_rule = match p.fill().map(|f| f.rule()) {
+ Some(usvg::FillRule::EvenOdd) => FillRule::EvenOdd,
+ _ => FillRule::NonZero,
+ };
+ let mut sh = ShapeLayer { path: Path { subpaths, fill_rule, inverted: false }, fill, stroke, ..Default::default() };
+ sh.cache = Some(photocraft_vector::render_shape(&sh, self.fmt, self.canvas));
+ Some(Layer::new(self.name(p.id(), 0), LayerContent::Shape(sh)))
+ }
+
+ /// A paint as a layer fill. `ts` maps the paint's space onto the canvas.
+ fn paint(&mut self, paint: &usvg::Paint, opacity: f32, ts: tiny_skia::Transform) -> Fill {
+ let stops = |g: &usvg::BaseGradient| -> Vec<(f32, Color)> {
+ g.stops().iter().map(|s| (s.offset().get(), color(s.color(), s.opacity().get() * opacity))).collect()
+ };
+ let gradient = |stops: Vec<(f32, Color)>, angle: f32, style: GradientStyle| Fill::Gradient {
+ stops,
+ angle,
+ scale: 1.0,
+ style,
+ reverse: false,
+ opacity_stops: Vec::new(),
+ midpoints: Vec::new(),
+ offset: (0.0, 0.0),
+ dither: false,
+ align: true,
+ };
+ match paint {
+ usvg::Paint::Color(c) => Fill::Solid(color(*c, opacity)),
+ usvg::Paint::LinearGradient(g) => {
+ self.note("gradient", "SVG: gradient paints keep their stops and direction, not their exact geometry".to_string());
+ let gt = g.transform().post_concat(ts);
+ let mut a = tiny_skia::Point::from_xy(g.x1(), g.y1());
+ let mut b = tiny_skia::Point::from_xy(g.x2(), g.y2());
+ gt.map_point(&mut a);
+ gt.map_point(&mut b);
+ // Photoshop's angle: 0° left → right, counter-clockwise positive (y points down).
+ let angle = (-(b.y - a.y)).atan2(b.x - a.x).to_degrees();
+ gradient(stops(g), if angle.is_finite() { angle } else { 0.0 }, GradientStyle::Linear)
+ }
+ usvg::Paint::RadialGradient(g) => {
+ self.note("gradient", "SVG: gradient paints keep their stops and direction, not their exact geometry".to_string());
+ gradient(stops(g), 0.0, GradientStyle::Radial)
+ }
+ // Pattern paints are rasterised before we get here.
+ usvg::Paint::Pattern(_) => Fill::Solid(Color::rgba(0.5, 0.5, 0.5, opacity)),
+ }
+ }
+
+ /// The node rendered by resvg into a pixel layer over its bounding box, clipped to the canvas.
+ /// `parent` is the absolute transform of the node's parent group.
+ fn raster_layer(&mut self, node: &usvg::Node, name: String, parent: tiny_skia::Transform) -> Option {
+ let bb = node.abs_layer_bounding_box()?;
+ let k = self.global.sx;
+ let x0 = ((bb.x() * k).floor() as i32).max(self.canvas.x0);
+ let y0 = ((bb.y() * k).floor() as i32).max(self.canvas.y0);
+ let x1 = ((bb.right() * k).ceil() as i32).min(self.canvas.x1);
+ let y1 = ((bb.bottom() * k).ceil() as i32).min(self.canvas.y1);
+ if x1 <= x0 || y1 <= y0 {
+ return None;
+ }
+ let Some(mut pixmap) = pixmap((x1 - x0) as u32, (y1 - y0) as u32) else {
+ self.note("raster-size", "SVG: an element too large to rasterise was dropped".to_string());
+ return None;
+ };
+ // resvg renders the node in its parent's space after shifting its absolute bounding box
+ // to the origin: undo that shift, apply the parent's transform, then scale to the canvas
+ // and shift so the pixmap starts at (x0, y0).
+ let canvas = tiny_skia::Transform::from_row(k, 0.0, 0.0, k, -(x0 as f32), -(y0 as f32));
+ let ts = canvas.pre_concat(parent).pre_translate(bb.x(), bb.y());
+ resvg::render_node(node, ts, &mut pixmap.as_mut())?;
+ Some(Layer::new(name, LayerContent::Raster(surface(&pixmap, Rect::new(x0, y0, x1, y1), self.fmt))))
+ }
+}
+
+fn color(c: usvg::Color, alpha: f32) -> Color {
+ Color::rgba(f32::from(c.red) / 255.0, f32::from(c.green) / 255.0, f32::from(c.blue) / 255.0, alpha.clamp(0.0, 1.0))
+}
+
+fn blend(b: usvg::BlendMode) -> BlendMode {
+ use usvg::BlendMode as B;
+ match b {
+ B::Normal => BlendMode::Normal,
+ 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,
+ }
+}
+
+fn pt(p: tiny_skia::Point) -> Point {
+ Point::new(f64::from(p.x), f64::from(p.y))
+}
+
+/// A flattened-transform path as subpaths of knots: lines keep retracted handles, quadratics
+/// are raised to cubics, and a closing segment that returns to the start merges into it. Every
+/// subpath after the first joins its component, so the fill rule counts winding across the whole
+/// path as SVG does (a second subpath drawn inside the first is a hole under even-odd).
+fn knots(path: &tiny_skia::Path) -> Vec {
+ use tiny_skia::PathSegment as S;
+ let mut out: Vec = Vec::new();
+ let mut cur: Option = None;
+ fn flush(cur: &mut Option, out: &mut Vec, closed: bool) {
+ let Some(mut sp) = cur.take() else { return };
+ sp.closed = closed;
+ if closed && sp.knots.len() > 1 {
+ let (first, last) = (sp.knots.first().map(|k| k.anchor), sp.knots.last().map(|k| (k.anchor, k.in_ctrl)));
+ if let (Some(a), Some((b, in_ctrl))) = (first, last)
+ && (a.x - b.x).abs() < 1e-6
+ && (a.y - b.y).abs() < 1e-6
+ {
+ sp.knots.pop();
+ if let Some(k) = sp.knots.first_mut() {
+ k.in_ctrl = in_ctrl;
+ }
+ }
+ }
+ if !sp.knots.is_empty() {
+ if !out.is_empty() {
+ sp.op = PathOp::Join;
+ }
+ out.push(sp);
+ }
+ }
+ fn curve(sp: &mut Subpath, c1: Point, c2: Point, p: Point) {
+ if let Some(last) = sp.knots.last_mut() {
+ last.out_ctrl = c1;
+ }
+ sp.knots.push(Knot { anchor: p, in_ctrl: c2, out_ctrl: p, smooth: false });
+ }
+ for seg in path.segments() {
+ match seg {
+ S::MoveTo(p) => {
+ flush(&mut cur, &mut out, false);
+ cur = Some(Subpath { knots: vec![Knot::corner(f64::from(p.x), f64::from(p.y))], ..Default::default() });
+ }
+ S::LineTo(p) => {
+ if let Some(sp) = cur.as_mut() {
+ sp.knots.push(Knot::corner(f64::from(p.x), f64::from(p.y)));
+ }
+ }
+ S::QuadTo(c, p) => {
+ if let Some(sp) = cur.as_mut() {
+ let (c, p) = (pt(c), pt(p));
+ let p0 = sp.knots.last().map_or(p, |k| k.anchor);
+ let c1 = Point::new(p0.x + 2.0 / 3.0 * (c.x - p0.x), p0.y + 2.0 / 3.0 * (c.y - p0.y));
+ let c2 = Point::new(p.x + 2.0 / 3.0 * (c.x - p.x), p.y + 2.0 / 3.0 * (c.y - p.y));
+ curve(sp, c1, c2, p);
+ }
+ }
+ S::CubicTo(c1, c2, p) => {
+ if let Some(sp) = cur.as_mut() {
+ curve(sp, pt(c1), pt(c2), pt(p));
+ }
+ }
+ S::Close => flush(&mut cur, &mut out, true),
+ }
+ }
+ flush(&mut cur, &mut out, false);
+ out
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn sniffing_accepts_svg_and_rejects_other_xml() {
+ assert!(is_svg(b""));
+ assert!(is_svg("\u{feff}\n\n ".as_bytes()));
+ assert!(is_svg(b" "));
+ assert!(!is_svg(b""));
+ assert!(!is_svg(b"\x89PNG\r\n"));
+ assert!(!is_svg(b""));
+ }
+
+ #[test]
+ fn quadratics_become_cubics_and_closing_merges_the_start_knot() {
+ let mut pb = tiny_skia::PathBuilder::new();
+ pb.move_to(0.0, 0.0);
+ pb.quad_to(5.0, 10.0, 10.0, 0.0);
+ pb.line_to(0.0, 0.0);
+ pb.close();
+ pb.move_to(20.0, 20.0);
+ pb.line_to(30.0, 20.0);
+ let path = pb.finish().unwrap();
+ let sp = knots(&path);
+ assert_eq!(sp.len(), 2);
+ assert!(sp[0].closed && !sp[1].closed);
+ // The explicit line back to the start was merged into the first knot.
+ assert_eq!(sp[0].knots.len(), 2);
+ let (a, b) = (&sp[0].knots[0], &sp[0].knots[1]);
+ assert!((a.out_ctrl.x - 10.0 / 3.0).abs() < 1e-9 && (a.out_ctrl.y - 20.0 / 3.0).abs() < 1e-9);
+ assert!((b.in_ctrl.x - 20.0 / 3.0).abs() < 1e-9 && (b.in_ctrl.y - 20.0 / 3.0).abs() < 1e-9);
+ assert_eq!(b.anchor, Point::new(10.0, 0.0));
+ assert_eq!(sp[1].knots.len(), 2);
+ }
+
+ #[test]
+ fn huge_drawings_scale_to_the_side_limit() {
+ let tree = parse(b" ").unwrap();
+ let mut w = Vec::new();
+ let cv = canvas(&tree, &mut w);
+ assert_eq!((cv.size.width, cv.size.height), (MAX_SIDE, MAX_SIDE / 2));
+ assert_eq!(w.len(), 1);
+ }
+}
diff --git a/crates/io/tests/svg.rs b/crates/io/tests/svg.rs
new file mode 100644
index 0000000000..8eab3933c9
--- /dev/null
+++ b/crates/io/tests/svg.rs
@@ -0,0 +1,279 @@
+//! SVG import: shapes, groups, transforms, curves, text, gradients, rasterised fallbacks, the
+//! size limit, sniffing and malformed input.
+
+use photocraft_color::blend::BlendMode;
+use photocraft_doc::vector::{FillRule, LineCap, LineJoin};
+use photocraft_doc::{Fill, LayerContent};
+use photocraft_io::*;
+
+const NS: &str = "xmlns=\"http://www.w3.org/2000/svg\"";
+
+fn open(svg: &str) -> ImportResult {
+ import("drawing.svg", svg.as_bytes()).unwrap_or_else(|e| panic!("{e}\n{svg}"))
+}
+
+fn px(doc: &photocraft_doc::Document, x: i32, y: i32) -> [f32; 4] {
+ photocraft_compose::flatten(doc).get(x, y)
+}
+
+fn close(a: [f32; 4], b: [f32; 4]) -> bool {
+ a.iter().zip(b).all(|(x, y)| (x - y).abs() < 0.03)
+}
+
+#[test]
+fn basic_shapes_become_shape_layers_with_their_fills_and_strokes() {
+ let r = open(&format!(
+ "\
+ \
+ \
+ "
+ ));
+ let d = &r.document;
+ assert_eq!((d.size.width, d.size.height), (100, 60));
+ assert_eq!(d.layers.len(), 2, "{:?}", d.layers.iter().map(|l| &l.name).collect::>());
+ assert_eq!(d.layers[0].name, "box");
+ assert_eq!(d.layers[1].name, "Shape 1", "unnamed shapes are numbered");
+ let LayerContent::Shape(sh) = &d.layers[0].content else { panic!("not a shape") };
+ assert_eq!(sh.fill, Some(Fill::Solid(photocraft_color::Color::rgba(1.0, 0.0, 0.0, 1.0))));
+ assert!(sh.stroke.is_none());
+ assert_eq!(sh.path.subpaths.len(), 1);
+ assert!(sh.path.subpaths[0].closed);
+ let LayerContent::Shape(c) = &d.layers[1].content else { panic!("not a shape") };
+ let st = c.stroke.as_ref().expect("stroke");
+ assert!((st.width - 4.0).abs() < 1e-3);
+ assert_eq!((st.cap, st.join), (LineCap::Round, LineJoin::Bevel));
+ assert_eq!(st.paint, Fill::Solid(photocraft_color::Color::rgba(0.0, 0.0, 0.0, 1.0)));
+ // The composite: red inside the box, blue inside the circle, black on its edge, nothing else.
+ assert!(close(px(d, 20, 20), [1.0, 0.0, 0.0, 1.0]), "{:?}", px(d, 20, 20));
+ assert!(close(px(d, 70, 30), [0.0, 0.0, 1.0, 1.0]), "{:?}", px(d, 70, 30));
+ assert!(close(px(d, 55, 30), [0.0, 0.0, 0.0, 1.0]), "{:?}", px(d, 55, 30));
+ assert!(px(d, 3, 3)[3] < 0.01 && px(d, 95, 55)[3] < 0.01);
+ assert!(r.warnings.is_empty(), "{:?}", r.warnings);
+}
+
+#[test]
+fn groups_keep_opacity_blend_and_nesting_while_plain_groups_vanish() {
+ let r = open(&format!(
+ "\
+ \
+ \
+ \
+ \
+ \
+ \
+ "
+ ));
+ let d = &r.document;
+ assert_eq!(d.layers.len(), 1, "the transform-only group is not a layer");
+ let art = &d.layers[0];
+ assert_eq!(art.name, "art");
+ assert!((art.opacity - 0.5).abs() < 1e-6);
+ assert_eq!(art.blend, BlendMode::Multiply);
+ let LayerContent::Group(g) = &art.content else { panic!("not a group") };
+ assert_eq!(g.children.len(), 2);
+ assert!(matches!(g.children[0].content, LayerContent::Shape(_)));
+ assert_eq!(g.children[1].name, "inner");
+ // The translate was baked into the paths: the green square now starts at (5, 5).
+ assert!(px(d, 3, 3)[3] < 0.01);
+ let p = px(d, 7, 7);
+ assert!(p[1] > 0.9 && (p[3] - 0.5).abs() < 0.03, "{p:?}");
+}
+
+#[test]
+fn transforms_curves_and_fill_rules_are_in_the_knots() {
+ let r = open(&format!(
+ "\
+ \
+ \
+ \
+ "
+ ));
+ let d = &r.document;
+ let LayerContent::Shape(rect) = &d.layers[0].content else { panic!() };
+ let xs: Vec = rect.path.subpaths[0].knots.iter().map(|k| k.anchor.x).collect();
+ let ys: Vec = rect.path.subpaths[0].knots.iter().map(|k| k.anchor.y).collect();
+ assert_eq!((xs.iter().cloned().fold(f64::MAX, f64::min), xs.iter().cloned().fold(f64::MIN, f64::max)), (10.0, 20.0));
+ assert_eq!((ys.iter().cloned().fold(f64::MAX, f64::min), ys.iter().cloned().fold(f64::MIN, f64::max)), (20.0, 30.0));
+ let LayerContent::Shape(curve) = &d.layers[1].content else { panic!() };
+ let k = &curve.path.subpaths[0].knots;
+ assert_eq!(k.len(), 3);
+ // The quadratic became a cubic: the first knot's leaving handle moved off its anchor.
+ assert!(k[0].out_ctrl != k[0].anchor && k[1].in_ctrl != k[1].anchor);
+ assert!(k[1].out_ctrl == k[1].anchor, "the straight segment keeps retracted handles");
+ let LayerContent::Shape(ring) = &d.layers[2].content else { panic!() };
+ assert_eq!(ring.path.fill_rule, FillRule::EvenOdd);
+ assert_eq!(ring.path.subpaths.len(), 2);
+ assert!(px(d, 2, 42)[3] > 0.99 && px(d, 10, 50)[3] < 0.01, "even-odd leaves the hole empty");
+}
+
+#[test]
+fn gradients_are_approximated_with_a_warning() {
+ let r = open(&format!(
+ "\
+ \
+ \
+ \
+ \
+ "
+ ));
+ let LayerContent::Shape(sh) = &r.document.layers[0].content else { panic!() };
+ let Some(Fill::Gradient { stops, angle, .. }) = &sh.fill else { panic!("{:?}", sh.fill) };
+ assert_eq!(stops.len(), 2);
+ assert!((stops[1].1.alpha - 0.5).abs() < 1e-6);
+ assert!(angle.abs() < 1e-3, "left to right is 0°, got {angle}");
+ assert!(r.warnings.iter().any(|w| w.contains("gradient")), "{:?}", r.warnings);
+ // Red on the left, bluish on the right.
+ let (l, rr) = (px(&r.document, 2, 10), px(&r.document, 97, 10));
+ assert!(l[0] > 0.8 && l[2] < 0.2, "{l:?}");
+ assert!(rr[2] > rr[0], "{rr:?}");
+}
+
+#[test]
+fn clip_paths_masks_filters_patterns_and_images_are_rasterised() {
+ let r = open(&format!(
+ "\
+ \
+ \
+ \
+ \
+ \
+ \
+ "
+ ));
+ let d = &r.document;
+ assert_eq!(d.layers.len(), 2);
+ assert!(matches!(d.layers[0].content, LayerContent::Raster(_)), "clipped group is pixels");
+ assert!(matches!(d.layers[1].content, LayerContent::Raster(_)), "pattern paint is pixels");
+ assert!(close(px(d, 5, 15), [1.0, 0.0, 0.0, 1.0]), "{:?}", px(d, 5, 15));
+ assert!(px(d, 20, 15)[3] < 0.01, "clipped away");
+ // 4 px tiles with a 2 px bar from the pattern origin: x = 32 is a bar, x = 34 a gap.
+ assert!(px(d, 32, 15)[3] > 0.99 && px(d, 34, 15)[3] < 0.01, "stripes: {:?} {:?}", px(d, 32, 15), px(d, 34, 15));
+ assert!(r.warnings.iter().any(|w| w.contains("clip-path")) && r.warnings.iter().any(|w| w.contains("pattern")), "{:?}", r.warnings);
+}
+
+/// A 2×2 PNG (red, green / blue, white) as a data URL, for ``.
+fn png_data_url() -> String {
+ let img = photocraft_codecs::Image::from_u8(2, 2, photocraft_codecs::ChannelLayout::Rgb, vec![255, 0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 255]).unwrap();
+ let bytes = photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap();
+ const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
+ let mut out = String::from("data:image/png;base64,");
+ for chunk in bytes.chunks(3) {
+ let n = chunk.len();
+ let v = (u32::from(chunk[0]) << 16) | (u32::from(*chunk.get(1).unwrap_or(&0)) << 8) | u32::from(*chunk.get(2).unwrap_or(&0));
+ for i in 0..4 {
+ if i <= n {
+ out.push(T[((v >> (18 - 6 * i)) & 63) as usize] as char);
+ } else {
+ out.push('=');
+ }
+ }
+ }
+ out
+}
+
+#[test]
+fn embedded_raster_images_become_pixel_layers() {
+ let r = open(&format!(
+ " ",
+ png_data_url()
+ ));
+ let d = &r.document;
+ assert_eq!(d.layers.len(), 1);
+ assert_eq!(d.layers[0].name, "photo");
+ assert!(matches!(d.layers[0].content, LayerContent::Raster(_)));
+ assert!(close(px(d, 3, 3), [1.0, 0.0, 0.0, 1.0]), "{:?}", px(d, 3, 3));
+ assert!(close(px(d, 16, 16), [1.0, 1.0, 1.0, 1.0]), "{:?}", px(d, 16, 16));
+}
+
+#[test]
+fn text_becomes_outline_shapes_or_warns_without_a_font() {
+ let r = open(&format!(
+ "II "
+ ));
+ let d = &r.document;
+ if d.layers.is_empty() {
+ assert!(r.warnings.iter().any(|w| w.contains("font")), "{:?}", r.warnings);
+ return;
+ }
+ assert_eq!(d.layers[0].name, "II", "the text names its group");
+ let LayerContent::Group(g) = &d.layers[0].content else { panic!("{:?}", d.layers[0].content.kind_name()) };
+ assert!(g.children.iter().all(|l| matches!(l.content, LayerContent::Shape(_))));
+ let dark = (0..120).filter(|&x| px(d, x, 30)[3] > 0.5).count();
+ assert!(dark > 4, "glyph strokes cross the middle row: {dark}");
+}
+
+#[test]
+fn size_comes_from_the_viewbox_and_huge_drawings_shrink_to_fit() {
+ let r = open(&format!(" "));
+ assert_eq!((r.document.size.width, r.document.size.height), (300, 150));
+ let r = open(&format!(" "));
+ let d = &r.document;
+ assert_eq!((d.size.width, d.size.height), (svg::MAX_SIDE, svg::MAX_SIDE / 2));
+ assert!(r.warnings.iter().any(|w| w.contains("scaled")), "{:?}", r.warnings);
+ // The right half is still the right half.
+ let (w, h) = (d.size.width as i32, d.size.height as i32);
+ assert!(px(d, w / 4, h / 2)[3] < 0.01 && px(d, 3 * w / 4, h / 2)[3] > 0.99);
+}
+
+#[test]
+fn sniffed_without_an_extension_and_refused_when_malformed() {
+ let svg = format!("\n ");
+ assert!(svg::is_svg(svg.as_bytes()));
+ let r = import("mystery.bin", svg.as_bytes()).unwrap();
+ assert_eq!(r.document.layers.len(), 1);
+ for bad in [""));
+ assert!(empty.document.layers.is_empty());
+ assert!(empty.warnings.iter().any(|w| w.contains("nothing")), "{:?}", empty.warnings);
+}
+
+#[test]
+fn rasterize_renders_the_whole_drawing_at_a_scale() {
+ let svg = format!(" ");
+ let b = svg::rasterize(svg.as_bytes(), 10.0).unwrap();
+ assert_eq!((b.rect.width(), b.rect.height()), (200, 100));
+ // The half-unit line is a crisp 5 px band at 10×.
+ assert!(b.get(100, 39)[3] < 0.01 && b.get(100, 42)[3] > 0.99 && b.get(100, 45)[3] < 0.01, "{:?}", (b.get(100, 39), b.get(100, 42), b.get(100, 45)));
+ let one = svg::rasterize(svg.as_bytes(), 1.0).unwrap();
+ assert_eq!((one.rect.width(), one.rect.height()), (20, 10));
+ let huge = format!(" ");
+ assert!(svg::rasterize(huge.as_bytes(), 64.0).is_err(), "a render past the pixel cap is an error, not an allocation");
+}
+
+#[test]
+fn an_svg_round_trips_through_pcraft_as_shapes() {
+ let r = open(&format!(
+ " "
+ ));
+ let saved = export(&r.document, "x.pcraft", &ExportOptions::default()).unwrap();
+ let back = import("x.pcraft", &saved.bytes).unwrap().document;
+ assert_eq!(back.layers.len(), 2);
+ assert!(back.layers.iter().all(|l| matches!(l.content, LayerContent::Shape(_))));
+ assert!(close(px(&back, 10, 10), px(&r.document, 10, 10)));
+}
+
+#[test]
+fn image_hrefs_to_local_files_are_never_read() {
+ // A real PNG on disk, referenced by absolute path and as a file URL: opening an untrusted
+ // drawing must not pull it in (only `data:` URLs resolve).
+ let dir = std::path::Path::new(env!("CARGO_TARGET_TMPDIR")).join("svg-local-image");
+ std::fs::create_dir_all(&dir).unwrap();
+ let file = dir.join("secret.png");
+ let img = photocraft_codecs::Image::from_u8(2, 2, photocraft_codecs::ChannelLayout::Rgb, vec![255; 12]).unwrap();
+ std::fs::write(&file, photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap()).unwrap();
+ let path = file.to_string_lossy().into_owned();
+ for href in [path.clone(), format!("file://{path}"), "secret.png".to_string()] {
+ let r = open(&format!(" "));
+ let d = &r.document;
+ assert!(d.layers.iter().all(|l| !matches!(l.content, LayerContent::Raster(_))), "{href}: {:?}", d.layers.len());
+ assert!(px(d, 10, 10)[3] < 0.01, "{href}: {:?}", px(d, 10, 10));
+ }
+ // The same picture as a data URL still loads.
+ assert!(matches!(
+ open(&format!(" ", png_data_url())).document.layers[0].content,
+ LayerContent::Raster(_)
+ ));
+}
diff --git a/docs/scorecard.md b/docs/scorecard.md
index a7bc125bc8..f74c351801 100644
--- a/docs/scorecard.md
+++ b/docs/scorecard.md
@@ -7,7 +7,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js
| Area | Done | Partial | Missing | Key numbers |
|---|---:|---:|---:|---|
| [Performance](#performance) | 3 | 22 | 13 | 47 scenarios: 3 within budget, 22 over budget or failing, 15 tracked only, 6 not run, 7 not measurable yet (baseline 2026-10-05) |
-| [File compatibility](#file-compatibility) | 3 | 10 | 14 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 229/309, round trip ≥ 307/309; photoshop oracles: oracle ≥ 133/256, round trip ≥ 258/256 |
+| [File compatibility](#file-compatibility) | 4 | 10 | 13 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 229/309, round trip ≥ 307/309; photoshop oracles: oracle ≥ 133/256, round trip ≥ 258/256 |
| [Tools](#tools) | 6 | 15 | 9 | |
| [Workspace and UI](#workspace-and-ui) | 0 | 5 | 18 | settings that do nothing: 57 of 138 (target 0) |
| [Type](#type) | 0 | 3 | 6 | |
@@ -97,7 +97,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t
| FILE-215-6 | Smart Object corner skew and perspective (quad or homography transform) | missing | [#215](https://github.com/storytold/photocraft/issues/215) | doc lib.rs:312 SmartObject.transform is an Affine |
| FILE-215-7 | Layered TIFF read and write (tag 37724), honouring the ask-before-saving pref | done | [#215](https://github.com/storytold/photocraft/issues/215) | psd tiff.rs (ImageSourceData, both byte orders), io tiff_layers.rs, ui tiff_options_ui.rs (Discard Layers and Save a Copy); extra alpha channels are not written (one transparency sample) |
| FILE-215-8 | Large and BigTIFF import with banded decode across all IFDs | done | [#215](https://github.com/storytold/photocraft/issues/215) | codecs tiff_ifd.rs (bounds-checked IFD chain + SubIFD walk, classic and BigTIFF, cycle-safe; tiff_info / decode_tiff_page), tiff.rs banded parallel strip/tile decode, planar 1 and 2, BigTIFF write (tiff_bigtiff or > 4 GiB); io import_tiff_page; 30 MP RGB8 Deflate 345 -> 32 ms; no page picker in the UI yet |
-| FILE-215-9 | SVG opens and places as shape layers or a vector Smart Object | missing | [#215](https://github.com/storytold/photocraft/issues/215) | No SVG codec or import |
+| FILE-215-9 | SVG opens and places as shape layers or a vector Smart Object | done | [#215](https://github.com/storytold/photocraft/issues/215) | io svg.rs: paths, groups, text outlines as shape layers (gradients approximated; clip/mask/filter/pattern/image subtrees rasterised); smart_cmds.rs renders an SVG source at its placement scale |
| FILE-215-10 | Multi-page PDF save, PDF presentation, layer comps to PDF, vector/text output, PDF import | missing | [#215](https://github.com/storytold/photocraft/issues/215) | Only print_cmds.rs:133 print_pdf (raster print page) |
| FILE-215-11 | RAW: lossy DNG, highlight modes, .xmp sidecars, RAW prefs wired, per-camera corpus | missing | [#215](https://github.com/storytold/photocraft/issues/215) | raw sensor.rs:210 lossy DNG unsupported; raw_defaults unread; no xmp |
| FILE-215-12 | Camera Raw dialog shows a histogram; settings persist as a PSD Smart Filter | partial | [#215](https://github.com/storytold/photocraft/issues/215) | Interactive RGB/Lab scopes. PSD WB/Light/Presence, curves, HSL, grading, sharpening/noise detail, grain and numeric vignette controls mapped with descriptor preservation. Active manual Optics and unverified vignette style keep the updated ACR 18.4 fixture opaque; PSD/.pcraft saves preserve it. HDR, remaining settings and Photoshop acceptance still open |
diff --git a/scorecard/file_compat.toml b/scorecard/file_compat.toml
index 652188459f..54ed00a960 100644
--- a/scorecard/file_compat.toml
+++ b/scorecard/file_compat.toml
@@ -64,9 +64,9 @@ note = "codecs tiff_ifd.rs (bounds-checked IFD chain + SubIFD walk, classic and
[[item]]
id = "FILE-215-9"
target = "SVG opens and places as shape layers or a vector Smart Object"
-status = "missing"
+status = "done"
issue = 215
-note = "No SVG codec or import"
+note = "io svg.rs: paths, groups, text outlines as shape layers (gradients approximated; clip/mask/filter/pattern/image subtrees rasterised); smart_cmds.rs renders an SVG source at its placement scale"
[[item]]
id = "FILE-215-10"