mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-13 21:52:54 +00:00
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:
1 parent
afacb057f7
commit
1acb1f464b
16 files changed
+546
-119
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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());
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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}));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in new issue
Block a user