diff --git a/apps/photocraft/src/external_svg.rs b/apps/photocraft/src/external_svg.rs index ff728a32b0..1d4f552573 100644 --- a/apps/photocraft/src/external_svg.rs +++ b/apps/photocraft/src/external_svg.rs @@ -11,7 +11,7 @@ pub(crate) fn open(editor: ExternalSvgEditor, source: photocraft_ui_egui::Extern } mod native { - use super::*; + use super::{ExternalSvgEditor, ExternalSvgSession, ExternalVectorSave}; use std::fs::{Metadata, OpenOptions}; use std::io::{Read, Write}; use std::path::Path; @@ -36,7 +36,12 @@ mod native { let portal = matches!(&editor, ExternalSvgEditor::VectorCraft); #[cfg(not(target_os = "linux"))] let portal = false; + let chosen_vectorcraft = matches!(&editor, ExternalSvgEditor::VectorCraft); + let chosen_bundle = matches!(&editor, ExternalSvgEditor::Program(path) if cfg!(target_os = "macos") && Path::new(path).extension().is_some_and(|ext| ext.eq_ignore_ascii_case("app")) && Path::new(path).is_dir()); let executable = if portal && source.extension == "svg" { None } else { Some(resolve(editor)?) }; + // Only VectorCraft's own working copy of an AI is regenerated (it opens foreign AI + // read-only); Illustrator and other editors get the original bytes. + let regenerate_ai = chosen_vectorcraft || executable.as_ref().is_some_and(|resolved| is_vectorcraft(resolved, chosen_bundle)); // Display conversion is always performed by VectorCraft's CLI, even when the user // chooses a different interactive editor. A VectorCraft build with an adjacent CLI keeps // its matching CLI; for a macOS bundle that is the bundle's own `Contents/MacOS`. @@ -52,7 +57,7 @@ mod native { .name("photocraft-vector-editor".into()) .spawn(move || { let prepared = match converter.as_deref() { - Some(executable) => super::vector_conversion::prepare(executable, &source, &stopped), + Some(executable) => super::vector_conversion::prepare(executable, &source, &stopped, regenerate_ai), None => OwnedSvg::create(&source.bytes).map(|owned| (owned, source.bytes.as_ref().clone())), }; match prepared { @@ -136,6 +141,22 @@ mod native { } } + /// Whether a chosen program is VectorCraft: a macOS bundle `resolve_program` identified by + /// its bundle id (it then runs the executable directly), a known VectorCraft installation, + /// or an executable named as VectorCraft is (`vectorcraft`/`vectorcraft.exe`, an AppImage + /// `vectorcraft--….AppImage`, the Flatpak launcher `ai.storyteller.vectorcraft`). + /// A neighbouring CLI alone doesn't make an editor VectorCraft. + fn is_vectorcraft(resolved: &Resolved, chosen_bundle: bool) -> bool { + resolved.bundle.is_none() + && (chosen_bundle + || resolved.program.file_name().is_some_and(|name| { + let name = name.to_string_lossy().to_ascii_lowercase(); + let stem = name.strip_suffix(".exe").unwrap_or(&name); + stem == "vectorcraft" || stem == "ai.storyteller.vectorcraft" || (stem.starts_with("vectorcraft-") && !stem.starts_with("vectorcraft-cli")) + }) + || vectorcraft_programs().contains(&resolved.program)) + } + /// True when `program`'s directory holds a regular (not linked) `vectorcraft-cli`. fn has_adjacent_cli(program: &Path) -> bool { let name = if cfg!(windows) { "vectorcraft-cli.exe" } else { "vectorcraft-cli" }; @@ -563,7 +584,7 @@ mod native { } /// Write normalized SVG through a checked, no-follow handle before editor launch. - #[cfg(test)] + #[cfg(all(test, unix))] pub(super) fn overwrite(&self, bytes: &[u8]) -> Result<(), String> { if bytes.is_empty() || bytes.len() > MAX_BYTES { return Err("Prepared SVG exceeds the supported byte limit".into()); @@ -880,6 +901,23 @@ mod native { mod tests { use super::*; + /// Only VectorCraft gets a regenerated AI working copy: Illustrator, another program or a + /// bundle opened through LaunchServices gets the original. + #[test] + fn only_vectorcraft_editors_get_a_regenerated_ai() { + let root = std::env::temp_dir(); + let program = |name: &str| Resolved::direct(root.join(name)); + assert!(is_vectorcraft(&program(if cfg!(windows) { "vectorcraft.exe" } else { "vectorcraft" }), false)); + assert!(!is_vectorcraft(&program(if cfg!(windows) { "Illustrator.exe" } else { "Illustrator" }), false)); + assert!(is_vectorcraft(&program("vectorcraft-1.2.0-linux-x86_64.AppImage"), false)); + assert!(is_vectorcraft(&program("ai.storyteller.vectorcraft"), false)); + assert!(!is_vectorcraft(&program(if cfg!(windows) { "vectorcraft-cli.exe" } else { "vectorcraft-cli" }), false)); + assert!(!is_vectorcraft(&program("uninstall-vectorcraft.exe"), false)); + assert!(is_vectorcraft(&program("Renamed"), true), "a bundle identified as VectorCraft runs directly"); + let cocoa = Resolved { program: root.join("Editor"), bundle: Some(root.join("Editor.app")) }; + assert!(!is_vectorcraft(&cocoa, true)); + } + #[test] fn conversion_errors_wait_for_queue_capacity_without_disappearing() { let owned = OwnedSvg::create(b"").unwrap(); diff --git a/apps/photocraft/src/native_saveback_tests.rs b/apps/photocraft/src/native_saveback_tests.rs index 9a08a0ae8e..70d0e1586e 100644 --- a/apps/photocraft/src/native_saveback_tests.rs +++ b/apps/photocraft/src/native_saveback_tests.rs @@ -14,6 +14,7 @@ use std::time::{Duration, Instant}; /// Exercise the real CLI's same-format Save, the native watcher and PhotoCraft's transaction. #[test] #[ignore = "set VECTORCRAFT_TEST_EDITOR and optionally PHOTO_NATIVE_LOGO_PSD/PHOTO_NATIVE_CONTAINER"] +#[allow(clippy::cognitive_complexity, reason = "one opt-in walk through a real editor, step by step")] fn original_format_real_editor_save_watcher_and_psd_roundtrip() { let editor = std::env::var_os("VECTORCRAFT_TEST_EDITOR").expect("VECTORCRAFT_TEST_EDITOR"); let editor = Path::new(&editor); @@ -68,7 +69,7 @@ fn original_format_real_editor_save_watcher_and_psd_roundtrip() { }; let before = photo.active().unwrap().doc.clone(); let (_send, stop) = mpsc::channel(); - let (owned, initial) = vector_conversion::prepare(editor, &source, &stop).unwrap(); + let (owned, initial) = vector_conversion::prepare(editor, &source, &stop, true).unwrap(); assert_eq!(owned.file.extension().unwrap().to_str().unwrap(), source.extension); assert!(photo.active().unwrap().doc == before, "preparation must not change the embedded original"); let file = owned.file.clone(); diff --git a/apps/photocraft/src/vector_conversion.rs b/apps/photocraft/src/vector_conversion.rs index e096fb2301..ce473a1b95 100644 --- a/apps/photocraft/src/vector_conversion.rs +++ b/apps/photocraft/src/vector_conversion.rs @@ -10,13 +10,16 @@ use std::sync::mpsc::Receiver; use std::time::{Duration, Instant}; /// Validate the source using a private preview, then expose only an original-format copy. -pub(super) fn prepare(editor: &Path, source: &ExternalVectorSource, stop: &Receiver<()>) -> Result<(OwnedSvg, Vec), String> { +/// `regenerate_ai` is true only when the interactive editor is VectorCraft: other editors +/// (Illustrator, a chosen program) get the original AI bytes. +pub(super) fn prepare(editor: &Path, source: &ExternalVectorSource, stop: &Receiver<()>, regenerate_ai: bool) -> Result<(OwnedSvg, Vec), String> { render_preview(editor, source, stop)?; // Foreign AI opens read-only for ordinary Save in VectorCraft. Regenerate only this // private AI working copy with editable AI data; never weaken that source-file protection. // Comparing the watcher with this initial copy prevents an open-only/no-op replacement. - let initial = if source.extension == "ai" { run_export(editor, source, stop, "ai")? } else { source.bytes.as_ref().clone() }; - if source.extension == "ai" { + let regenerate = regenerate_ai && source.extension == "ai"; + let initial = if regenerate { run_export(editor, source, stop, "ai")? } else { source.bytes.as_ref().clone() }; + if regenerate { let prepared = ExternalVectorSource { bytes: std::sync::Arc::new(initial.clone()), extension: source.extension.clone(), size: source.size }; render_preview(editor, &prepared, stop)?; } @@ -247,6 +250,50 @@ mod tests { assert!(source_svg(br#""#, Some((16.0, 12.0))).is_err()); } + /// A stand-in `vectorcraft-cli` that logs every request beside itself and answers the open + /// and export requests, writing a small SVG (or, for `ai`, a regenerated PDF) to the export path. + #[cfg(unix)] + const FAKE_CLI: &str = r#"#!/bin/sh +log="$(dirname "$0")/requests.log" +while IFS= read -r line; do + printf '%s\n' "$line" >> "$log" + case "$line" in + *document.export*) + out=$(printf '%s' "$line" | sed -n 's/.*"path":"\([^"]*\)".*/\1/p') + case "$line" in + *'"format":"ai"'*) printf '%%PDF-1.7 regenerated' > "$out" ;; + *) printf '' > "$out" ;; + esac ;; + esac +done +printf '{"id":1,"result":{"isError":false}}\n{"id":2,"result":{"isError":false}}\n' +"#; + + /// Illustrator or another chosen editor gets the original AI; only VectorCraft's own working + /// copy is regenerated (VectorCraft opens a foreign AI read-only). + #[cfg(unix)] + #[test] + fn ai_working_copy_is_regenerated_only_for_vectorcraft() { + use std::os::unix::fs::PermissionsExt; + let directory = tempfile::tempdir().unwrap(); + let editor = directory.path().join("Editor"); + std::fs::write(&editor, b"#!/bin/sh\n").unwrap(); + let cli = directory.path().join("vectorcraft-cli"); + std::fs::write(&cli, FAKE_CLI).unwrap(); + std::fs::set_permissions(&cli, std::fs::Permissions::from_mode(0o755)).unwrap(); + let original = b"%PDF-1.7\noriginal AI".to_vec(); + let source = ExternalVectorSource { bytes: std::sync::Arc::new(original.clone()), extension: "ai".into(), size: None }; + let (_send, stop) = std::sync::mpsc::channel(); + let (owned, initial) = prepare(&editor, &source, &stop, false).unwrap(); + assert_eq!(initial, original); + assert_eq!(std::fs::read(&owned.file).unwrap(), original, "the other editor opens the original AI"); + let log = std::fs::read_to_string(directory.path().join("requests.log")).unwrap(); + assert!(log.contains(r#""format":"svg""#), "the preview is still converted: {log}"); + assert!(!log.contains(r#""format":"ai""#), "no AI re-export for another editor: {log}"); + let (_owned, regenerated) = prepare(&editor, &source, &stop, true).unwrap(); + assert_eq!(regenerated, b"%PDF-1.7 regenerated"); + } + #[test] fn conversion_requires_successful_open_and_export_replies() { let good = r#"{"id":1,"result":{"isError":false}} @@ -278,7 +325,7 @@ mod live_vectorcraft_tests { let (_send, stop) = mpsc::channel(); let eps = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 8 6\n0 0 1 setrgbcolor 0 0 8 6 rectfill\nshowpage\n%%EOF\n"; let source = ExternalVectorSource { bytes: Arc::new(eps.to_vec()), extension: "eps".into(), size: Some((16.0, 12.0)) }; - let (owned, initial) = prepare(editor, &source, &stop).unwrap(); + let (owned, initial) = prepare(editor, &source, &stop, true).unwrap(); assert_eq!(initial, *source.bytes); assert_eq!(owned.file.extension().unwrap(), "eps"); assert_eq!(std::fs::read(&owned.file).unwrap(), *source.bytes); @@ -287,7 +334,7 @@ mod live_vectorcraft_tests { let raster = photocraft_io::svg::rasterize_bounded(&svg, 1.0, 24_000_000).unwrap(); assert_eq!((raster.rect.width(), raster.rect.height()), (16, 12)); let invalid = ExternalVectorSource { bytes: Arc::new(b"%PDF-1.6\nnot a PDF".to_vec()), extension: "pdf".into(), size: None }; - assert!(prepare(editor, &invalid, &stop).is_err()); + assert!(prepare(editor, &invalid, &stop, true).is_err()); let root = owned.root.clone(); drop(owned); assert!(!root.exists()); diff --git a/crates/engine/src/cmd/smart_cmds.rs b/crates/engine/src/cmd/smart_cmds.rs index 5c8ff0c349..2f3c1ce0cb 100644 --- a/crates/engine/src/cmd/smart_cmds.rs +++ b/crates/engine/src/cmd/smart_cmds.rs @@ -71,7 +71,7 @@ pub fn encode_source(doc: &Document) -> Result> { /// Decodes smart-object contents (a `.pcraft` bundle, PSD/PSB, or any flat image format). pub fn decode_source(file_name: &str, bytes: &[u8]) -> Result { if photocraft_io::svg::is_svg(bytes) { - return photocraft_io::svg::import_svg_bounded_layers(file_name, bytes, 24_000_000) + return photocraft_io::svg::import_svg_bounded_images(file_name, bytes, 24_000_000) .map(|imported| imported.document) .map_err(|e| other(format!("can't read SVG smart object contents: {e}"))); } diff --git a/crates/engine/src/cmd/smart_cmds/rendering.rs b/crates/engine/src/cmd/smart_cmds/rendering.rs index 05ae2179b4..b955da17c1 100644 --- a/crates/engine/src/cmd/smart_cmds/rendering.rs +++ b/crates/engine/src/cmd/smart_cmds/rendering.rs @@ -23,6 +23,15 @@ pub fn stack_image(file_name: &str, bytes: &[u8], fmt: PixelFormat, mode: photoc all => all, }; let bounds = doc.bounds(); + // An SVG opens with one layer per shape, and every visible one gets its own float frame: + // they may take 1 GiB together, the SVG import's own budget, before any is rendered. + if photocraft_io::svg::is_svg(bytes) { + let visible = u64::try_from(layers.iter().filter(|l| l.visible).count()).unwrap_or(u64::MAX); + let frame_bytes = doc.size.area().saturating_mul(16); + if visible.saturating_mul(frame_bytes) > 1 << 30 { + return Err(EngineError::Other("the SVG has too many shapes for Stack Mode at this size".into())); + } + } let frames: Vec> = layers .iter() .filter(|l| l.visible) diff --git a/crates/engine/src/cmd/smart_cmds/tests/svg_profile.rs b/crates/engine/src/cmd/smart_cmds/tests/svg_profile.rs index 9dff55964e..ebe17ae996 100644 --- a/crates/engine/src/cmd/smart_cmds/tests/svg_profile.rs +++ b/crates/engine/src/cmd/smart_cmds/tests/svg_profile.rs @@ -34,18 +34,77 @@ fn svg_smart_rerender_checks_embedded_raster_dimensions() { assert!(render(&s.active().unwrap().doc, &smart).unwrap_err().to_string().contains("decoded pixel budget")); } +/// An SVG with many shapes opens as editable layers for its contents and Stack Mode, as +/// File › Open does, under the SVG import's own retained-pixel budget. #[test] -fn svg_smart_contents_and_stack_reject_excessive_editable_caches() { +fn svg_smart_contents_and_stack_open_many_shapes_as_layers() { let svg = format!("{}", "".repeat(400)); - assert!(decode_source("Clipboard.svg", svg.as_bytes()).unwrap_err().to_string().contains("cache budget")); - assert!(stack_image("Clipboard.svg", svg.as_bytes(), PixelFormat::RGBA32F, photocraft_doc::StackMode::Mean).is_err()); + assert_eq!(decode_source("Clipboard.svg", svg.as_bytes()).unwrap().layers.len(), 400); + assert!(stack_image("Clipboard.svg", svg.as_bytes(), PixelFormat::RGBA32F, photocraft_doc::StackMode::Mean).is_ok()); } -/// Warp setup needs only the source frame. An SVG whose editable layer caches exceed the budget -/// renders through the bounded whole-drawing path, so it must still get that frame, while Stack -/// Mode and Edit Contents keep refusing to build its layers. +/// Stack Mode renders a float frame per layer: an SVG whose shapes would need more than 1 GiB of +/// them (40 full-HD shapes, 1.3 GB) is refused before any is rendered, while its contents open. #[test] -fn svg_warp_frame_uses_bounded_bounds_while_stack_and_contents_stay_strict() { +fn svg_stack_mode_refuses_frames_past_its_budget() { + let svg = format!("{}", "".repeat(40)); + assert_eq!(decode_source("Many.svg", svg.as_bytes()).unwrap().layers.len(), 40); + let error = stack_image("Many.svg", svg.as_bytes(), PixelFormat::RGBA32F, photocraft_doc::StackMode::Mean).unwrap_err(); + assert!(error.to_string().contains("Stack Mode"), "{error}"); +} + +/// An ordinary layout (a 1920 × 1080 SVG with 12 shapes) placed as a smart object: Replace +/// Contents with another like it and Convert to Layers both work through their commands. +#[test] +fn ordinary_layered_svg_contents_replace_and_convert_to_layers() { + let rects: String = (0..12).map(|i| format!("", i * 150)).collect(); + let svg = format!("{rects}"); + let mut s = Session::new(); + s.execute("file.new", json!({"width": 1920, "height": 1080})).unwrap(); + crate::file_cmds::place_bytes(&mut s, "Layout.svg", svg.clone().into_bytes(), None, &json!({"smartObject": true})).unwrap(); + let dir = std::env::temp_dir().join(format!("pcraft-svg-layout-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("Layout 2.svg"); + std::fs::write(&path, svg.replace("#336699", "#993366")).unwrap(); + s.execute("layer.smartObjects.replaceContents", json!({"path": path.to_str().unwrap()})).unwrap(); + std::fs::remove_dir_all(&dir).ok(); + let r = s.execute("layer.smartObjects.convertToLayers", json!({})).unwrap(); + let doc = &s.active().unwrap().doc; + let group = doc.layer(LayerId(r["layer"].as_u64().unwrap())).unwrap(); + assert_eq!(group.children().map(<[Layer]>::len), Some(12), "the 12 shapes become 12 layers"); +} + +/// Convert to Layers on an SVG scaled to fill a 4K document: four full-canvas shapes (33 MP of +/// shape caches, as many as four full-canvas layers anywhere) unpack. +#[test] +fn convert_to_layers_unpacks_full_canvas_shapes_in_a_4k_document() { + let svg = format!("{}", "".repeat(4)); + let mut s = Session::new(); + s.execute("file.new", json!({"width": 3840, "height": 2160})).unwrap(); + let placed = crate::file_cmds::place_bytes(&mut s, "Full.svg", svg.into_bytes(), None, &json!({"smartObject": true})).unwrap(); + let id = LayerId(placed["layer"].as_u64().unwrap()); + let LayerContent::Smart(sm) = &mut Arc::make_mut(&mut s.active_mut().unwrap().doc).layer_mut(id).unwrap().content else { panic!("smart") }; + sm.transform = Affine { m: [10.0, 0.0, 0.0, 10.0, 0.0, 0.0] }; + s.execute("layer.smartObjects.convertToLayers", json!({"layer": id.0})).unwrap(); +} + +/// An SVG with an embedded image the bounded resolver can't read (an unknown MIME type) places +/// and renders the rest of the drawing, as it did before the bounded render. +#[test] +fn svg_with_an_unreadable_embedded_image_still_places() { + let svg = ""; + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + let placed = crate::file_cmds::place_bytes(&mut s, "Logo.svg", svg.as_bytes().to_vec(), None, &json!({"smartObject": true})).unwrap(); + let doc = &s.active().unwrap().doc; + let LayerContent::Smart(sm) = &doc.layer(LayerId(placed["layer"].as_u64().unwrap())).unwrap().content else { panic!("smart") }; + assert_eq!(sm.cache.as_ref().unwrap().rgba(6, 4), [1.0, 0.0, 0.0, 1.0]); +} + +/// Warp setup needs only the source frame, found with the bounded parse and the exact canvas +/// limit; Stack Mode and Edit Contents build the SVG's layers. +#[test] +fn svg_warp_frame_uses_bounded_bounds() { // 256 × 256 × 400 shapes = 26,214,400 pixels of layer caches, over the 24 MP budget. let svg = format!( "{}", @@ -61,11 +120,10 @@ fn svg_warp_frame_uses_bounded_bounds_while_stack_and_contents_stay_strict() { assert_eq!(source_box(&doc, &smart), Some(bounds), "a warped SVG keeps its frame"); let warped = render(&doc, &smart).unwrap().unwrap(); assert!((warped.pixel(10, 10)[2] - 0x99 as f32 / 255.0).abs() < 0.01, "the bounded warp render draws the SVG"); - // Strict paths are unchanged. - assert!(decode_source("Clipboard.svg", svg.as_bytes()).unwrap_err().to_string().contains("cache budget")); + assert_eq!(decode_source("Clipboard.svg", svg.as_bytes()).unwrap().layers.len(), 400); smart.warp = None; smart.stack_mode = Some(photocraft_doc::StackMode::Mean); - assert_eq!(source_box(&doc, &smart), None, "Stack Mode needs the strict layered import"); + assert_eq!(source_box(&doc, &smart), Some(bounds), "Stack Mode gets the layered import's frame"); // The canvas limit is exact: 24,000,000 pixels gets a frame, one more row does not. let canvas = |h: u32| format!(""); let frame = |svg: String| { diff --git a/crates/engine/src/cmd/smart_cmds/unpack.rs b/crates/engine/src/cmd/smart_cmds/unpack.rs index 2e591383d1..e6ce545dc8 100644 --- a/crates/engine/src/cmd/smart_cmds/unpack.rs +++ b/crates/engine/src/cmd/smart_cmds/unpack.rs @@ -93,7 +93,7 @@ fn place(s: &Session, doc: &Document, sm: &SmartObject, layers: &mut [Layer]) -> && photocraft_io::svg::is_svg(bytes) && let Some(transform) = affine { - check_destination_shapes(layers, transform, doc.size).map_err(other)?; + check_destination_shapes(layers, transform, doc.size, doc.pixel_format().bytes_per_pixel()).map_err(other)?; } for l in layers { released(l, |l| crate::transform_cmds::transform_layer(None, photocraft_doc::Locks::default(), l, &h, affine, Interp::Bicubic))?; @@ -193,8 +193,17 @@ pub(super) fn convert_to_layers(s: &mut Session, p: &Value) -> Result { Ok(json!({"layer": new_id.0, "group": group, "discardedSmartFilters": discarded})) } -/// Preflight transformed SVG shape caches before an operation refreshes their raster caches. -fn check_destination_shapes(layers: &[Layer], transform: photocraft_geom::Affine, size: photocraft_geom::Size) -> std::result::Result<(), String> { +/// Preflight transformed SVG shape caches before an operation refreshes their raster caches: +/// together they may take 1 GiB in the document's pixel format, the budget the SVG import +/// itself keeps its layer caches under (several full-canvas shapes in a 24 MP document fit). +fn check_destination_shapes( + layers: &[Layer], + transform: photocraft_geom::Affine, + size: photocraft_geom::Size, + bytes_per_pixel: usize, +) -> std::result::Result<(), String> { + const MAX_CACHE_BYTES: u64 = 1 << 30; + let max_pixels = MAX_CACHE_BYTES / u64::try_from(bytes_per_pixel.max(1)).unwrap_or(u64::MAX); let canvas = photocraft_geom::Rect::from_size(size); let mut pixels = 0u64; let mut pending: Vec<&Layer> = layers.iter().collect(); @@ -206,7 +215,7 @@ fn check_destination_shapes(layers: &[Layer], transform: photocraft_geom::Affine let compiled = photocraft_vector::CompiledShape::new(&shape, photocraft_vector::DEFAULT_TOLERANCE, canvas); let area = compiled.bounds().map_or(0, |bounds| bounds.intersect(&canvas).size().area()); pixels = pixels.checked_add(area).ok_or("The unpacked shape caches exceed the smart-object cache budget")?; - if pixels > 24_000_000 { + if pixels > max_pixels { return Err("The unpacked shape caches exceed the smart-object cache budget".into()); } } diff --git a/crates/engine/src/cmd/smart_svg_cmds.rs b/crates/engine/src/cmd/smart_svg_cmds.rs index 91b322b53d..bb1e0cabac 100644 --- a/crates/engine/src/cmd/smart_svg_cmds.rs +++ b/crates/engine/src/cmd/smart_svg_cmds.rs @@ -294,6 +294,10 @@ fn update_contents_with_frame_budget(s: &mut Session, p: &Value, native: bool, f } bytes } else { + // updateSvg replaces SVG contents; an AI, PDF or EPS source keeps its own format. + if photocraft_io::vector_preview::is_native_source(original) { + return Err(bad(UPDATE, "the original source is AI, PDF or EPS: use layer.smartObjects.updateVectorSource")); + } svg.as_bytes().to_vec() }; if original == replacement_bytes { diff --git a/crates/engine/src/cmd/smart_svg_cmds/tests.rs b/crates/engine/src/cmd/smart_svg_cmds/tests.rs index 778cd79789..b1dfd59dae 100644 --- a/crates/engine/src/cmd/smart_svg_cmds/tests.rs +++ b/crates/engine/src/cmd/smart_svg_cmds/tests.rs @@ -321,18 +321,25 @@ fn embedded_pdf_ai_and_eps_update_shared_instances_without_reading_linked_paths( assert_eq!(base64::engine::general_purpose::STANDARD.decode(exported["sourceBase64"].as_str().unwrap()).unwrap(), bytes); assert_eq!(exported["extension"], if name.ends_with("ai") { "ai" } else { "eps" }); assert_eq!(s.active().unwrap().doc, before); - let params = + // updateSvg refuses native sources; they save back in their own format. + let svg_params = json!({"document":exported["document"],"layer":id.0,"svg":BLUE,"fingerprint":exported["fingerprint"],"contentsId":exported["contentsId"]}); - assert_eq!(s.execute(UPDATE, params.clone()).unwrap()["updated"], true); + fails_without_edit(&mut s, svg_params); + let changed = [bytes, b" edited"].concat(); + let params = json!({"document":exported["document"],"layer":id.0,"fingerprint":exported["fingerprint"],"contentsId":exported["contentsId"], + "sourceBase64":base64::engine::general_purpose::STANDARD.encode(&changed),"extension":exported["extension"],"previewSvg":BLUE}); + assert_eq!(s.execute(UPDATE_VECTOR, params.clone()).unwrap()["updated"], true); for id in [id, duplicate] { - assert_eq!(source(&s, id), BLUE.as_bytes()); + assert_eq!(source(&s, id), changed); } let after = s.active().unwrap().doc.clone(); assert!(s.undo()); assert_eq!(s.active().unwrap().doc, before); assert!(s.redo()); assert_eq!(s.active().unwrap().doc, after); - fails_without_edit(&mut s, params); // stale first-save fingerprint + let before_stale = s.active().unwrap().doc.clone(); + assert!(s.execute(UPDATE_VECTOR, params).is_err(), "stale first-save fingerprint"); + assert_eq!(s.active().unwrap().doc, before_stale); let serialized = photocraft_format::save_to_bytes(&after, &Default::default()).unwrap(); assert_eq!(photocraft_format::load_from_bytes(&serialized).unwrap(), *after); } @@ -408,6 +415,28 @@ fn original_vector_formats_save_atomically_and_survive_undo_and_persistence() { } } +/// A save from an external editor whose SVG embeds an image PhotoCraft can't read (here a BMP) +/// is accepted with that image left out, as placing the same SVG is. +#[test] +fn saved_svg_with_an_unreadable_embedded_image_still_updates() { + let (mut s, id) = session(8); + let edited = r#""#; + let params = request(&mut s, id, edited); + assert_eq!(s.execute(UPDATE, params).unwrap()["updated"], true); + assert_eq!(source(&s, id), edited.as_bytes()); +} + +/// updateSvg replaces SVG contents only: it never turns an AI, PDF or EPS source into SVG. +#[test] +fn update_svg_refuses_native_sources() { + for ext in ["ai", "pdf", "eps"] { + let (mut s, id, original) = native_session(ext, 8); + let params = request(&mut s, id, BLUE); + fails_without_edit(&mut s, params); + assert_eq!(source(&s, id), original, "{ext}"); + } +} + #[test] fn native_saves_reject_wrong_formats_bad_previews_and_locks_without_editing() { let (mut s, id, original) = native_session("ai", 8); diff --git a/crates/io/src/psd_import.rs b/crates/io/src/psd_import.rs index a5904386c4..2e74d10fc1 100644 --- a/crates/io/src/psd_import.rs +++ b/crates/io/src/psd_import.rs @@ -647,10 +647,11 @@ impl Ctx<'_> { } _ => stored, }; + // The global blocks' data lives in `self.global` (an owned open moves it out of the file). let vector_preview = rec.block(&crate::vector_preview::PSD_KEY).and_then(|block| { - let file = self.file; + let global = &self.global; self.vector_previews.decode(&id, &block.data, || { - crate::linked::embedded_payload_in_blocks(file.global_blocks.iter().map(|b| (&b.key, b.data.as_slice())), &id).map(|(_, source)| source) + crate::linked::embedded_payload_in_blocks(global.iter().map(|(_, key, data)| (key, data.as_slice())), &id).map(|(_, source)| source) }) }); LayerContent::Smart(SmartObject { diff --git a/crates/io/src/svg.rs b/crates/io/src/svg.rs index 0980898467..0728035530 100644 --- a/crates/io/src/svg.rs +++ b/crates/io/src/svg.rs @@ -376,13 +376,28 @@ struct ImageBudget { pixels: u64, xml_bytes: u64, svg_images: u64, + /// The deepest chain of embedded SVG images (an SVG image inside an SVG image is 2). resvg + /// draws each one through a scratch pixmap as large as the one it is drawn into and frees it + /// afterwards, so this many such surfaces exist at once, however many siblings there are. + svg_chain: u64, draw_nodes: u64, repeatable_raster: bool, repeatable_svg: bool, fontless_text: bool, + /// An embedded image that can't be read fails the drawing instead of being left out. A + /// stored display preview must draw completely, or it isn't used. + strict: bool, error: Option, } +/// Leave out an embedded image that can't be read, or with a strict budget record `error`. +fn unreadable_image(budget: &Mutex, error: &str) -> Option { + if budget.lock().unwrap_or_else(std::sync::PoisonError::into_inner).strict { + image_error(budget, error); + } + None +} + /// Record the first budget or decoding failure seen while usvg resolves images. Resolver /// callbacks cannot return errors, so the parse checks this slot afterwards and fails with it. fn image_error(budget: &Mutex, error: impl Into) { @@ -487,10 +502,20 @@ fn webp_frame_sizes(chunks: &[u8], dimensions: &mut (usize, usize), nested: bool } /// usvg's data-URL image resolver with budgets: nested SVG images are parsed with the same -/// budget up to [`MAX_EMBEDDED_SVG_DEPTH`], and PNG/JPEG/GIF/WebP images are charged their -/// largest declared frame before decoding. Anything else, or any overrun, records an error and -/// resolves to no image so the enclosing parse fails. -fn bounded_data_image(mime: &str, data: Arc>, options: &usvg::Options, budget: &Arc>, depth: usize) -> Option { +/// budget up to [`MAX_EMBEDDED_SVG_DEPTH`] and within the `xml_left` levels of XML nesting the +/// drawing left them, and PNG/JPEG/GIF/WebP images are charged their largest declared frame +/// before decoding. An overrun records an error and resolves to no image so the enclosing parse +/// fails. An image that isn't one of those formats, is invalid, or has a MIME type usvg doesn't +/// know is left out and the rest still draws, as usvg's own resolver does (with a strict budget +/// it is an error too). +fn bounded_data_image( + mime: &str, + data: Arc>, + options: &usvg::Options, + budget: &Arc>, + depth: usize, + xml_left: usize, +) -> Option { if budget.lock().unwrap_or_else(std::sync::PoisonError::into_inner).error.is_some() { return None; } @@ -500,94 +525,100 @@ fn bounded_data_image(mime: &str, data: Arc>, options: &usvg::Options, b image_error(budget, "embedded SVG images exceed the nesting limit"); return None; } - match parse_image_bounded(&data, budget.clone(), depth + 1) { - Ok(tree) => { - let mut counter = budget.lock().unwrap_or_else(std::sync::PoisonError::into_inner); - counter.svg_images = counter.svg_images.saturating_add(1); - Some(usvg::ImageKind::SVG(tree)) - } - Err(e) => { - image_error(budget, format!("can't parse embedded SVG image: {e}")); - None - } - } + // A budget overrun inside the image is already recorded; any other failure (not an + // SVG, nested deeper than the drawing left room for) only leaves the image out. + let Ok(tree) = parse_image_bounded(&data, budget.clone(), depth + 1, xml_left) else { + return unreadable_image(budget, "can't parse an embedded SVG image"); + }; + let mut counter = budget.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + counter.svg_images = counter.svg_images.saturating_add(1); + counter.svg_chain = counter.svg_chain.max(u64::try_from(depth + 1).unwrap_or(u64::MAX)); + Some(usvg::ImageKind::SVG(tree)) } else { let supported = matches!( format, Some(photocraft_codecs::Format::Png | photocraft_codecs::Format::Jpeg | photocraft_codecs::Format::Gif | photocraft_codecs::Format::WebP) ); - if !supported { - image_error(budget, "embedded SVG image has unsupported or invalid raster data"); - return None; + let size = supported.then(|| imagesize::blob_size(&data).ok()).flatten(); + let Some(size) = size else { return unreadable_image(budget, "embedded SVG image has unsupported or invalid raster data") }; + let mut dimensions = (size.width, size.height); + if format == Some(photocraft_codecs::Format::Gif) { + let Some(frame) = gif_frame_size(&data) else { return unreadable_image(budget, "can't read embedded GIF frame dimensions") }; + dimensions = (dimensions.0.max(frame.0), dimensions.1.max(frame.1)); } - let dimensions = match imagesize::blob_size(&data) { - Ok(size) => (size.width, size.height), - Err(e) => { - image_error(budget, format!("can't read embedded SVG image dimensions: {e}")); - return None; - } - }; - let mut dimensions = if format == Some(photocraft_codecs::Format::Gif) { - let Some(frame) = gif_frame_size(&data) else { - image_error(budget, "can't read embedded GIF frame dimensions"); - return None; - }; - (dimensions.0.max(frame.0), dimensions.1.max(frame.1)) - } else { - dimensions - }; if format == Some(photocraft_codecs::Format::WebP) && data.get(12..).and_then(|chunks| webp_frame_sizes(chunks, &mut dimensions, false)).is_none() { - image_error(budget, "can't read embedded WebP frame dimensions"); - return None; + return unreadable_image(budget, "can't read embedded WebP frame dimensions"); + } + // usvg's resolver only wraps the bytes (resvg decodes them when drawing): an image under + // a MIME type it doesn't know, or with no pixels, is left out uncharged, as usvg would. + let Some(image) = (usvg::ImageHrefResolver::default_data_resolver())(mime, data, options) else { + return unreadable_image(budget, "embedded SVG raster image has an unsupported MIME type"); + }; + if dimensions.0 == 0 || dimensions.1 == 0 { + return unreadable_image(budget, "embedded SVG image has no pixels"); } if !charge_image(budget, dimensions.0, dimensions.1) { return None; } - let image = (usvg::ImageHrefResolver::default_data_resolver())(mime, data, options); - if image.is_none() { - image_error(budget, "embedded SVG raster image has an unsupported MIME type"); - } - image + Some(image) } } /// [`parse_image_bounded_with`] using the shared system font database. -fn parse_image_bounded(bytes: &[u8], budget: Arc>, depth: usize) -> Result { - parse_image_bounded_with(bytes, budget, depth, fonts) +fn parse_image_bounded(bytes: &[u8], budget: Arc>, depth: usize, xml_left: usize) -> Result { + parse_image_bounded_with(bytes, budget, depth, xml_left, fonts) } /// Parse one (possibly gzip-compressed) SVG at nesting `depth` against `budget`: the byte, /// inflated-size and XML-depth limits apply first, embedded images go through /// [`bounded_data_image`], and the tree's drawing-node count and repeatable image resources are /// added to the budget for the caller's work estimate. Fonts load only when text is present. +/// usvg parses an embedded SVG on top of the drawing's own recursion, so the drawing and the +/// images inside it share `xml_left` levels of XML nesting ([`MAX_XML_DEPTH`] for the drawing). +/// Byte and decode overruns are also recorded in `budget`, so they fail the whole drawing; a +/// nesting or syntax error only fails this SVG. fn parse_image_bounded_with( bytes: &[u8], budget: Arc>, depth: usize, + xml_left: usize, fonts: fn() -> Arc, ) -> Result { + let overrun = |error: IoError| { + image_error(&budget, error.to_string()); + error + }; if u64::try_from(bytes.len()).unwrap_or(u64::MAX) > MAX_BOUNDED_SVG_BYTES { - return Err(IoError::Svg("embedded SVG source exceeds the 16 MiB byte limit".into())); + return Err(overrun(IoError::Svg("embedded SVG source exceeds the 16 MiB byte limit".into()))); } - let xml = inflate_with_limit(bytes, MAX_BOUNDED_SVG_BYTES)?; - if nesting_depth(&xml, MAX_XML_DEPTH) > MAX_XML_DEPTH { - return Err(IoError::Svg(format!("elements are nested more than {MAX_XML_DEPTH} levels deep"))); - } - { + let xml = inflate_with_limit(bytes, MAX_BOUNDED_SVG_BYTES).map_err(overrun)?; + // Charged before anything can leave the image out, so skipped images still use up the budget. + let xml_len = u64::try_from(xml.len()).unwrap_or(u64::MAX); + let left = { let mut counter = budget.lock().unwrap_or_else(std::sync::PoisonError::into_inner); - counter.xml_bytes = counter - .xml_bytes - .checked_sub(u64::try_from(xml.len()).unwrap_or(u64::MAX)) - .ok_or_else(|| IoError::Svg("embedded SVG XML exceeds the decoded byte budget".into()))?; + let left = counter.xml_bytes.checked_sub(xml_len); + if let Some(left) = left { + counter.xml_bytes = left; + } + left + }; + if left.is_none() { + return Err(overrun(IoError::Svg("embedded SVG XML exceeds the decoded byte budget".into()))); + } + let nesting = nesting_depth(&xml, xml_left); + if nesting > xml_left { + return Err(IoError::Svg(format!("elements are nested more than {xml_left} levels deep"))); } let has_text = xml.windows(4).any(|w| w == b"text"); let fontdb = if has_text { fonts() } else { Arc::new(usvg::fontdb::Database::new()) }; if has_text && fontdb.faces().next().is_none() { budget.lock().unwrap_or_else(std::sync::PoisonError::into_inner).fontless_text = true; } + // The resolver below replaces `options`' own, so its embedded-SVG depth argument is unused. let mut opt = options(fontdb, 0); let shared = budget.clone(); - opt.image_href_resolver.resolve_data = Box::new(move |mime, data, options| bounded_data_image(mime, data, options, &shared, depth)); + let image_xml_left = xml_left.saturating_sub(nesting); + opt.image_href_resolver.resolve_data = Box::new(move |mime, data, options| bounded_data_image(mime, data, options, &shared, depth, image_xml_left)); // The bounded inflate above is the only decompression step. from_data would detect a // nested gzip payload and inflate it again without the source-byte limit. let text = std::str::from_utf8(&xml).map_err(|_| IoError::Svg("the SVG is not valid UTF-8".into()))?; @@ -656,53 +687,66 @@ fn repeatable_image_resources(tree: &usvg::Tree) -> (bool, bool) { struct ParsedWork { parsed: Parsed, svg_images: u64, + svg_chain: u64, decoded: u64, draw_nodes: u64, repeatable_raster: bool, repeatable_svg: bool, } -/// Parse untrusted SVG with at most `max_pixels` (capped at 24 MP) of decoded image pixels. +/// Parse untrusted SVG with at most `max_pixels` (capped at 24 MP) of decoded image pixels. The +/// scratch surfaces its embedded SVG images need at the drawing's own size count against the +/// same limit. fn parse_bounded(bytes: &[u8], max_pixels: u64) -> Result { - parse_bounded_inner(bytes, max_pixels).map(|(tree, _)| tree) + let (parsed, chain) = parse_bounded_inner(bytes, max_pixels)?; + let area = canvas(&parsed.tree, &mut Vec::new()).size.area(); + check_scratch_surfaces(area, chain, max_pixels)?; + Ok(parsed) } -/// [`parse_bounded`], also returning how many embedded SVG images were parsed (each one is -/// rendered through its own intermediate surface). +/// [`parse_bounded`] without the scratch-surface check, also returning the deepest chain of +/// embedded SVG images (see [`ImageBudget::svg_chain`]) so a render can check it at its own size. fn parse_bounded_inner(bytes: &[u8], max_pixels: u64) -> Result<(Parsed, u64), IoError> { parse_bounded_inner_with(bytes, max_pixels, fonts) } /// [`parse_bounded_inner`] with an explicit font source, so tests can pin the available fonts. fn parse_bounded_inner_with(bytes: &[u8], max_pixels: u64, fonts: fn() -> Arc) -> Result<(Parsed, u64), IoError> { - parse_bounded_work_with(bytes, max_pixels, fonts).map(|work| (work.parsed, work.svg_images)) + parse_bounded_work_with(bytes, max_pixels, false, fonts).map(|work| (work.parsed, work.svg_chain)) +} + +/// resvg draws an embedded SVG image through a scratch pixmap as large as the `area` it is drawn +/// into, one per level of a `chain` of nested images, all alive at once; past `max_pixels` (at +/// most 24 MP) of them is an error. Siblings reuse the space one after another, so a drawing +/// with any number of side-by-side SVG images needs one, like the drawing itself. +fn check_scratch_surfaces(area: u64, chain: u64, max_pixels: u64) -> Result<(), IoError> { + if chain != 0 && area.checked_mul(chain).is_none_or(|pixels| pixels > max_pixels.min(MAX_RASTER_PIXELS)) { + return Err(IoError::Svg("embedded SVG intermediate images exceed the SVG cache budget".into())); + } + Ok(()) } /// The decoder's charged pixels, including raster images inside nested SVG images. -fn parse_bounded_work_with(bytes: &[u8], max_pixels: u64, fonts: fn() -> Arc) -> Result { +fn parse_bounded_work_with(bytes: &[u8], max_pixels: u64, strict: bool, fonts: fn() -> Arc) -> Result { let budget = Arc::new(Mutex::new(ImageBudget { pixels: max_pixels.min(MAX_RASTER_PIXELS), xml_bytes: MAX_BOUNDED_SVG_BYTES, svg_images: 0, + svg_chain: 0, draw_nodes: 0, repeatable_raster: false, repeatable_svg: false, fontless_text: false, + strict, error: None, })); - let tree = parse_image_bounded_with(bytes, budget.clone(), 0, fonts)?; + let tree = parse_image_bounded_with(bytes, budget.clone(), 0, MAX_XML_DEPTH, fonts)?; let counter = budget.lock().unwrap_or_else(std::sync::PoisonError::into_inner); - // resvg renders each embedded SVG through a temporary pixmap as large as its destination. - if counter.svg_images != 0 { - let area = canvas(&tree, &mut Vec::new()).size.area(); - if area.checked_mul(counter.svg_images.saturating_add(1)).is_none_or(|pixels| pixels > max_pixels.min(MAX_RASTER_PIXELS)) { - return Err(IoError::Svg("embedded SVG intermediate images exceed the SVG cache budget".into())); - } - } let decoded = max_pixels.min(MAX_RASTER_PIXELS).saturating_sub(counter.pixels); Ok(ParsedWork { parsed: Parsed { tree, fontless_text: counter.fontless_text }, svg_images: counter.svg_images, + svg_chain: counter.svg_chain, decoded, draw_nodes: counter.draw_nodes, repeatable_raster: counter.repeatable_raster, @@ -836,14 +880,29 @@ pub fn import_svg_bounded(name: &str, bytes: &[u8], max_cached_pixels: u64) -> R /// their independent 24 MP caps; the aggregate allowance can pay for several components. /// Raster-bearing patterns, masks and image-filter resources conservatively reserve repaint /// work across drawing nodes; ordinary vector paths, artboard clips and blur alone do not. -/// [`import_svg_bounded`] retains its existing independent image/cache limits. +/// [`import_svg_bounded`] retains its existing independent image/cache limits. An embedded image +/// it can't read is left out, as on placement. pub fn import_svg_bounded_with_work(name: &str, bytes: &[u8], max_work: u64) -> Result<(ImportResult, u64), IoError> { + import_svg_bounded_with_work_mode(name, bytes, max_work, false) +} + +/// [`import_svg_bounded_with_work`] for a stored display preview: an embedded image it can't +/// read fails it, so only a preview that draws completely stands in for its native source. +pub fn import_svg_preview_with_work(name: &str, bytes: &[u8], max_work: u64) -> Result<(ImportResult, u64), IoError> { + import_svg_bounded_with_work_mode(name, bytes, max_work, true) +} + +fn import_svg_bounded_with_work_mode(name: &str, bytes: &[u8], max_work: u64, strict: bool) -> Result<(ImportResult, u64), IoError> { if max_work == 0 { return Err(IoError::Svg("SVG preview validation work budget is exhausted".into())); } - let ParsedWork { parsed, svg_images, decoded, draw_nodes, repeatable_raster, repeatable_svg } = - parse_bounded_work_with(bytes, max_work.min(MAX_RASTER_PIXELS), fonts)?; + let ParsedWork { parsed, svg_images, decoded, draw_nodes, repeatable_raster, repeatable_svg, .. } = + parse_bounded_work_with(bytes, max_work.min(MAX_RASTER_PIXELS), strict, fonts)?; let area = canvas(&parsed.tree, &mut Vec::new()).size.area(); + // This preview check charges every embedded SVG image a canvas-sized surface of work. + if svg_images != 0 && area.checked_mul(svg_images.saturating_add(1)).is_none_or(|pixels| pixels > max_work.min(MAX_RASTER_PIXELS)) { + return Err(IoError::Svg("embedded SVG intermediate images exceed the SVG cache budget".into())); + } let uses = draw_nodes.max(1); let intermediate_surfaces = svg_images.saturating_mul(if repeatable_svg { uses } else { 1 }).saturating_add(1); // Only raster-bearing repeatable resources can repaint an already-parsed image. Plain @@ -876,6 +935,14 @@ pub fn import_svg_bounded_layers(name: &str, bytes: &[u8], max_cached_pixels: u6 import_svg_inner(name, bytes, MAX_GROUP_DEPTH, Some(max_cached_pixels), true) } +/// Opens untrusted SVG (smart-object contents) as [`import_svg`] does, as editable layers under +/// its retained-pixel budget, but with the bounded parse: embedded images may decode to at most +/// `max_decoded_pixels` (capped at 24 MP) in all, nested SVG images get the same budget, and +/// compressed data inflates to at most 16 MiB. +pub fn import_svg_bounded_images(name: &str, bytes: &[u8], max_decoded_pixels: u64) -> Result { + import_parsed(name, parse_bounded(bytes, max_decoded_pixels)?, MAX_GROUP_DEPTH, None, false) +} + /// The pixel bounds [`import_svg_bounded`] gives the drawing, found without rendering any layer /// cache. It applies the same bounded parse (decoded-image and nested-SVG budgets) and the same /// canvas limit, so a canvas of exactly `max_cached_pixels` (at most 24 MP) fits and one more @@ -994,17 +1061,15 @@ pub fn rasterize(bytes: &[u8], scale: f32) -> Result Result { - let (parsed, images) = parse_bounded_inner(bytes, max_decoded_pixels)?; + let (parsed, chain) = parse_bounded_inner(bytes, max_decoded_pixels)?; let tree = parsed.tree; - if images != 0 { + if chain != 0 { let cv = canvas(&tree, &mut Vec::new()); let k = cv.scale * scale.clamp(1.0 / 64.0, 64.0); let width = ((tree.size().width() * k).ceil() as u32).max(1); let height = ((tree.size().height() * k).ceil() as u32).max(1); - if (u64::from(width) * u64::from(height)).checked_mul(images.saturating_add(1)).is_none_or(|pixels| pixels > max_decoded_pixels.min(MAX_RASTER_PIXELS)) - { - return Err(IoError::Svg("embedded SVG intermediate images exceed the rendered pixel budget".into())); - } + check_scratch_surfaces(u64::from(width) * u64::from(height), chain, max_decoded_pixels) + .map_err(|_| IoError::Svg("embedded SVG intermediate images exceed the rendered pixel budget".into()))?; } rasterize_tree(&tree, scale) } diff --git a/crates/io/src/svg/tests.rs b/crates/io/src/svg/tests.rs index 4adcf3a2ff..fd7f2713a7 100644 --- a/crates/io/src/svg/tests.rs +++ b/crates/io/src/svg/tests.rs @@ -55,7 +55,7 @@ fn preview_work_plain_paths_and_artboard_clip_do_not_repeat_photo_decode() { let svg = format!( r#""# ); - let parsed = parse_bounded_work_with(svg.as_bytes(), MAX_RASTER_PIXELS, fonts).unwrap(); + let parsed = parse_bounded_work_with(svg.as_bytes(), MAX_RASTER_PIXELS, true, fonts).unwrap(); assert!(!parsed.repeatable_raster && !parsed.repeatable_svg); let (imported, work) = import_svg_bounded_with_work("photo and paths", svg.as_bytes(), 48_000_000).unwrap(); assert!(work <= 48_000_000, "ordinary photo composition stays within two full-size surfaces: {work}"); @@ -70,7 +70,7 @@ fn preview_work_repeatable_raster_patterns_pay_for_repainting() { let svg = format!( r#""# ); - let parsed = parse_bounded_work_with(svg.as_bytes(), MAX_RASTER_PIXELS, fonts).unwrap(); + let parsed = parse_bounded_work_with(svg.as_bytes(), MAX_RASTER_PIXELS, true, fonts).unwrap(); assert!(parsed.repeatable_raster); assert!( import_svg_bounded_with_work("pattern", svg.as_bytes(), 8192).is_err(), @@ -86,14 +86,14 @@ fn preview_work_use_images_charge_each_resolved_occurrence() { let svg = format!( r##""## ); - let parsed = parse_bounded_work_with(svg.as_bytes(), MAX_RASTER_PIXELS, fonts).unwrap(); + let parsed = parse_bounded_work_with(svg.as_bytes(), MAX_RASTER_PIXELS, true, fonts).unwrap(); assert!(parsed.decoded >= 3 * 4096, "each resolved use must reserve its own raster decode: {}", parsed.decoded); assert!(!parsed.repeatable_raster); assert!(import_svg_bounded_with_work("uses", svg.as_bytes(), 3 * 4096).is_err()); let blurred = svg .replace("", "") .replace("{}", + embedded_image(mime, &bytes) + ); + let buf = rasterize_bounded(svg.as_bytes(), 1.0, MAX_RASTER_PIXELS).unwrap_or_else(|e| panic!("{mime}: {e}")); + let at = |x: usize, y: usize| buf.px[y * 4 + x]; + assert!(at(3, 1)[0] > 0.99 && at(3, 1)[3] > 0.99, "{mime}: the rect still draws"); + assert!(import_svg_bounded("clipboard", svg.as_bytes(), MAX_RASTER_PIXELS).is_ok(), "{mime}"); + } +} + +/// resvg reuses one scratch surface per level of nested SVG images, so side-by-side SVG +/// images (icons in a layout) cost one drawing-sized surface, not one each, and a drawing +/// rendered at up to the 24 MP output limit with one level of them still renders. +#[test] +fn side_by_side_svg_images_need_one_scratch_surface() { + let icon = ""; + let images: String = (0..12) + .map(|i| { + let data = icon.bytes().map(|byte| format!("%{byte:02X}")).collect::(); + format!("", i * 150) + }) + .collect(); + let layout = format!("{images}"); + assert!(parse_bounded(layout.as_bytes(), 24_000_000).is_ok(), "12 icons on a 2 MP canvas"); + let buf = rasterize_bounded(layout.as_bytes(), 2.0, 24_000_000).unwrap(); + assert_eq!((buf.rect.width(), buf.rect.height()), (3840, 2160)); + assert!(buf.px[100 * 3840 + 100][2] > 0.99, "the first icon draws"); + let one = format!("{}", &images[..images.find("/>").unwrap() + 2]); + let big = rasterize_bounded(one.as_bytes(), 10.0, 24_000_000).unwrap(); + assert_eq!((big.rect.width(), big.rect.height()), (4000, 3001), "a 12 MP render with one SVG image"); +} + +/// usvg parses an embedded SVG on top of the drawing's own recursion: 511 + 511 levels, each fine +/// alone, overflowed a 1 MB stack. The bounded path shares [`MAX_XML_DEPTH`] between them too. +#[test] +fn bounded_embedded_svg_gets_only_the_nesting_depth_the_drawing_left() { + let nested = |depth: usize| { + format!( + "{}{}", + "".repeat(depth), + "".repeat(depth) + ) + }; + let embed = move |outer: usize, inner: usize| { + let data = nested(inner).bytes().map(|byte| format!("%{byte:02X}")).collect::(); + format!( + "{}{}", + "".repeat(outer), + "".repeat(outer) + ) + }; + std::thread::Builder::new() + .stack_size(1 << 20) + .spawn(move || { + let deep = embed(MAX_XML_DEPTH - 2, MAX_XML_DEPTH - 2); + let buf = rasterize_bounded(deep.as_bytes(), 1.0, MAX_RASTER_PIXELS).unwrap(); + assert!(buf.px[4 * 8 + 4][3] < 0.01, "the too-deep image is left out"); + let fine = embed(200, 200); + let buf = rasterize_bounded(fine.as_bytes(), 1.0, MAX_RASTER_PIXELS).unwrap(); + assert!(buf.px[4 * 8 + 4][3] > 0.99, "within the shared budget the image draws"); + }) + .unwrap() + .join() + .unwrap(); +} + +/// Each level of embedded SVG image adds the parser's and renderer's frames on top of the one it +/// sits in. Every chain the bounded path takes (base64 grows each level, so the XML byte budget +/// ends it before [`MAX_EMBEDDED_SVG_DEPTH`]) parses and renders on a 1 MB thread stack (Windows' +/// main-thread size); deeper ones are refused. +#[test] +fn the_deepest_chain_of_embedded_svg_images_fits_a_small_stack() { + fn base64(bytes: &[u8]) -> String { + const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::new(); + for chunk in bytes.chunks(3) { + let n = chunk.iter().enumerate().fold(0u32, |n, (i, b)| n | u32::from(*b) << (16 - 8 * i)); + for i in 0..4 { + out.push(if i <= chunk.len() { T[(n >> (18 - 6 * i) & 63) as usize] as char } else { '=' }); + } + } + out + } + let chain = |levels: usize| { + let mut svg = "".to_string(); + for _ in 0..levels { + svg = format!( + "", + base64(svg.as_bytes()) + ); + } + svg + }; + std::thread::Builder::new() + .stack_size(1 << 20) + .spawn(move || { + let mut accepted = 0; + for levels in 1..=MAX_EMBEDDED_SVG_DEPTH + 1 { + match rasterize_bounded(chain(levels).as_bytes(), 1.0, MAX_RASTER_PIXELS) { + Ok(buf) => { + assert!(buf.px[4 * 8 + 4][3] > 0.99, "{levels} levels: the innermost rect draws through every level"); + accepted = levels; + } + Err(_) => assert!(accepted > 0, "a one-level chain renders"), + } + } + assert!((20..=MAX_EMBEDDED_SVG_DEPTH).contains(&accepted), "deepest accepted chain: {accepted}"); + }) + .unwrap() + .join() + .unwrap(); +} + +/// Only a stored display preview is strict about an embedded image it can't read; an edited SVG +/// saved back from an external editor leaves it out, as placement does. +#[test] +fn only_stored_previews_refuse_unreadable_embedded_images() { + let svg = image_svg(&embedded_image("image/bmp", b"BM\0\0\0\0")); + assert!(import_svg_bounded_with_work("edit", svg.as_bytes(), MAX_RASTER_PIXELS).is_ok()); + assert!(import_svg_preview_with_work("preview", svg.as_bytes(), MAX_RASTER_PIXELS).is_err()); +} diff --git a/crates/io/src/vector_preview.rs b/crates/io/src/vector_preview.rs index 32a3f1aeec..646d8c73f1 100644 --- a/crates/io/src/vector_preview.rs +++ b/crates/io/src/vector_preview.rs @@ -113,7 +113,7 @@ fn display_document_with_work(svg: &[u8], max_pixels: u64) -> Option<(photocraft } #[cfg(test)] RENDERED_PREVIEWS.with(|count| count.set(count.get().saturating_add(1))); - crate::svg::import_svg_bounded_with_work("Preview.svg", svg, max_pixels).ok().map(|(imported, work)| (imported.document, work)) + crate::svg::import_svg_preview_with_work("Preview.svg", svg, max_pixels).ok().map(|(imported, work)| (imported.document, work)) } /// Serialize only a validated preview of the same native source; the blob is four-byte padded. diff --git a/crates/ui-egui/src/smart_svg_ui.rs b/crates/ui-egui/src/smart_svg_ui.rs index eeb0e416cb..fc61944906 100644 --- a/crates/ui-egui/src/smart_svg_ui.rs +++ b/crates/ui-egui/src/smart_svg_ui.rs @@ -93,6 +93,10 @@ pub(crate) fn intercept(app: &mut PhotocraftApp, id: &str, params: &Value) -> Op return None; } let doc = st.doc.id; + // Contents already open in PhotoCraft's own editor: the engine switches to that document. + if app.session.smart_links.iter().any(|link| link.parent == doc && link.contents_id == smart.contents_id) { + return None; + } let result: Result = (|| { if app.smart_svg.pending.is_some() { return Ok(json!({"deferred": true})); diff --git a/crates/ui-egui/src/smart_svg_ui/tests.rs b/crates/ui-egui/src/smart_svg_ui/tests.rs index d782f3ac4f..bce275addb 100644 --- a/crates/ui-egui/src/smart_svg_ui/tests.rs +++ b/crates/ui-egui/src/smart_svg_ui/tests.rs @@ -170,6 +170,25 @@ fn manual_internal_choice_uses_existing_child_document_flow() { assert!(!app.smart_svg.internal_once); } +/// Edit Contents again on contents already open in the internal editor switches to that +/// document, as it does for any smart object, instead of an error. +#[test] +fn edit_contents_again_switches_to_the_open_internal_copy() { + let (mut app, _, calls) = fixture(); + let parent = app.session.active_index().unwrap(); + open(&mut app); + app.smart_svg.pending.as_mut().unwrap().editor = Editor::Internal; + confirm(&mut app).unwrap(); + let child = app.session.active().unwrap().doc.id; + app.session.set_active(parent); + app.run("layer.smartObjects.editContents", json!({})).unwrap(); + assert_eq!(app.session.active().unwrap().doc.id, child); + assert_eq!(app.session.smart_links.len(), 1); + assert!(app.smart_svg.pending.is_none()); + assert!(!app.ui.status_error, "{}", app.ui.status); + assert!(calls.borrow().is_empty()); +} + #[test] fn missing_external_service_keeps_internal_edit_and_save_back() { let (mut app, saves, calls) = fixture(); diff --git a/docs/embedded-vector-editing.md b/docs/embedded-vector-editing.md index 45bf5f14a5..e1fa2c33ba 100644 --- a/docs/embedded-vector-editing.md +++ b/docs/embedded-vector-editing.md @@ -21,8 +21,8 @@ No original file on disk is edited. Test on a PSD copy. Format preservation is not a claim of lossless Illustrator editing: the editor regenerates its supported AI/PDF/EPS content, and unsupported features or missing fonts can change appearance. Native Adobe Photoshop/Illustrator Edit Contents acceptance remains unverified. The chooser -keeps that warning explicit. Existing explicit `updateSvg` API calls retain their conversion -behavior; the native editing UI never uses that conversion path. +keeps that warning explicit. `updateSvg` only replaces SVG contents: it refuses an AI, PDF or +EPS source, which only `updateVectorSource` saves back, in its own format. Native editing requires VectorCraft and its adjacent vectorcraft-cli for bounded previews, even when another interactive editor is selected. The CLI runs headless in the background, @@ -30,9 +30,10 @@ restricted to a fresh private temporary read/write root per snapshot, separate f interactive working directory. Open and export must both succeed, with cancellation and a 60-second timeout. Each stable native save is validated before PhotoCraft accepts it. -Foreign AI files ordinarily require Save As in VectorCraft to protect the source. PhotoCraft -regenerates only the private AI working copy with VectorCraft editing data before launching it, -so ordinary Save can be used without changing that general protection. Merely opening the +Foreign AI files ordinarily require Save As in VectorCraft to protect the source. When VectorCraft +is the editor, PhotoCraft regenerates only the private AI working copy with VectorCraft editing +data before launching it, so ordinary Save can be used without changing that general protection. +Illustrator and other editors get the original AI bytes. Merely opening the editor never updates the embedded original. Ordinary EPS Save needs the companion VectorCraft EPS-save support; older editors may require exporting EPS to the opened private file.