Integration: SVG contents open and render as on main; AI originals for other editors (#8571)

- Smart-object contents (Replace Contents, Convert to Layers, Stack Mode,
  internal Edit Contents) refused any SVG whose canvas area times its
  node count passed 24 MP, e.g. a 1920x1080 layout with 12 shapes.
  decode_source now uses the bounded image parse with File > Open's
  layered import (import_svg_bounded_images). Convert to Layers' shape
  cache cap is 1 GiB in the document's format, the import's own budget,
  not 24 MP (four full-canvas shapes in a 4K document failed).
- The bounded resolver failed the whole drawing on an embedded image it
  can't read (an unknown MIME type, a corrupt PNG); it now leaves it out
  as usvg does, so such SVGs place and render again. Byte and decode
  overruns stay errors.
- resvg gives each embedded SVG image a scratch pixmap the size of its
  target and frees it after, so the scratch budget counts the deepest
  chain of nested SVG images, not every sibling: 12 SVG icons on a 2 MP
  canvas, or one at a 12 MP render, no longer fail.
- Main's shared nesting budget (#9397) now covers the bounded path too:
  511 + 511 levels across a drawing and its embedded SVG overflowed a
  1 MB stack.
- An AI working copy is regenerated through VectorCraft only when
  VectorCraft is the editor; Illustrator or another program gets the
  original bytes. VectorCraft is recognised by bundle id, a known
  install, or its name (vectorcraft[.exe], vectorcraft-<version> AppImage,
  ai.storyteller.vectorcraft).
- updateSvg refuses an AI/PDF/EPS source (updateVectorSource saves it
  back in its own format); the doc no longer calls updateSvg "existing".
- Edit Contents on contents already open in the internal editor switches
  to that document, as main does, instead of an error.
- Stack Mode on an SVG refuses up front when one float frame per layer
  would pass 1 GiB (the layered import no longer refuses for it).
- A stored display preview still fails on an embedded image it can't
  read (strict budget, its own import_svg_preview_with_work; external-
  editor saves and conversions leave the image out as placement does); skipped embedded SVGs still use up the XML byte
  budget; an image under an unknown MIME type or with no pixels is left
  out uncharged.
- Merge: the PSD import reads a preview's native source from the
  document's global blocks; main's owned open moves them out of the
  file, so every stored preview was dropped.
- Main's strict Clippy rules on the PR's app code (explicit import, a
  Unix-only test helper, one long opt-in test).

Co-authored-by: Mocky Digital <282319807+mockydevs@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
committed 2026-10-29 18:13:23 +01:00
1 parent afacb057f7
commit 1acb1f464b
16 files changed
+546 -119

No files matched your search

+41 -3
View File
@@ -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-<version>-….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"<svg/>").unwrap();
+2 -1
View File
@@ -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();
+52 -5
View File
@@ -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<u8>), 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<u8>), 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#"<svg xmlns="http://www.w3.org/2000/svg" width="12mm" height="9mm"/>"#, 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 '<svg xmlns="http://www.w3.org/2000/svg" width="4" height="4"/>' > "$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());
+1 -1
View File
@@ -71,7 +71,7 @@ pub fn encode_source(doc: &Document) -> Result<Vec<u8>> {
/// Decodes smart-object contents (a `.pcraft` bundle, PSD/PSB, or any flat image format).
pub fn decode_source(file_name: &str, bytes: &[u8]) -> Result<Document> {
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}")));
}
@@ -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<Vec<[f32; 4]>> = layers
.iter()
.filter(|l| l.visible)
@@ -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!("<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"256\" height=\"256\">{}</svg>", "<rect width=\"256\" height=\"256\"/>".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!("<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1920\" height=\"1080\">{}</svg>", "<rect width=\"10\" height=\"10\"/>".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!("<rect x=\"{}\" width=\"100\" height=\"100\" fill=\"#336699\"/>", i * 150)).collect();
let svg = format!("<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1920\" height=\"1080\">{rects}</svg>");
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!("<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"384\" height=\"216\">{}</svg>", "<rect width=\"384\" height=\"216\"/>".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 = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"8\" height=\"8\"><image width=\"4\" height=\"4\" href=\"data:image/x-foo,abc\"/><rect x=\"4\" width=\"4\" height=\"8\" fill=\"#ff0000\"/></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!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"256\" height=\"256\">{}</svg>",
@@ -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!("<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"4000\" height=\"{h}\"><rect width=\"8\" height=\"8\"/></svg>");
let frame = |svg: String| {
+13 -4
View File
@@ -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<Value> {
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());
}
}
+4
View File
@@ -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 {
+33 -4
View File
@@ -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#"<svg xmlns="http://www.w3.org/2000/svg" width="8" height="6"><image width="4" height="6" href="data:image/bmp,BM"/><rect x="4" width="4" height="6" fill="blue"/></svg>"#;
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);
+3 -2
View File
@@ -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 {
+142 -77
View File
@@ -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<String>,
}
/// Leave out an embedded image that can't be read, or with a strict budget record `error`.
fn unreadable_image(budget: &Mutex<ImageBudget>, error: &str) -> Option<usvg::ImageKind> {
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<ImageBudget>, error: impl Into<String>) {
@@ -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<Vec<u8>>, options: &usvg::Options, budget: &Arc<Mutex<ImageBudget>>, depth: usize) -> Option<usvg::ImageKind> {
/// 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<Vec<u8>>,
options: &usvg::Options,
budget: &Arc<Mutex<ImageBudget>>,
depth: usize,
xml_left: usize,
) -> Option<usvg::ImageKind> {
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<Vec<u8>>, 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<Mutex<ImageBudget>>, depth: usize) -> Result<usvg::Tree, IoError> {
parse_image_bounded_with(bytes, budget, depth, fonts)
fn parse_image_bounded(bytes: &[u8], budget: Arc<Mutex<ImageBudget>>, depth: usize, xml_left: usize) -> Result<usvg::Tree, IoError> {
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<Mutex<ImageBudget>>,
depth: usize,
xml_left: usize,
fonts: fn() -> Arc<usvg::fontdb::Database>,
) -> Result<usvg::Tree, IoError> {
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<Parsed, IoError> {
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<usvg::fontdb::Database>) -> 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<usvg::fontdb::Database>) -> Result<ParsedWork, IoError> {
fn parse_bounded_work_with(bytes: &[u8], max_pixels: u64, strict: bool, fonts: fn() -> Arc<usvg::fontdb::Database>) -> Result<ParsedWork, IoError> {
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<ImportResult, IoError> {
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<photocraft_compose::Buffer,
/// Render untrusted embedded SVG with intrinsic raster-image and aggregate decode budgets.
pub fn rasterize_bounded(bytes: &[u8], scale: f32, max_decoded_pixels: u64) -> Result<photocraft_compose::Buffer, IoError> {
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)
}
+148 -6
View File
@@ -55,7 +55,7 @@ fn preview_work_plain_paths_and_artboard_clip_do_not_repeat_photo_decode() {
let svg = format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="6000" height="4000"><defs><clipPath id="page"><path d="M0 0H6000V4000H0Z"/></clipPath></defs><g clip-path="url(#page)"><image width="6000" height="4000" href="data:image/png,{data}"/><path d="M0 0H4V4Z"/><path d="M5 5H9V9Z"/><path d="M10 10H14V14Z"/><path d="M15 15H19V19Z"/></g></svg>"#
);
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#"<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8"><defs><pattern id="paint" width="1" height="1" patternUnits="userSpaceOnUse"><image width="1" height="1" href="data:image/png,{data}"/></pattern></defs><path fill="url(#paint)" d="M0 0H4V4H0Z"/><path fill="url(#paint)" d="M4 4H8V8H4Z"/></svg>"#
);
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##"<svg xmlns="http://www.w3.org/2000/svg" width="4" height="4"><defs><image id="photo" width="1" height="1" href="data:image/png,{data}"/></defs><use href="#photo"/><use href="#photo" x="1"/><use href="#photo" x="2"/></svg>"##
);
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("<defs>", "<defs><filter id=\"blur\"><feGaussianBlur stdDeviation=\"0.1\"/></filter>")
.replace("<use href=", "<use filter=\"url(#blur)\" href=");
let parsed = parse_bounded_work_with(blurred.as_bytes(), MAX_RASTER_PIXELS, fonts).unwrap();
let parsed = parse_bounded_work_with(blurred.as_bytes(), MAX_RASTER_PIXELS, true, fonts).unwrap();
assert!(!parsed.repeatable_raster && !parsed.repeatable_svg, "blur without image resources must not multiply source decoding");
}
@@ -123,8 +123,10 @@ fn embedded_images_share_budget_and_nested_svg_cannot_hide_raster_bombs() {
let oversized = image_svg(&embedded_image("image/png", &png_dimensions(u32::MAX, u32::MAX)));
let nested = image_svg(&embedded_image("image/svg+xml", oversized.as_bytes()));
assert!(parse_bounded(nested.as_bytes(), MAX_RASTER_PIXELS).unwrap_err().to_string().contains("decoded pixel budget"));
// A PNG without a header can't be charged, so it is left out like usvg's own resolver
// leaves out an image it can't read; the drawing still opens.
let malformed = image_svg(&embedded_image("image/png", b"\x89PNG\r\n\x1a\n"));
assert!(parse_bounded(malformed.as_bytes(), MAX_RASTER_PIXELS).unwrap_err().to_string().contains("dimensions"));
assert!(!parse_bounded(malformed.as_bytes(), MAX_RASTER_PIXELS).unwrap().tree.root().has_children());
assert_eq!(gif_frame_size(b"GIF89a\x01\0\x01\0\0\0\0\x21\xfe\xff"), None, "truncated extension has no frame");
}
@@ -760,8 +762,148 @@ fn bounded_svg_rejects_nested_gzip_at_top_level_and_inside_data_urls() {
for mime in ["image/svg+xml", "text/plain"] {
let single = image_svg(&embedded_image(mime, &once));
assert!(parse_bounded(single.as_bytes(), 64).is_ok());
// Inflated once, the doubly compressed image is not SVG text: it is left out, never
// inflated a second time.
let nested = image_svg(&embedded_image(mime, &twice));
assert!(parse_bounded(nested.as_bytes(), 64).unwrap_err().to_string().contains("UTF-8"));
assert!(!parse_bounded(nested.as_bytes(), 64).unwrap().tree.root().has_children());
}
assert!(parse_bounded(b"\xff\xfe", 64).is_err(), "invalid XML encoding is refused");
}
/// An embedded image the bounded resolver can't charge (an unknown MIME type, a format resvg
/// doesn't draw, a PNG under another MIME type, a corrupt PNG) is left out as usvg's own
/// resolver leaves it out, and the rest of the drawing still renders.
#[test]
fn unreadable_embedded_images_are_left_out_and_the_drawing_still_renders() {
let image = photocraft_codecs::Image::from_u8(2, 2, photocraft_codecs::ChannelLayout::Rgb, vec![255; 12]).unwrap();
let png = photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).unwrap();
for (mime, bytes) in [
("image/x-foo", png.clone()),
("application/octet-stream", png.clone()),
("application/octet-stream", png_dimensions(6000, 5000)),
("image/png", png_dimensions(0, 4)),
("image/bmp", b"BM\0\0\0\0".to_vec()),
("image/png", png[..20].to_vec()),
] {
let svg = format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"4\" height=\"4\">{}<rect x=\"2\" width=\"2\" height=\"4\" fill=\"#ff0000\"/></svg>",
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 = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"10\" height=\"10\"><rect width=\"10\" height=\"10\" fill=\"#0000ff\"/></svg>";
let images: String = (0..12)
.map(|i| {
let data = icon.bytes().map(|byte| format!("%{byte:02X}")).collect::<String>();
format!("<image x=\"{}\" width=\"100\" height=\"100\" href=\"data:image/svg+xml,{data}\"/>", i * 150)
})
.collect();
let layout = format!("<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1920\" height=\"1080\">{images}</svg>");
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!("<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"400\" height=\"300.1\">{}</svg>", &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!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"8\" height=\"8\">{}<rect width=\"8\" height=\"8\"/>{}</svg>",
"<g>".repeat(depth),
"</g>".repeat(depth)
)
};
let embed = move |outer: usize, inner: usize| {
let data = nested(inner).bytes().map(|byte| format!("%{byte:02X}")).collect::<String>();
format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"8\" height=\"8\">{}<image width=\"8\" height=\"8\" href=\"data:image/svg+xml,{data}\"/>{}</svg>",
"<g>".repeat(outer),
"</g>".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 = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"8\" height=\"8\"><rect width=\"8\" height=\"8\"/></svg>".to_string();
for _ in 0..levels {
svg = format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"8\" height=\"8\"><image width=\"8\" height=\"8\" href=\"data:image/svg+xml;base64,{}\"/></svg>",
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());
}
+1 -1
View File
@@ -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.
+4
View File
@@ -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<Value, String> = (|| {
if app.smart_svg.pending.is_some() {
return Ok(json!({"deferred": true}));
+19
View File
@@ -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();
+6 -5
View File
@@ -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.