mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-02 18:13:39 +00:00
Merge remote-tracking branch 'fork/integration-batch-10' into HEAD
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -91,6 +91,8 @@ The other built-in ICC profiles and the generated LUT looks are produced by code
|
||||
| `docs/brand/` (all files) | ArtCraft name, wordmark and mark | ArtCraft Team | getartcraft.com | Not open source; trademarks of the ArtCraft Team, [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt) |
|
||||
| `docs/images/photocraft-*.jpg` | PhotoCraft screenshots | PhotoCraft contributors (UI) | Rendered offscreen with the `snapshot` example | MIT OR Apache-2.0 (UI); the artwork in each is public domain, listed below |
|
||||
| `docs/images/preferences-apply-*.png` | Preferences before and after adding Apply (no artwork) | PhotoCraft contributors | PhotoCraft control-channel capture and offscreen `snapshot` example | MIT OR Apache-2.0 |
|
||||
| `docs/images/embedded-vector-editor-en.png` | Embedded AI/PDF/EPS conversion warning and editor chooser (no artwork) | Don Omondi | Offscreen `smart_svg_ui::tests::capture_embedded_vector_conversion_prompt` with authored bytes and mock services | MIT OR Apache-2.0 |
|
||||
| `docs/images/embedded-vector-tabs-linux.png` | Shared VectorCraft window after a portal editor save and ordinary document launch | Don Omondi | Local Linux GUI acceptance with authored orange rectangle/blue circle SVGs; no private artwork | MIT OR Apache-2.0 |
|
||||
| `docs/images/gradient-overlay-method-*.png` | Gradient Overlay Method selector, before and after (synthetic gradient) | @oRE-o | Rendered offscreen with the `snapshot` example; no external artwork | MIT OR Apache-2.0 |
|
||||
| `docs/images/cpu-pixel-alignment/*.png` | CPU canvas pixel alignment, before and after #8321 | PhotoCraft contributors | Original synthetic noise rendered with the offscreen `snapshot` example; see the adjacent README | MIT OR Apache-2.0, adjacent `LICENSE-MIT` and `LICENSE-APACHE` |
|
||||
|
||||
@@ -130,3 +132,12 @@ next to the files:
|
||||
| `corpus/pixls/` (11 files) | Real camera raws, one per decode path: Canon PowerShot SX50 HS (CR2 and its DNG conversion), Nikon D3 uncompressed NEF and Z6 paired 12/14-bit lossy and packed NEFs, Sony DSC-RX0 cRAW ARW, Panasonic DC-G9 high-res RW2, Olympus E-1 GRBG ORF, plus the E-5 packed ORF as the documented known-unsupported case. Per file (each CC0, per its database entry): [IMG_4059.CR2](https://raw.pixls.us/data/Canon/PowerShot%20SX50%20HS/IMG_4059.CR2), [CRW_4061.DNG](https://raw.pixls.us/data/Canon/PowerShot%20SX50%20HS/CRW_4061.DNG), [JD1_8203.NEF](https://raw.pixls.us/data/Nikon/D3/JD1_8203.NEF), [DSC00009.ARW](https://raw.pixls.us/data/Sony/DSC-RX0/DSC00009.ARW), [P1000475.RW2](https://raw.pixls.us/data/Panasonic/DC-G9/P1000475.RW2), [E_1__C106743_gredos.ORF](https://raw.pixls.us/data/Olympus/E-1/E_1__C106743_gredos.ORF), [_7061961_copy.ORF](https://raw.pixls.us/data/Olympus/E-5/_7061961_copy.ORF), [DSC_0750.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0750.NEF), [DSC_0751.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0751.NEF), [DSC_0753.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0753.NEF), [DSC_0754.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0754.NEF) | The photographers of the pixls.us raw samples database | [raw.pixls.us](https://raw.pixls.us) `data/`, fetched per [xtask/src/corpus_pins.rs](xtask/src/corpus_pins.rs) | CC0 / public domain, per file as linked |
|
||||
|
||||
Files copied into `corpus/` by hand (tiff, exr, raw) must be MIT, BSD or CC0.
|
||||
|
||||
## Embedded-vector editor source
|
||||
|
||||
The external SVG watcher, engine/UI handoff and bounded SVG rendering in this change
|
||||
are adapted from Skyfuller1303’s MIT OR Apache-2.0 implementation in
|
||||
[PhotoCraft #3409](https://github.com/storytold/photocraft/pull/3409), commit
|
||||
`897f20072762fdf0eb688adcdb8d8cda7b38ce66`. Don Omondi’s embedded AI/PDF/EPS
|
||||
extension and current-main integration build on that source. Clipboard and unrelated
|
||||
Shape Tools changes from that branch are not included.
|
||||
|
||||
Generated
+7
@@ -4965,6 +4965,8 @@ version = "0.17.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"async-io",
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
"chrono",
|
||||
"clap",
|
||||
"core-graphics2",
|
||||
@@ -4993,9 +4995,12 @@ dependencies = [
|
||||
"photocraft-raster",
|
||||
"photocraft-tablet",
|
||||
"photocraft-ui-egui",
|
||||
"plist",
|
||||
"resvg",
|
||||
"rfd",
|
||||
"ron",
|
||||
"roxmltree 0.21.1",
|
||||
"rustix 1.1.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
@@ -5146,6 +5151,7 @@ dependencies = [
|
||||
name = "photocraft-engine"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
"chrono",
|
||||
"craft-print",
|
||||
@@ -5248,6 +5254,7 @@ dependencies = [
|
||||
"craft-docpath",
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
"imagesize",
|
||||
"miniz_oxide 0.9.1",
|
||||
"photocraft-affinity",
|
||||
"photocraft-algo",
|
||||
|
||||
@@ -34,11 +34,14 @@ egui = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
blake3 = { version = "1.8", default-features = false, features = ["pure"] }
|
||||
chrono = { workspace = true }
|
||||
clap = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
tempfile = "=3.27.0"
|
||||
roxmltree = "0.21"
|
||||
craft-update = { workspace = true }
|
||||
rfd = { workspace = true }
|
||||
arboard = { version = "3.7", features = ["wayland-data-control"] }
|
||||
@@ -69,6 +72,15 @@ muda = { version = "=0.21.2", default-features = false }
|
||||
# Already in Cargo.lock (winit, objc2-app-kit); pinned exactly to the same version.
|
||||
dispatch2 = { version = "=0.3.1", default-features = false, features = ["std"] }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
rustix = { version = "=1.1.5", features = ["fs"] }
|
||||
# Reads CFBundleExecutable from a chosen macOS application bundle's Info.plist (XML or binary) for
|
||||
# external vector editing. Already in Cargo.lock (craft-update); built on every Unix so Linux CI
|
||||
# compiles and tests the bundle checks. The event-stream reader (behind plist's "unstable"
|
||||
# feature) lets us cap nesting: building a `Value`, or serde's ignored fields, recurses per level.
|
||||
# Pinned exactly because that API may change in a minor release: review the diff before bumping.
|
||||
plist = { version = "=1.10.3", features = ["enable_unstable_features_that_may_break_with_minor_version_bumps"] }
|
||||
|
||||
# Drop position on X11 (`cursor.rs`): pure-Rust X client, already in Cargo.lock; pinned exactly.
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
photocraft-cms = { workspace = true }
|
||||
@@ -110,3 +122,7 @@ harness = false
|
||||
[build-dependencies]
|
||||
# Icon + VERSIONINFO for photocraft.exe; build.rs only uses it when targeting Windows.
|
||||
winresource = "0.1"
|
||||
|
||||
[dev-dependencies]
|
||||
# Opt-in native save-back acceptance transports embedded sources through engine JSON.
|
||||
base64 = "=0.22.1"
|
||||
|
||||
@@ -390,7 +390,7 @@ fn stage_copy(from: &Path, to: &Path) -> std::io::Result<PathBuf> {
|
||||
fn remove_stale_staging(folder: &Path) {
|
||||
let Ok(entries) = std::fs::read_dir(folder) else { return };
|
||||
for entry in entries.flatten() {
|
||||
if !entry.file_name().to_str().is_some_and(|n| side_of(n, STAGED).is_some()) {
|
||||
if entry.file_name().to_str().is_none_or(|n| side_of(n, STAGED).is_none()) {
|
||||
continue;
|
||||
}
|
||||
let removed = match entry.file_type() {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,193 @@
|
||||
//! Launch a sandboxed editor through the desktop, exporting only the writable working SVG.
|
||||
//! Never execute another Flatpak's payload inside PhotoCraft or grant access to its home.
|
||||
|
||||
use super::native::OwnedSvg;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::mpsc::{Receiver, TryRecvError};
|
||||
use std::time::{Duration, Instant};
|
||||
use zbus::blocking::{Connection, Proxy};
|
||||
use zbus::zvariant::{Fd, OwnedObjectPath, OwnedValue, Value};
|
||||
|
||||
/// Ask the desktop (xdg-desktop-portal OpenURI) to let the user pick an editor for the working
|
||||
/// file, on a fresh session-bus connection with a 5-second method timeout. Returns once the
|
||||
/// portal answers; `stop` or a 120-second deadline cancels the wait.
|
||||
pub(super) fn open(owned: &OwnedSvg, stop: &Receiver<()>) -> Result<(), String> {
|
||||
let connection =
|
||||
zbus::blocking::connection::Builder::session().map_err(|e| e.to_string())?.method_timeout(Duration::from_secs(5)).build().map_err(|e| e.to_string())?;
|
||||
open_on(&connection, owned, stop)
|
||||
}
|
||||
|
||||
/// [`open`] on an existing connection: share a checked writable handle (never a pathname) with
|
||||
/// `OpenFile`, always asking the user to choose, and wait for the named request's response.
|
||||
fn open_on(connection: &Connection, owned: &OwnedSvg, stop: &Receiver<()>) -> Result<(), String> {
|
||||
use futures_lite::StreamExt;
|
||||
// Retain the checked handle for the portal call. Writable document-portal access also
|
||||
// supports atomic saves. The watcher keeps using PhotoCraft's original owned pathname.
|
||||
let file = owned.share_handle()?;
|
||||
let unique = connection.unique_name().ok_or("No desktop connection name")?.as_str().trim_start_matches(':').replace('.', "_");
|
||||
static NEXT: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
|
||||
let token = format!("photocraft_vector_{}_{}", std::process::id(), NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed));
|
||||
let path = format!("/org/freedesktop/portal/desktop/request/{unique}/{token}");
|
||||
let request = Proxy::new(connection, "org.freedesktop.portal.Desktop", path.as_str(), "org.freedesktop.portal.Request").map_err(|e| e.to_string())?;
|
||||
// Subscribe before launching: the portal can finish before OpenFile returns.
|
||||
let mut replies = futures_lite::future::block_on(request.inner().receive_signal("Response")).map_err(|e| e.to_string())?;
|
||||
let portal = Proxy::new(connection, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.OpenURI")
|
||||
.map_err(|e| e.to_string())?;
|
||||
// SVG's desktop default may be a viewer or browser. Always let the user select an editor.
|
||||
let options = HashMap::from([("handle_token", Value::from(token.as_str())), ("writable", Value::from(true)), ("ask", Value::from(true))]);
|
||||
let handle: OwnedObjectPath =
|
||||
portal.call("OpenFile", &("", Fd::from(&file), options)).map_err(|e| format!("Cannot open the desktop editor chooser: {e}"))?;
|
||||
if handle.as_str() != path {
|
||||
let returned =
|
||||
Proxy::new(connection, "org.freedesktop.portal.Desktop", handle.as_str(), "org.freedesktop.portal.Request").map_err(|e| e.to_string())?;
|
||||
let _: Result<(), _> = returned.call("Close", &());
|
||||
return Err("The desktop portal does not support named requests".into());
|
||||
}
|
||||
let deadline = Instant::now() + Duration::from_secs(120);
|
||||
loop {
|
||||
if !matches!(stop.try_recv(), Err(TryRecvError::Empty)) || Instant::now() >= deadline {
|
||||
let _: Result<(), _> = request.call("Close", &());
|
||||
return Err("Vector editor selection cancelled or timed out".into());
|
||||
}
|
||||
let next = futures_lite::future::block_on(futures_lite::future::race(async { Some(replies.next().await) }, async {
|
||||
async_io::Timer::after(Duration::from_millis(50)).await;
|
||||
None
|
||||
}));
|
||||
if let Some(reply) = next {
|
||||
let reply = reply.ok_or("The desktop editor chooser closed without an answer")?;
|
||||
let (code, _): (u32, HashMap<String, OwnedValue>) = reply.body().deserialize().map_err(|e| e.to_string())?;
|
||||
return match code {
|
||||
0 => Ok(()),
|
||||
1 => Err("Vector editor selection cancelled".into()),
|
||||
_ => Err("The desktop could not open the vector editor".into()),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::{BufRead, Read, Seek, Write};
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::mpsc;
|
||||
|
||||
struct Bus(Child);
|
||||
impl Drop for Bus {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.0.kill();
|
||||
let _ = self.0.wait();
|
||||
}
|
||||
}
|
||||
|
||||
fn bus() -> (Bus, Connection, Connection) {
|
||||
let guard = crate::CHILD_SPAWNS.read().unwrap();
|
||||
let mut child = Command::new("dbus-daemon").args(["--session", "--nofork", "--print-address=1"]).stdout(Stdio::piped()).spawn().unwrap();
|
||||
drop(guard);
|
||||
let mut address = String::new();
|
||||
std::io::BufReader::new(child.stdout.take().unwrap()).read_line(&mut address).unwrap();
|
||||
let server = zbus::blocking::connection::Builder::address(address.trim()).unwrap().name("org.freedesktop.portal.Desktop").unwrap().build().unwrap();
|
||||
let client = zbus::blocking::connection::Builder::address(address.trim()).unwrap().method_timeout(Duration::from_secs(2)).build().unwrap();
|
||||
(Bus(child), server, client)
|
||||
}
|
||||
|
||||
/// A real D-Bus FD transfer carries only the owned SVG, with explicit writable/chooser
|
||||
/// options. An immediate response cannot race subscription; writing that FD updates the
|
||||
/// original watched path. A rejected chooser never succeeds or launches a fallback.
|
||||
#[test]
|
||||
fn shares_a_writable_working_copy_and_handles_chooser_responses() {
|
||||
for code in [0u32, 1, 2] {
|
||||
let (_bus, server, client) = bus();
|
||||
let owned = OwnedSvg::create(b"<svg/>").unwrap();
|
||||
let messages = zbus::blocking::MessageIterator::from(&server);
|
||||
let worker = std::thread::spawn(move || {
|
||||
for message in messages {
|
||||
let message = message.unwrap();
|
||||
let header = message.header();
|
||||
if header.member().map(|m| m.as_str()) != Some("OpenFile") {
|
||||
continue;
|
||||
}
|
||||
let (parent, fd, options): (String, zbus::zvariant::OwnedFd, HashMap<String, OwnedValue>) = message.body().deserialize().unwrap();
|
||||
assert!(parent.is_empty());
|
||||
assert!(bool::try_from(options["ask"].try_clone().unwrap()).unwrap());
|
||||
assert!(bool::try_from(options["writable"].try_clone().unwrap()).unwrap());
|
||||
let mut file = std::fs::File::from(std::os::fd::OwnedFd::from(fd));
|
||||
let mut bytes = String::new();
|
||||
file.read_to_string(&mut bytes).unwrap();
|
||||
assert_eq!(bytes, "<svg/>");
|
||||
if code == 0 {
|
||||
file.rewind().unwrap();
|
||||
file.write_all(b"<svg >").unwrap();
|
||||
file.sync_all().unwrap();
|
||||
}
|
||||
let token = String::try_from(options["handle_token"].try_clone().unwrap()).unwrap();
|
||||
let sender = header.sender().unwrap().as_str().trim_start_matches(':').replace('.', "_");
|
||||
let path = format!("/org/freedesktop/portal/desktop/request/{sender}/{token}");
|
||||
server.reply(&header, &OwnedObjectPath::try_from(path.as_str()).unwrap()).unwrap();
|
||||
server
|
||||
.emit_signal(
|
||||
header.sender().cloned().map(zbus::names::BusName::from),
|
||||
path.as_str(),
|
||||
"org.freedesktop.portal.Request",
|
||||
"Response",
|
||||
&(code, HashMap::<String, Value<'_>>::new()),
|
||||
)
|
||||
.unwrap();
|
||||
// Keep the owner alive until the client has consumed its response.
|
||||
return server;
|
||||
}
|
||||
panic!("OpenFile was not called");
|
||||
});
|
||||
let (_stop, stopped) = mpsc::channel();
|
||||
assert_eq!(open_on(&client, &owned, &stopped).is_ok(), code == 0);
|
||||
let _server = worker.join().unwrap();
|
||||
assert_eq!(std::fs::read(&owned.file).unwrap(), if code == 0 { b"<svg >" } else { b"<svg/>" });
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_redirected_or_nonregular_files_before_contacting_the_portal() {
|
||||
let (_bus, _server, client) = bus();
|
||||
let owned = OwnedSvg::create(b"<svg/>").unwrap();
|
||||
let other = tempfile::NamedTempFile::new().unwrap();
|
||||
std::fs::remove_file(&owned.file).unwrap();
|
||||
std::os::unix::fs::symlink(other.path(), &owned.file).unwrap();
|
||||
let (_stop, stopped) = mpsc::channel();
|
||||
assert!(open_on(&client, &owned, &stopped).unwrap_err().contains("regular file"));
|
||||
std::fs::remove_file(&owned.file).unwrap();
|
||||
rustix::fs::mkfifoat(rustix::fs::CWD, &owned.file, rustix::fs::Mode::RUSR | rustix::fs::Mode::WUSR).unwrap();
|
||||
assert!(open_on(&client, &owned, &stopped).unwrap_err().contains("regular file"));
|
||||
}
|
||||
#[test]
|
||||
fn stopping_sync_closes_an_unanswered_desktop_request() {
|
||||
let (_bus, server, client) = bus();
|
||||
let owned = OwnedSvg::create(b"<svg/>").unwrap();
|
||||
let messages = zbus::blocking::MessageIterator::from(&server);
|
||||
let (stop, stopped) = mpsc::channel();
|
||||
let worker = std::thread::spawn(move || {
|
||||
let mut pending_stop = Some(stop);
|
||||
for message in messages {
|
||||
let message = message.unwrap();
|
||||
let header = message.header();
|
||||
match header.member().map(|member| member.as_str()) {
|
||||
Some("OpenFile") => {
|
||||
let (_, _, options): (String, zbus::zvariant::OwnedFd, HashMap<String, OwnedValue>) = message.body().deserialize().unwrap();
|
||||
let token = String::try_from(options["handle_token"].try_clone().unwrap()).unwrap();
|
||||
let sender = header.sender().unwrap().as_str().trim_start_matches(':').replace('.', "_");
|
||||
let path = format!("/org/freedesktop/portal/desktop/request/{sender}/{token}");
|
||||
server.reply(&header, &OwnedObjectPath::try_from(path.as_str()).unwrap()).unwrap();
|
||||
pending_stop.take().unwrap().send(()).unwrap();
|
||||
}
|
||||
Some("Close") => {
|
||||
server.reply(&header, &()).unwrap();
|
||||
return;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
panic!("The desktop request was not closed");
|
||||
});
|
||||
assert!(open_on(&client, &owned, &stopped).unwrap_err().contains("cancelled"));
|
||||
worker.join().unwrap();
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,8 @@ mod mac_menu;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod mac_window;
|
||||
// Pure logic is tested on every platform; only Linux runs the check.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod external_svg;
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
mod linux_libs;
|
||||
mod logging;
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
//! Opt-in local acceptance. All artwork paths come from the test environment; originals are read-only.
|
||||
|
||||
use super::{native, vector_conversion};
|
||||
use base64::Engine as _;
|
||||
use photocraft_doc::{Document, LayerContent, LayerId, SmartSource};
|
||||
use photocraft_ui_egui::{ExternalSvgSession, ExternalVectorSource};
|
||||
use serde_json::{Value, json};
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::{Arc, mpsc};
|
||||
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);
|
||||
let mut photo = photocraft_engine::Session::new();
|
||||
let vector_path = std::env::var_os("PHOTO_NATIVE_VECTOR");
|
||||
let vector_original = vector_path.as_ref().map(|path| std::fs::read(path).unwrap());
|
||||
let original_path = std::env::var_os("PHOTO_NATIVE_LOGO_PSD");
|
||||
let original = original_path.as_ref().map(|path| std::fs::read(path).unwrap());
|
||||
let mut parent_before = None;
|
||||
let id;
|
||||
if let Some(bytes) = &original {
|
||||
photo.add_document(photocraft_io::import("Logo.psd", bytes).unwrap().document, None);
|
||||
if let Ok(name) = std::env::var("PHOTO_NATIVE_CONTAINER") {
|
||||
parent_before = Some(photo.active().unwrap().doc.clone());
|
||||
let layer = find_source(&photo.active().unwrap().doc, &name).expect("nested logo container");
|
||||
photo.execute("layer.smartObjects.editContents", json!({"layer":layer.0})).unwrap();
|
||||
}
|
||||
let name = std::env::var("PHOTO_NATIVE_SOURCE").unwrap_or("Vector Smart Object.ai".into());
|
||||
id = find_source(&photo.active().unwrap().doc, &name).expect("visible embedded vector");
|
||||
} else {
|
||||
photo.execute("file.new", json!({"width":32,"height":24})).unwrap();
|
||||
let svg = br#"<svg xmlns="http://www.w3.org/2000/svg" width="8" height="6"><rect x="1" y="1" width="3" height="2" fill="red"/></svg>"#;
|
||||
let placed = photocraft_engine::file_cmds::place_bytes(&mut photo, "Artwork.svg", svg.to_vec(), None, &json!({"smartObject":true})).unwrap();
|
||||
id = LayerId(placed["layer"].as_u64().unwrap());
|
||||
// Generate native sources with our writer, or use a read-only local vector fixture.
|
||||
let (format, bytes) = if let Some(bytes) = &vector_original {
|
||||
let extension = Path::new(vector_path.as_ref().unwrap()).extension().unwrap().to_str().unwrap().to_ascii_lowercase();
|
||||
(extension, bytes.clone())
|
||||
} else {
|
||||
let owned = native::OwnedSvg::create(svg).unwrap();
|
||||
let format = std::env::var("PHOTO_NATIVE_FORMAT").unwrap_or("ai".into());
|
||||
let path = owned.root.join(format!("Input.{format}"));
|
||||
run_commands(
|
||||
editor,
|
||||
&owned.root,
|
||||
vec![
|
||||
("document.open", json!({"path":owned.file})),
|
||||
("document.export", json!({"path":path,"format":format,"preserveEditing":true,"useArtboards":true})),
|
||||
],
|
||||
);
|
||||
(format, std::fs::read(path).unwrap())
|
||||
};
|
||||
let state = photo.active_mut().unwrap();
|
||||
let LayerContent::Smart(sm) = &mut Arc::make_mut(&mut state.doc).layer_mut(id).unwrap().content else { panic!("smart") };
|
||||
sm.source = SmartSource::Embedded { file_name: format!("Artwork.{format}"), bytes: Arc::new(bytes) };
|
||||
}
|
||||
let handoff = photo.execute("layer.smartObjects.exportSvg", json!({"layer":id.0})).unwrap();
|
||||
let source = ExternalVectorSource {
|
||||
bytes: Arc::new(base64::engine::general_purpose::STANDARD.decode(handoff["sourceBase64"].as_str().unwrap()).unwrap()),
|
||||
extension: handoff["extension"].as_str().unwrap().into(),
|
||||
size: serde_json::from_value(handoff["sourceSize"].clone()).unwrap(),
|
||||
};
|
||||
let before = photo.active().unwrap().doc.clone();
|
||||
let (_send, stop) = mpsc::channel();
|
||||
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();
|
||||
let root = owned.root.clone();
|
||||
let mut watcher = native::start_native_test(owned, initial.clone(), editor.to_path_buf(), source.clone()).unwrap();
|
||||
std::thread::sleep(Duration::from_millis(300));
|
||||
assert!(watcher.poll_vector().is_none(), "opening must not accept the regenerated private AI working copy");
|
||||
// A small visible edit, ordinary Save to the already-open file, no conversion to SVG.
|
||||
run_commands(
|
||||
editor,
|
||||
&root,
|
||||
vec![
|
||||
("document.open", json!({"path":file})),
|
||||
("paint.setFill", json!({"color":"#00ff00"})),
|
||||
("shape.rectangle", json!({"x":2,"y":2,"width":3,"height":2})),
|
||||
("file.save", json!({})),
|
||||
],
|
||||
);
|
||||
let saved = std::fs::read(&file).unwrap();
|
||||
eprintln!("Native save: format={}, bytes={}", source.extension, saved.len());
|
||||
assert!(saved != initial, "Save did not update the native working file");
|
||||
assert!(photocraft_io::vector_preview::is_native_source(&saved));
|
||||
assert!(!photocraft_io::svg::is_svg(&saved));
|
||||
let deadline = Instant::now() + Duration::from_secs(90);
|
||||
let snapshot = loop {
|
||||
assert!(Instant::now() < deadline, "saved native vector never reached watcher");
|
||||
if let Some(result) = watcher.poll_vector() {
|
||||
break result.unwrap();
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(25));
|
||||
};
|
||||
assert!(snapshot.bytes == saved, "watcher snapshot differs from saved native bytes");
|
||||
let params = json!({"document":handoff["document"],"layer":id.0,"contentsId":handoff["contentsId"],"fingerprint":handoff["fingerprint"],"extension":source.extension,"sourceBase64":base64::engine::general_purpose::STANDARD.encode(&saved),"previewSvg":String::from_utf8(snapshot.preview_svg.unwrap()).unwrap()});
|
||||
assert_eq!(photo.execute("layer.smartObjects.updateVectorSource", params).unwrap()["updated"], true);
|
||||
let after = photo.active().unwrap().doc.clone();
|
||||
let LayerContent::Smart(previous) = &before.layer(id).unwrap().content else { panic!("smart") };
|
||||
let LayerContent::Smart(changed) = &after.layer(id).unwrap().content else { panic!("smart") };
|
||||
assert_eq!(changed.transform, previous.transform);
|
||||
assert_eq!(changed.perspective, previous.perspective);
|
||||
assert_eq!(changed.warp, previous.warp);
|
||||
assert_eq!(changed.smart_filters, previous.smart_filters);
|
||||
assert!(embedded(&after, changed).unwrap().1 == saved, "native source was replaced with different bytes");
|
||||
assert!(photo.undo());
|
||||
assert!(photo.active().unwrap().doc == before, "Undo did not restore original source state");
|
||||
assert!(photo.redo());
|
||||
assert!(photo.active().unwrap().doc == after, "Redo did not restore saved native source state");
|
||||
let output = photocraft_io::export(&after, "Roundtrip.psd", &Default::default()).unwrap();
|
||||
let reopened = photocraft_io::import("Roundtrip.psd", &output.bytes).unwrap().document;
|
||||
let layer = find_source(&reopened, &embedded(&after, changed).unwrap().0).unwrap();
|
||||
let LayerContent::Smart(sm) = &reopened.layer(layer).unwrap().content else { panic!("smart") };
|
||||
assert!(embedded(&reopened, sm).unwrap().1 == saved, "PSD reopen did not retain the saved native bytes");
|
||||
assert!(sm.vector_preview == changed.vector_preview, "PSD did not retain the source-bound preview");
|
||||
assert!(
|
||||
sm.transform.m.iter().zip(changed.transform.m).all(|(a, b)| (a - b).abs() < 1e-9),
|
||||
"PSD corner reconstruction changed placement beyond floating-point tolerance"
|
||||
);
|
||||
assert!(photocraft_engine::smart_cmds::render(&reopened, sm).unwrap().as_ref() == changed.cache.as_ref(), "reopened native preview changed appearance");
|
||||
if let Some(parent_before) = parent_before {
|
||||
photo.execute("layer.smartObjects.saveContents", json!({})).unwrap();
|
||||
photo.set_active(0);
|
||||
let parent_after = photo.active().unwrap().doc.clone();
|
||||
assert!(photo.undo());
|
||||
assert!(photo.active().unwrap().doc == parent_before, "parent Undo changed original content");
|
||||
assert!(photo.redo());
|
||||
assert!(photo.active().unwrap().doc == parent_after, "parent Redo changed saved content");
|
||||
let output = photocraft_io::export(&parent_after, "Roundtrip.psd", &Default::default()).unwrap();
|
||||
let reopened = photocraft_io::import("Roundtrip.psd", &output.bytes).unwrap().document;
|
||||
assert!(contains_source(&reopened, &saved, 0), "parent PSD lost its nested native vector");
|
||||
}
|
||||
if let Some(path) = vector_path {
|
||||
assert!(std::fs::read(path).unwrap() == vector_original.unwrap(), "original vector changed")
|
||||
}
|
||||
if let Some(path) = original_path {
|
||||
assert!(std::fs::read(path).unwrap() == original.unwrap(), "original PSD changed")
|
||||
}
|
||||
}
|
||||
|
||||
fn run_commands(editor: &Path, root: &Path, commands: Vec<(&str, Value)>) {
|
||||
let cli = editor.parent().unwrap().join(if cfg!(windows) { "vectorcraft-cli.exe" } else { "vectorcraft-cli" });
|
||||
let output_path = root.join("TestResponses.jsonl");
|
||||
let output = std::fs::File::create(&output_path).unwrap();
|
||||
let mut child = Command::new(cli)
|
||||
.args(["mcp", "--headless", "--automation-read-root"])
|
||||
.arg(root)
|
||||
.arg("--automation-write-root")
|
||||
.arg(root)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(output)
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let mut input = child.stdin.take().unwrap();
|
||||
let count = commands.len();
|
||||
for (index, (command, params)) in commands.into_iter().enumerate() {
|
||||
writeln!(
|
||||
input,
|
||||
"{}",
|
||||
json!({"jsonrpc":"2.0","id":index+1,"method":"tools/call","params":{"name":"run_command","arguments":{"command":command,"params":params}}})
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
drop(input);
|
||||
let deadline = Instant::now() + Duration::from_secs(90);
|
||||
loop {
|
||||
if let Some(status) = child.try_wait().unwrap() {
|
||||
assert!(status.success());
|
||||
break;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
panic!("editor test timed out")
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(25));
|
||||
}
|
||||
let responses = std::fs::read_to_string(output_path).unwrap();
|
||||
for id in 1..=count {
|
||||
let response = responses.lines().filter_map(|line| serde_json::from_str::<Value>(line).ok()).find(|r| r["id"] == id).unwrap();
|
||||
assert!(response.get("error").is_none() && response["result"]["isError"] == false, "editor command {id} failed");
|
||||
}
|
||||
}
|
||||
|
||||
fn embedded<'a>(doc: &'a Document, sm: &'a photocraft_doc::SmartObject) -> Option<(String, &'a [u8])> {
|
||||
match &sm.source {
|
||||
SmartSource::Embedded { file_name, bytes } => Some((file_name.clone(), bytes)),
|
||||
SmartSource::Linked { path } => photocraft_io::linked::embedded_payload(&doc.metadata, path),
|
||||
}
|
||||
}
|
||||
fn find_source(doc: &Document, name: &str) -> Option<LayerId> {
|
||||
doc.walk().into_iter().find_map(|(path, _, layer)| {
|
||||
if !doc.shown_at(&path) {
|
||||
return None;
|
||||
}
|
||||
let LayerContent::Smart(sm) = &layer.content else { return None };
|
||||
embedded(doc, sm).filter(|(file, _)| file == name).map(|_| layer.id)
|
||||
})
|
||||
}
|
||||
|
||||
fn contains_source(doc: &Document, saved: &[u8], depth: usize) -> bool {
|
||||
if depth > 4 {
|
||||
return false;
|
||||
}
|
||||
doc.walk().into_iter().any(|(_, _, layer)| {
|
||||
let LayerContent::Smart(sm) = &layer.content else { return false };
|
||||
let Some((name, bytes)) = embedded(doc, sm) else { return false };
|
||||
bytes == saved
|
||||
|| ((bytes.starts_with(b"8BPS") || photocraft_format::is_pcraft(bytes))
|
||||
&& photocraft_io::import(&name, bytes).is_ok_and(|child| contains_source(&child.document, saved, depth + 1)))
|
||||
})
|
||||
}
|
||||
@@ -716,6 +716,7 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
|
||||
cb.set_image(arboard::ImageData { width: w as usize, height: h as usize, bytes: std::borrow::Cow::Borrowed(px) }).map_err(|e| e.to_string())
|
||||
})
|
||||
}),
|
||||
open_external_svg: Some(Box::new(crate::external_svg::open)),
|
||||
clipboard_get_image: Some({
|
||||
let clip = clip.clone();
|
||||
let unreadable = clip_unreadable.clone();
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
//! Bounded display previews for native vector editing. The working source retains its format.
|
||||
//! No document metadata supplies a process, command line or output path.
|
||||
|
||||
use super::native::OwnedSvg;
|
||||
use photocraft_ui_egui::ExternalVectorSource;
|
||||
use std::io::{Read, Seek, Write};
|
||||
use std::path::Path;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::mpsc::Receiver;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Validate the source using a private preview, then expose only an original-format copy.
|
||||
/// `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 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)?;
|
||||
}
|
||||
let owned = OwnedSvg::create_format(&initial, &source.extension)?;
|
||||
Ok((owned, initial))
|
||||
}
|
||||
|
||||
/// Render a stable source snapshot inside private MCP roots; never rewrite the editor's file.
|
||||
pub(super) fn render_preview(editor: &Path, source: &ExternalVectorSource, stop: &Receiver<()>) -> Result<Vec<u8>, String> {
|
||||
let converted = if source.extension == "svg" { source.bytes.as_ref().clone() } else { run_export(editor, source, stop, "svg")? };
|
||||
source_svg(&converted, source.size)
|
||||
}
|
||||
|
||||
/// Convert `source` to `format` (`svg` or `ai`) with the `vectorcraft-cli` next to `editor`,
|
||||
/// confined to a private temporary read/write root. Errors on a format/magic mismatch, a
|
||||
/// missing or linked CLI, a failed open/export reply, cancellation via `stop`, or the
|
||||
/// 60-second deadline; the child process is killed and reaped on any failure.
|
||||
fn run_export(editor: &Path, source: &ExternalVectorSource, stop: &Receiver<()>, format: &str) -> Result<Vec<u8>, String> {
|
||||
if !matches!(source.extension.as_str(), "svg" | "pdf" | "ai" | "eps") {
|
||||
return Err("Unsupported embedded vector source type".into());
|
||||
}
|
||||
if !matches!(format, "svg" | "ai")
|
||||
|| !photocraft_io::vector_preview::is_native_source(&source.bytes)
|
||||
|| (source.extension == "pdf" && !source.bytes.starts_with(b"%PDF-"))
|
||||
|| (source.extension == "eps" && source.bytes.starts_with(b"%PDF-"))
|
||||
{
|
||||
return Err("The saved bytes do not match the original vector format".into());
|
||||
}
|
||||
let owned = OwnedSvg::create_format(&source.bytes, format)?;
|
||||
let cli_name = if cfg!(windows) { "vectorcraft-cli.exe" } else { "vectorcraft-cli" };
|
||||
let cli = editor.parent().ok_or("The editor has no installation directory")?.join(cli_name);
|
||||
// Selecting an editor explicitly also selects its adjacent VectorCraft CLI; PATH and the
|
||||
// embedded file name never participate in executable discovery.
|
||||
let metadata =
|
||||
std::fs::symlink_metadata(&cli).map_err(|_| "Embedded AI/PDF/EPS editing needs vectorcraft-cli next to the selected VectorCraft executable")?;
|
||||
if !metadata.is_file() || metadata.file_type().is_symlink() {
|
||||
return Err("VectorCraft CLI must be a regular file".into());
|
||||
}
|
||||
let input = owned.root.join(format!("Source.{}", source.extension));
|
||||
let mut file = std::fs::OpenOptions::new().write(true).create_new(true).open(&input).map_err(|e| format!("Cannot create vector input: {e}"))?;
|
||||
file.write_all(&source.bytes).map_err(|e| format!("Cannot write vector input: {e}"))?;
|
||||
drop(file);
|
||||
let commands = export_commands(&input, &owned.file, format)?;
|
||||
// MCP's headless roots apply to every thread and file reader, including references in
|
||||
// hostile embedded artwork. Ordinary `convert` does not establish those restrictions.
|
||||
let responses_path = owned.root.join("Responses.jsonl");
|
||||
let mut responses_file = std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&responses_path)
|
||||
.map_err(|e| format!("Cannot create conversion response file: {e}"))?;
|
||||
let responses_output = responses_file.try_clone().map_err(|e| format!("Cannot prepare conversion response handle: {e}"))?;
|
||||
let mut child = Command::new(cli)
|
||||
.args(["mcp", "--headless", "--automation-read-root"])
|
||||
.arg(&owned.root)
|
||||
.arg("--automation-write-root")
|
||||
.arg(&owned.root)
|
||||
.current_dir(&owned.root)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::from(responses_output))
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Cannot start VectorCraft conversion: {e}"))?;
|
||||
let result = (|| {
|
||||
let mut stdin = child.stdin.take().ok_or("VectorCraft conversion input is unavailable")?;
|
||||
for (id, command, params) in commands {
|
||||
let request = serde_json::json!({"jsonrpc":"2.0","id":id,"method":"tools/call","params":{"name":"run_command","arguments":{"command":command,"params":params}}});
|
||||
writeln!(stdin, "{request}").map_err(|e| format!("Cannot send vector conversion request: {e}"))?;
|
||||
}
|
||||
drop(stdin);
|
||||
let deadline = Instant::now() + Duration::from_secs(60);
|
||||
loop {
|
||||
if stop.try_recv().is_ok() {
|
||||
return Err("Vector editing cancelled".into());
|
||||
}
|
||||
if let Some(status) = child.try_wait().map_err(|e| format!("Cannot wait for vector conversion: {e}"))? {
|
||||
if !status.success() {
|
||||
return Err("VectorCraft could not convert the embedded vector".into());
|
||||
}
|
||||
break;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return Err("VectorCraft conversion timed out".into());
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(25));
|
||||
}
|
||||
let mut responses = String::new();
|
||||
// Read the retained handle, never reopen a pathname an input processor could replace.
|
||||
responses_file.rewind().map_err(|e| format!("Cannot seek conversion responses: {e}"))?;
|
||||
responses_file.take(1024 * 1024 + 1).read_to_string(&mut responses).map_err(|e| format!("Invalid conversion responses: {e}"))?;
|
||||
validate_responses(&responses)?;
|
||||
let converted = owned.read(&owned.stamp()?)?;
|
||||
if format == "ai" && !converted.starts_with(b"%PDF-") {
|
||||
return Err("VectorCraft did not save a PDF-compatible AI working copy".into());
|
||||
}
|
||||
Ok(converted)
|
||||
})();
|
||||
if result.is_err() {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Build requests only from UTF-8 paths: serializing a non-UTF-8 `Path` inside `json!` panics.
|
||||
#[cfg(all(test, unix))]
|
||||
fn conversion_commands(input: &Path, output: &Path) -> Result<[(u8, &'static str, serde_json::Value); 2], String> {
|
||||
export_commands(input, output, "svg")
|
||||
}
|
||||
|
||||
/// The open (id 1) and export (id 2) requests that [`validate_responses`] checks; AI exports
|
||||
/// preserve editing data. Non-UTF-8 paths return errors because `json!` would panic on them.
|
||||
fn export_commands(input: &Path, output: &Path, format: &str) -> Result<[(u8, &'static str, serde_json::Value); 2], String> {
|
||||
let input = input.to_str().ok_or("The temporary vector input path is not UTF-8")?;
|
||||
let output = output.to_str().ok_or("The temporary vector output path is not UTF-8")?;
|
||||
Ok([
|
||||
(1, "document.open", serde_json::json!({"path": input})),
|
||||
(2, "document.export", serde_json::json!({"path": output, "format": format, "preserveEditing": format == "ai"})),
|
||||
])
|
||||
}
|
||||
|
||||
/// Keep the original PSD source-pixel frame: PDF points and EPS bounds need not equal the
|
||||
/// placed object's stored pixel dimensions. The nested viewport scales vector geometry once.
|
||||
fn source_svg(bytes: &[u8], size: Option<(f64, f64)>) -> Result<Vec<u8>, String> {
|
||||
let text = std::str::from_utf8(bytes).map_err(|_| "Converted SVG is not UTF-8")?;
|
||||
photocraft_io::svg::import_svg_bounded_with_work("Artwork.svg", bytes, 96_000_000).map_err(|e| format!("Converted SVG is unsupported: {e}"))?;
|
||||
let doc = roxmltree::Document::parse(text).map_err(|e| format!("Invalid converted SVG: {e}"))?;
|
||||
let root = doc.root_element();
|
||||
if root.tag_name().name() != "svg" {
|
||||
return Err("VectorCraft did not return SVG".into());
|
||||
}
|
||||
let Some((w, h)) = size else {
|
||||
return Ok(bytes.to_vec());
|
||||
};
|
||||
if !w.is_finite() || !h.is_finite() || w <= 0.0 || h <= 0.0 || w > 16384.0 || h > 16384.0 || w * h > 24_000_000.0 {
|
||||
return Err("The original vector source dimensions exceed the editing limit".into());
|
||||
}
|
||||
// The nested root keeps its own width/height, so the outer viewBox must use the same CSS
|
||||
// pixels: `612pt` is 816 user units at SVG's 96 dpi. Other units are rejected below.
|
||||
let dimension = |key| {
|
||||
let value = root.attribute(key)?;
|
||||
let (number, scale) = value.strip_suffix("pt").map_or((value, 1.0), |number| (number, 4.0 / 3.0));
|
||||
number.parse::<f64>().ok().map(|v| v * scale).filter(|v| v.is_finite() && *v > 0.0)
|
||||
};
|
||||
let (iw, ih) = dimension("width").zip(dimension("height")).ok_or("Converted SVG has no finite dimensions")?;
|
||||
let inner = text.get(root.range()).ok_or("Invalid SVG root range")?;
|
||||
let result =
|
||||
format!(r#"<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}" viewBox="0 0 {iw} {ih}" preserveAspectRatio="none">{inner}</svg>"#)
|
||||
.into_bytes();
|
||||
if result.len() > 16 * 1024 * 1024 {
|
||||
return Err("Converted SVG exceeds 16 MiB".into());
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Require successful replies for both opening the source and exporting the SVG.
|
||||
fn validate_responses(text: &str) -> Result<(), String> {
|
||||
if text.len() > 1024 * 1024 {
|
||||
return Err("VectorCraft conversion responses exceeded the limit".into());
|
||||
}
|
||||
let replies: Vec<serde_json::Value> =
|
||||
text.lines().map(serde_json::from_str).collect::<Result<_, _>>().map_err(|_| "Invalid VectorCraft conversion response")?;
|
||||
for id in [1, 2] {
|
||||
let reply = replies.iter().find(|r| r.get("id").and_then(serde_json::Value::as_u64) == Some(id)).ok_or("Missing VectorCraft conversion response")?;
|
||||
if reply.get("error").is_some() || reply.get("result").and_then(|r| r.get("isError")).and_then(serde_json::Value::as_bool) != Some(false) {
|
||||
return Err("VectorCraft could not open or export the embedded vector; original contents were retained".into());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn non_utf8_temporary_paths_return_errors_instead_of_panicking() {
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
let invalid = Path::new(std::ffi::OsStr::from_bytes(b"/tmp/vector-\xff"));
|
||||
let valid = Path::new("/tmp/Artwork.svg");
|
||||
assert!(conversion_commands(invalid, valid).is_err());
|
||||
assert!(conversion_commands(valid, invalid).is_err());
|
||||
let commands = conversion_commands(valid, valid).unwrap();
|
||||
assert_eq!(commands[0].2["path"], "/tmp/Artwork.svg");
|
||||
assert_eq!(commands[1].2["format"], "svg");
|
||||
assert_eq!(commands[1].2["preserveEditing"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converted_vectors_use_the_psd_source_pixel_frame() {
|
||||
let svg = br#"<svg xmlns="http://www.w3.org/2000/svg" width="8" height="6"><rect width="8" height="6" fill="red"/></svg>"#;
|
||||
let normalized = source_svg(svg, Some((16.0, 12.0))).unwrap();
|
||||
let parsed = photocraft_io::svg::import_svg_bounded("Artwork.svg", &normalized, 24_000_000).unwrap();
|
||||
assert_eq!(parsed.document.size, photocraft_geom::Size::new(16, 12));
|
||||
assert_eq!(source_svg(svg, None).unwrap(), svg);
|
||||
let fractional = source_svg(svg, Some((16.3, 12.2))).unwrap();
|
||||
let parsed = photocraft_io::svg::import_svg_bounded("Artwork.svg", &fractional, 24_000_000).unwrap();
|
||||
assert_eq!(parsed.document.size, photocraft_geom::Size::new(16, 12));
|
||||
for size in [(f64::NAN, 1.0), (1.0, f64::INFINITY), (0.0, 1.0), (16384.0, 16384.0)] {
|
||||
assert!(source_svg(svg, Some(size)).is_err());
|
||||
}
|
||||
assert!(source_svg(b"%PDF-1.6", None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_sized_converted_vectors_fill_the_frame_without_cropping() {
|
||||
// 12pt × 9pt is 16 × 12 CSS pixels: red on the left half, blue on the right half,
|
||||
// with and without a root viewBox. Misreading pt as px would scale by 4/3 and crop.
|
||||
for svg in [
|
||||
r#"<svg xmlns="http://www.w3.org/2000/svg" width="12pt" height="9pt" viewBox="0 0 12 9"><rect width="6" height="9" fill="red"/><rect x="6" width="6" height="9" fill="blue"/></svg>"#,
|
||||
r#"<svg xmlns="http://www.w3.org/2000/svg" width="12pt" height="9pt"><rect width="8" height="12" fill="red"/><rect x="8" width="8" height="12" fill="blue"/></svg>"#,
|
||||
] {
|
||||
let normalized = source_svg(svg.as_bytes(), Some((16.0, 12.0))).unwrap();
|
||||
let raster = photocraft_io::svg::rasterize_bounded(&normalized, 1.0, 24_000_000).unwrap();
|
||||
assert_eq!((raster.rect.width(), raster.rect.height()), (16, 12));
|
||||
let (x0, y0) = (raster.rect.x0, raster.rect.y0);
|
||||
let is = |x: i32, y: i32, channel: usize| {
|
||||
let px = raster.get(x0 + x, y0 + y);
|
||||
px[3] > 0.99 && px[channel] > 0.99 && px[2 - channel] < 0.01
|
||||
};
|
||||
for y in [0, 6, 11] {
|
||||
assert!(is(0, y, 0) && is(6, y, 0), "left half must be red: {svg}");
|
||||
assert!(is(9, y, 2) && is(15, y, 2), "right half must be blue and uncropped: {svg}");
|
||||
}
|
||||
}
|
||||
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}}
|
||||
{"id":2,"result":{"isError":false}}"#;
|
||||
assert!(validate_responses(good).is_ok());
|
||||
for bad in [
|
||||
"",
|
||||
r#"{"id":2,"result":{"isError":false}}"#,
|
||||
r#"{"id":1,"result":{"isError":true}}
|
||||
{"id":2,"result":{"isError":false}}"#,
|
||||
r#"{"id":1,"error":{"message":"denied"}}"#,
|
||||
] {
|
||||
assert!(validate_responses(bad).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod live_vectorcraft_tests {
|
||||
use super::*;
|
||||
use std::sync::{Arc, mpsc};
|
||||
|
||||
/// Opt-in executable test uses only our own PostScript, never user artwork or Adobe assets.
|
||||
#[test]
|
||||
#[ignore = "set VECTORCRAFT_TEST_EDITOR to a local VectorCraft executable"]
|
||||
fn real_vectorcraft_converts_eps_and_rejects_failed_open_without_blank_replacement() {
|
||||
let editor = std::env::var_os("VECTORCRAFT_TEST_EDITOR").expect("VECTORCRAFT_TEST_EDITOR");
|
||||
let editor = Path::new(&editor);
|
||||
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, true).unwrap();
|
||||
assert_eq!(initial, *source.bytes);
|
||||
assert_eq!(owned.file.extension().unwrap(), "eps");
|
||||
assert_eq!(std::fs::read(&owned.file).unwrap(), *source.bytes);
|
||||
let svg = render_preview(editor, &source, &stop).unwrap();
|
||||
assert!(owned.file.exists());
|
||||
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, true).is_err());
|
||||
let root = owned.root.clone();
|
||||
drop(owned);
|
||||
assert!(!root.exists());
|
||||
}
|
||||
}
|
||||
@@ -205,6 +205,9 @@ sync_github_name = true
|
||||
[people.fjamesprice]
|
||||
sync_github_name = true
|
||||
|
||||
[people.mockydevs]
|
||||
real_name = "Don Omondi"
|
||||
|
||||
[people.ha1des]
|
||||
sync_github_name = true
|
||||
|
||||
|
||||
@@ -322,6 +322,8 @@ pub fn authorize_engine_command(id: &str, params: &Value) -> Result<(), Automati
|
||||
| "layer.smartObjects.editContents"
|
||||
| "layer.smartObjects.convertToLayers"
|
||||
| "layer.smartObjects.saveContents"
|
||||
| "layer.smartObjects.exportSvg"
|
||||
| "layer.smartObjects.updateSvg"
|
||||
);
|
||||
if (id.starts_with("layer.smartObjects.") && !in_memory_smart_command)
|
||||
|| matches!(
|
||||
@@ -371,9 +373,13 @@ fn params_contain_ambient_path(id: &str, params: &Value) -> bool {
|
||||
let keys: &[&str] = match id {
|
||||
"image.adjustments.colorLookup" | "layer.newAdjustmentLayer.colorLookup" | "layer.setAdjustment" => &["file"],
|
||||
"filter.distort.displace" => &["mapPath"],
|
||||
"layer.quickExportAsPng" | "layer.exportAs" | "image.applyDataSet" | "layer.smartObjects.editContents" | "layer.smartObjects.convertToLayers" => {
|
||||
&["path"]
|
||||
}
|
||||
"layer.quickExportAsPng"
|
||||
| "layer.exportAs"
|
||||
| "image.applyDataSet"
|
||||
| "layer.smartObjects.editContents"
|
||||
| "layer.smartObjects.convertToLayers"
|
||||
| "layer.smartObjects.exportSvg"
|
||||
| "layer.smartObjects.updateSvg" => &["path"],
|
||||
"image.mode.rgb" | "image.mode.grayscale" | "image.mode.cmyk" | "image.mode.lab" => &["profile"],
|
||||
"edit.assignProfile" | "edit.convertToProfile" | "edit.profileInfo" | "view.proofSetup" | "view.gamutWarning" => &["profile"],
|
||||
// New Document's Color Profile (#321) can name an .icc file.
|
||||
|
||||
@@ -510,6 +510,132 @@ fn registry_filesystem_path_params_are_classified() {
|
||||
assert!(unclassified.is_empty(), "filesystem path parameters in the command registry need an automation policy: {unclassified:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_vector_preview_updates_are_not_an_untrusted_automation_capability() {
|
||||
let params = serde_json::json!({"document":1,"layer":1,"contentsId":1,"fingerprint":"00".repeat(32),"extension":"ai","sourceBase64":"JVBERi0=","previewSvg":"<svg/>"});
|
||||
assert!(authorize_engine_command("layer.smartObjects.updateVectorSource", ¶ms).is_err());
|
||||
assert!(authorize_desktop_engine_command("layer.smartObjects.updateVectorSource", ¶ms).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn svg_smart_handoff_is_memory_only_and_linked_sources_stay_denied() {
|
||||
let mut headless = crate::Headless::new();
|
||||
headless.command_run("file.new", serde_json::json!({"width": 16, "height": 16})).unwrap();
|
||||
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="4" height="4"><rect width="4" height="4" fill="red"/></svg>"#;
|
||||
let inserted = photocraft_engine::file_cmds::place_bytes(
|
||||
&mut headless.session,
|
||||
"Artwork.svg",
|
||||
svg.as_bytes().to_vec(),
|
||||
None,
|
||||
&serde_json::json!({"smartObject":true}),
|
||||
)
|
||||
.unwrap();
|
||||
let layer = inserted["layer"].clone();
|
||||
let handoff = headless.command_run("layer.smartObjects.exportSvg", serde_json::json!({"layer": layer})).unwrap();
|
||||
assert_eq!(handoff["svg"], svg);
|
||||
let params = serde_json::json!({
|
||||
"document": handoff["document"], "layer": layer,
|
||||
"contentsId": handoff["contentsId"], "fingerprint": handoff["fingerprint"],
|
||||
"svg": svg.replace("red", "blue"),
|
||||
});
|
||||
assert!(authorize_desktop_engine_command("layer.smartObjects.updateSvg", ¶ms).is_ok());
|
||||
assert_eq!(headless.command_run("layer.smartObjects.updateSvg", params.clone()).unwrap()["updated"], true);
|
||||
for id in ["layer.smartObjects.exportSvg", "layer.smartObjects.updateSvg"] {
|
||||
let mut ambient = params.clone();
|
||||
ambient["path"] = serde_json::json!("/outside/source.svg");
|
||||
assert!(authorize_engine_command(id, &ambient).is_err(), "{id}");
|
||||
assert!(authorize_desktop_engine_command(id, &ambient).is_err(), "{id}");
|
||||
assert!(headless.command_run(id, ambient).is_err(), "{id}");
|
||||
}
|
||||
let doc = Arc::make_mut(&mut headless.session.active_mut().unwrap().doc);
|
||||
let id = photocraft_doc::LayerId(layer.as_u64().unwrap());
|
||||
let photocraft_doc::LayerContent::Smart(sm) = &mut doc.layer_mut(id).unwrap().content else { panic!("SVG smart object") };
|
||||
sm.source = photocraft_doc::SmartSource::Linked { path: "/outside/source.svg".into() };
|
||||
let before = headless.session.active().unwrap().doc.clone();
|
||||
assert!(headless.command_run("layer.smartObjects.exportSvg", serde_json::json!({"layer": layer})).is_err());
|
||||
assert!(headless.command_run("layer.smartObjects.updateSvg", params).is_err());
|
||||
assert_eq!(headless.session.active().unwrap().doc, before);
|
||||
assert!(headless.command_run("layer.smartObjects.exportContents", serde_json::json!({"layer": layer, "path": "/outside/export.svg"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn svg_update_cannot_replay_desktop_lut_file_in_any_shared_instance() {
|
||||
let (_, outside, _) = roots("svg-stored-filter");
|
||||
let lut = outside.join("private.cube");
|
||||
let cube = "LUT_3D_SIZE 2\n0 0 0\n1 0 0\n0 1 0\n1 1 0\n0 0 1\n1 0 1\n0 1 1\n1 1 1\n";
|
||||
std::fs::write(&lut, cube).unwrap();
|
||||
for shared_only in [false, true] {
|
||||
let mut headless = crate::Headless::new();
|
||||
headless.command_run("file.new", serde_json::json!({"width": 16, "height": 16})).unwrap();
|
||||
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="4" height="4"><rect width="4" height="4" fill="red"/></svg>"#;
|
||||
let pasted = photocraft_engine::file_cmds::place_bytes(
|
||||
&mut headless.session,
|
||||
"Artwork.svg",
|
||||
svg.as_bytes().to_vec(),
|
||||
None,
|
||||
&serde_json::json!({"smartObject":true}),
|
||||
)
|
||||
.unwrap();
|
||||
let target = photocraft_doc::LayerId(pasted["layer"].as_u64().unwrap());
|
||||
let filtered = if shared_only {
|
||||
let doc = Arc::make_mut(&mut headless.session.active_mut().unwrap().doc);
|
||||
let duplicate = doc.layer(target).unwrap().duplicate();
|
||||
let id = duplicate.id;
|
||||
doc.layers.push(duplicate);
|
||||
id
|
||||
} else {
|
||||
target
|
||||
};
|
||||
headless.session.select_layer(filtered).unwrap();
|
||||
// Record a natural desktop Color Lookup operation with its original file params,
|
||||
// then restore the untrusted automation hook as for an imported document.
|
||||
let authorization = headless.session.authorize.take();
|
||||
headless.session.execute("image.adjustments.colorLookup", serde_json::json!({"file":lut.to_string_lossy()})).unwrap();
|
||||
headless.session.authorize = authorization;
|
||||
let photocraft_doc::LayerContent::Smart(sm) = &headless.session.active().unwrap().doc.layer(filtered).unwrap().content else { panic!("smart object") };
|
||||
assert_eq!(sm.smart_filters.last().unwrap().params["file"], lut.to_string_lossy().as_ref());
|
||||
let handoff = headless.command_run("layer.smartObjects.exportSvg", serde_json::json!({"layer":target.0})).unwrap();
|
||||
let params = serde_json::json!({"document":handoff["document"],"layer":target.0,"contentsId":handoff["contentsId"],"fingerprint":handoff["fingerprint"],"svg":svg.replace("red","blue")});
|
||||
let before = headless.session.active().unwrap().doc.clone();
|
||||
let revision = headless.session.active().unwrap().revision;
|
||||
let error = headless.command_run("layer.smartObjects.updateSvg", params.clone()).unwrap_err();
|
||||
assert!(error.to_string().contains("ambient filesystem paths"), "{error}");
|
||||
assert_eq!(headless.session.active().unwrap().doc, before);
|
||||
assert_eq!(headless.session.active().unwrap().revision, revision);
|
||||
// A hidden stored filter is not executed, so its unused filename grants no access.
|
||||
let doc = Arc::make_mut(&mut headless.session.active_mut().unwrap().doc);
|
||||
let photocraft_doc::LayerContent::Smart(sm) = &mut doc.layer_mut(filtered).unwrap().content else { panic!("smart object") };
|
||||
sm.smart_filters.last_mut().unwrap().visible = false;
|
||||
assert_eq!(headless.command_run("layer.smartObjects.updateSvg", params).unwrap()["updated"], true);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn svg_update_keeps_inline_and_builtin_smart_filters_working() {
|
||||
let cube = "LUT_3D_SIZE 2\n0 0 0\n1 0 0\n0 1 0\n1 1 0\n0 0 1\n1 0 1\n0 1 1\n1 1 1\n";
|
||||
for filter_params in [serde_json::json!({"lut":"warm"}), serde_json::json!({"data":cube,"fileName":"look.cube"})] {
|
||||
let mut headless = crate::Headless::new();
|
||||
headless.command_run("file.new", serde_json::json!({"width":16,"height":16})).unwrap();
|
||||
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="4" height="4"><rect width="4" height="4" fill="red"/></svg>"#;
|
||||
let pasted = photocraft_engine::file_cmds::place_bytes(
|
||||
&mut headless.session,
|
||||
"Artwork.svg",
|
||||
svg.as_bytes().to_vec(),
|
||||
None,
|
||||
&serde_json::json!({"smartObject":true}),
|
||||
)
|
||||
.unwrap();
|
||||
let id = photocraft_doc::LayerId(pasted["layer"].as_u64().unwrap());
|
||||
headless.command_run("image.adjustments.colorLookup", filter_params).unwrap();
|
||||
let handoff = headless.command_run("layer.smartObjects.exportSvg", serde_json::json!({"layer":id.0})).unwrap();
|
||||
let result = headless.command_run("layer.smartObjects.updateSvg",serde_json::json!({"document":handoff["document"],"layer":id.0,"contentsId":handoff["contentsId"],"fingerprint":handoff["fingerprint"],"svg":svg.replace("red","blue")})).unwrap();
|
||||
assert_eq!(result["updated"], true);
|
||||
let photocraft_doc::LayerContent::Smart(sm) = &headless.session.active().unwrap().doc.layer(id).unwrap().content else { panic!("smart object") };
|
||||
assert_eq!(sm.smart_filters.len(), 1);
|
||||
assert!(!sm.cache.as_ref().unwrap().content_bounds().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
fn documents_filesystem_path_params(id: &str, params: &str) -> bool {
|
||||
// These commands return settings or installed-profile paths; they do not accept a host
|
||||
// filesystem path as an automation input.
|
||||
|
||||
@@ -503,12 +503,22 @@ fn normalize_runs<S: Clone>(text: &str, runs: Vec<(usize, S)>, base: S) -> Vec<(
|
||||
out
|
||||
}
|
||||
|
||||
/// A bounded display preview for an embedded native vector, bound to its exact source bytes.
|
||||
/// The SVG is display data only; saving the object retains the native source format.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct VectorPreview {
|
||||
pub source_hash: [u8; 32],
|
||||
pub svg: Arc<Vec<u8>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SmartObject {
|
||||
/// Ordinary layer duplicates share this identity; independent contents receive a fresh one.
|
||||
pub contents_id: SmartContentsId,
|
||||
/// Embedded source file bytes (PSD, PNG, …) or linked path.
|
||||
pub source: SmartSource,
|
||||
/// Optional source-pixel display preview for AI/PDF/EPS; ignored after source bytes change.
|
||||
pub vector_preview: Option<VectorPreview>,
|
||||
pub transform: Affine,
|
||||
pub smart_filters: Vec<SmartFilter>,
|
||||
/// Rendered appearance.
|
||||
@@ -540,6 +550,7 @@ impl SmartObject {
|
||||
Self {
|
||||
contents_id: SmartContentsId::fresh(),
|
||||
source,
|
||||
vector_preview: None,
|
||||
transform,
|
||||
smart_filters: Vec::new(),
|
||||
cache,
|
||||
|
||||
@@ -31,6 +31,7 @@ photocraft-io = { workspace = true }
|
||||
photocraft-psd = { workspace = true }
|
||||
photocraft-format = { workspace = true }
|
||||
photocraft-plugins = { workspace = true }
|
||||
base64 = "0.22"
|
||||
blake3 = { version = "1.8", default-features = false, features = ["pure"] }
|
||||
photocraft-codecs = { workspace = true }
|
||||
flate2 = { workspace = true }
|
||||
|
||||
@@ -239,7 +239,7 @@ fn shape_gradient_angles_after(cmd: &str, p: serde_json::Value) -> (f32, f32) {
|
||||
fn shape_gradient_stroke_turns_with_the_canvas() {
|
||||
let same = |a: f32, b: f32| {
|
||||
let d = (a - b).rem_euclid(360.0);
|
||||
d < 1e-3 || d > 360.0 - 1e-3
|
||||
d.is_finite() && !(1e-3..=360.0 - 1e-3).contains(&d)
|
||||
};
|
||||
for (cmd, p, want) in [
|
||||
("image.imageRotation.90cw", json!({}), -90.0),
|
||||
|
||||
@@ -133,6 +133,7 @@ pub mod select_extra_cmds;
|
||||
pub mod selection_cmds;
|
||||
pub mod slice_cmds;
|
||||
pub mod smart_cmds;
|
||||
pub mod smart_svg_cmds;
|
||||
pub mod smartselect_cmds;
|
||||
pub mod solid_fill_cmds;
|
||||
pub mod stamp_cmds;
|
||||
@@ -1230,6 +1231,7 @@ fn build() -> Vec<CommandSpec> {
|
||||
v.extend(crate::cutout_cmds::specs());
|
||||
v.extend(crate::symmetry_cmds::specs());
|
||||
v.extend(crate::edit_cmds::specs());
|
||||
v.extend(crate::smart_svg_cmds::specs());
|
||||
v.extend(crate::color_cmds::specs());
|
||||
v.extend(crate::brush_cmds::specs());
|
||||
v.extend(crate::brush_preset_cmds::specs());
|
||||
|
||||
@@ -70,6 +70,11 @@ 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_images(file_name, bytes, 24_000_000)
|
||||
.map(|imported| imported.document)
|
||||
.map_err(|e| other(format!("can't read SVG smart object contents: {e}")));
|
||||
}
|
||||
crate::file_cmds::import(file_name, bytes).map_err(|e| other(format!("can't read smart object contents \"{file_name}\": {e}")))
|
||||
}
|
||||
|
||||
@@ -141,17 +146,22 @@ use contents::{edit_contents, export_contents, is_smart_child, path_param, repla
|
||||
use filters::smart_mut;
|
||||
pub(crate) use filters::{add_smart_filter, filter_mask_paintable, filter_mask_surface, refresh_after_filter_mask_edit};
|
||||
use filters::{blending_options, delete_filter, edit_filters, move_filter, set_filter_params, set_filter_visible, smart, transfer_filters};
|
||||
use placing::{convert, detach_psd, shift_layer, via_copy};
|
||||
pub(crate) use placing::detach_psd;
|
||||
use placing::{convert, shift_layer, via_copy};
|
||||
pub use placing::{layer_to_smart, layers_to_smart, placement};
|
||||
pub(crate) use placing::{set_placement, shift_smart, transform_placement};
|
||||
#[cfg(test)]
|
||||
pub(crate) use rendering::DISPLAY_NATIVE_HASH_BYTES;
|
||||
use rendering::refresh_or_fail;
|
||||
pub(crate) use rendering::source_box;
|
||||
#[cfg(test)]
|
||||
use rendering::{NativeDigests, blend_surfaces, place, render_in};
|
||||
pub use rendering::{apply_smart_filters, purge_stack_cache, refresh, refresh_layer, render, render_below_filter, stack_cache_bytes, stack_image};
|
||||
#[cfg(test)]
|
||||
use rendering::{blend_surfaces, place, render_in};
|
||||
use source::svg_source_image_in;
|
||||
pub use source::{SourceImage, source_bytes, source_decode_count, source_image, svg_source_image};
|
||||
use source::{authorize_source, authorized_source_bytes};
|
||||
pub(crate) use source::{restore_unrendered, take_caches};
|
||||
pub(crate) use source::{restore_unrendered, take_caches, vector_scale};
|
||||
|
||||
const SO: &[&str] = &["Layer", "Smart Objects"];
|
||||
const SF: &[&str] = &["Layer", "Smart Filter"];
|
||||
|
||||
@@ -59,7 +59,7 @@ pub(crate) fn shift_smart(sm: &mut SmartObject, dx: i32, dy: i32) {
|
||||
|
||||
/// Forget PSD placed-layer data that no longer describes the smart object (its source changed):
|
||||
/// PSD export then writes fresh placed-layer blocks and embeds the new source.
|
||||
pub(super) fn detach_psd(l: &mut Layer) {
|
||||
pub(crate) fn detach_psd(l: &mut Layer) {
|
||||
if let LayerContent::Smart(sm) = &mut l.content {
|
||||
sm.psd_raw = None;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
use super::filters::smart;
|
||||
use super::source::{SourceImage, cache_get, cache_key, cache_put, source_bytes, source_image, source_surface, svg_source_image, vector_scale};
|
||||
use super::source::{
|
||||
SourceImage, cache_get, cache_key, cache_put, convert_svg_source, source_bytes, source_image, source_surface, svg_source_image_in, vector_scale,
|
||||
};
|
||||
use super::{
|
||||
Affine, Arc, BlendMode, Document, EngineError, Homography, JobCtx, Layer, LayerContent, LayerId, PixelFormat, Rect, Result, SmartFilter, SmartObject,
|
||||
SmartSource, Stage, StageKey, Surface, Value, decode_source, other, stack_cache,
|
||||
};
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// Layer › Smart Objects › Stack Mode: the source's layers (or, when it holds a single group, the
|
||||
/// group's layers) combined per pixel with `mode`. Cached like [`source_image`], keyed by mode.
|
||||
@@ -20,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)
|
||||
@@ -153,12 +165,13 @@ fn apply_smart_filter(f: &SmartFilter, cur: &Surface, canvas: Rect, extent: Rect
|
||||
}
|
||||
|
||||
/// The source placed in document pixels, before any smart filter.
|
||||
pub(super) fn place(name: &str, bytes: &[u8], fmt: PixelFormat, sm: &SmartObject) -> Result<Surface> {
|
||||
pub(super) fn place(name: &str, bytes: &[u8], doc: &Document, sm: &SmartObject) -> Result<Surface> {
|
||||
let fmt = doc.pixel_format();
|
||||
// A vector source is rendered at the placement's scale, and the placement is divided by
|
||||
// that scale so the rendered pixels land where the source units would.
|
||||
if sm.stack_mode.is_none() && sm.warp.is_none() && photocraft_io::svg::is_svg(bytes) {
|
||||
let k = vector_scale(sm);
|
||||
let img = svg_source_image(bytes, fmt, k)?;
|
||||
let img = svg_source_image_in(bytes, doc, k)?;
|
||||
return Ok(match &sm.perspective {
|
||||
Some(p) => {
|
||||
let [h0, h1, h2, h3, h4, h5, h6, h7, h8] = *p;
|
||||
@@ -173,6 +186,8 @@ pub(super) fn place(name: &str, bytes: &[u8], fmt: PixelFormat, sm: &SmartObject
|
||||
});
|
||||
}
|
||||
let img = match sm.stack_mode {
|
||||
Some(mode) if photocraft_io::svg::is_svg(bytes) => convert_svg_source(stack_image(name, bytes, PixelFormat::RGBA32F, mode)?, doc)?,
|
||||
None if photocraft_io::svg::is_svg(bytes) => svg_source_image_in(bytes, doc, 1.0)?,
|
||||
Some(mode) => stack_image(name, bytes, fmt, mode)?,
|
||||
None => source_image(name, bytes, fmt)?,
|
||||
};
|
||||
@@ -183,17 +198,103 @@ pub(super) fn place(name: &str, bytes: &[u8], fmt: PixelFormat, sm: &SmartObject
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! { pub(crate) static DISPLAY_NATIVE_HASH_BYTES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) }; }
|
||||
|
||||
/// BLAKE3 digests of native vector sources, remembered per source allocation so explicit
|
||||
/// refreshes and warp source frames of shared contents hash a (up to 16 MiB) source once.
|
||||
///
|
||||
/// Entries hold a [`Weak`](std::sync::Weak), never a strong reference: the source buffer is freed
|
||||
/// as soon as the document drops it, and only the small `Arc` header stays allocated until the
|
||||
/// entry is pruned. That header pinning is what makes the address a sound identity: no other
|
||||
/// `Arc` can be allocated there while the entry exists, and an `Arc` with a live `Weak` cannot be
|
||||
/// mutated in place (`Arc::get_mut` fails, `Arc::make_mut` moves to a new allocation), so a
|
||||
/// remembered digest always describes the bytes at that address. At most [`Self::CAPACITY`]
|
||||
/// entries are kept, least recently used first out, and dead entries are pruned on every lookup.
|
||||
pub(super) struct NativeDigests {
|
||||
pub(super) entries: Vec<(std::sync::Weak<Vec<u8>>, [u8; 32])>,
|
||||
capacity: usize,
|
||||
}
|
||||
|
||||
impl NativeDigests {
|
||||
const CAPACITY: usize = 32;
|
||||
|
||||
/// An empty cache holding at most `capacity` digests.
|
||||
pub(super) const fn new(capacity: usize) -> Self {
|
||||
Self { entries: Vec::new(), capacity }
|
||||
}
|
||||
|
||||
/// The remembered digest of exactly this allocation, refreshing its recency.
|
||||
pub(super) fn get(&mut self, bytes: &Arc<Vec<u8>>) -> Option<[u8; 32]> {
|
||||
self.entries.retain(|(weak, _)| weak.strong_count() > 0);
|
||||
let index = self.entries.iter().position(|(weak, _)| std::ptr::eq(weak.as_ptr(), Arc::as_ptr(bytes)))?;
|
||||
let entry = self.entries.remove(index);
|
||||
let digest = entry.1;
|
||||
self.entries.push(entry);
|
||||
Some(digest)
|
||||
}
|
||||
|
||||
/// Remember `digest` (computed from `bytes`), evicting the least recently used entry.
|
||||
pub(super) fn insert(&mut self, bytes: &Arc<Vec<u8>>, digest: [u8; 32]) {
|
||||
if self.get(bytes).is_some() || self.capacity == 0 {
|
||||
return;
|
||||
}
|
||||
if self.entries.len() >= self.capacity {
|
||||
self.entries.remove(0);
|
||||
}
|
||||
self.entries.push((Arc::downgrade(bytes), digest));
|
||||
}
|
||||
}
|
||||
|
||||
static NATIVE_DIGESTS: Mutex<NativeDigests> = Mutex::new(NativeDigests::new(NativeDigests::CAPACITY));
|
||||
|
||||
/// The BLAKE3 digest of a native source, hashed only the first time this allocation is seen.
|
||||
/// The lock is not held while hashing, so other renders never wait on a 16 MiB hash.
|
||||
fn native_digest(bytes: &Arc<Vec<u8>>) -> [u8; 32] {
|
||||
let lock = || NATIVE_DIGESTS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if let Some(digest) = lock().get(bytes) {
|
||||
return digest;
|
||||
}
|
||||
#[cfg(test)]
|
||||
DISPLAY_NATIVE_HASH_BYTES.with(|count| count.set(count.get().saturating_add(bytes.len())));
|
||||
let digest = *blake3::hash(bytes).as_bytes();
|
||||
lock().insert(bytes, digest);
|
||||
digest
|
||||
}
|
||||
|
||||
/// Preview data is untrusted after reload. Keep it on the bounded SVG path and
|
||||
/// never reuse it after a source replacement, even if a command retained the field.
|
||||
pub(super) fn display_source(sm: &SmartObject, bytes: &Arc<Vec<u8>>) -> Arc<Vec<u8>> {
|
||||
sm.vector_preview
|
||||
.as_ref()
|
||||
.filter(|preview| {
|
||||
if bytes.len() > 16 * 1024 * 1024 || !photocraft_io::vector_preview::is_native_source(bytes) {
|
||||
return false;
|
||||
}
|
||||
preview.source_hash == native_digest(bytes)
|
||||
&& !preview.svg.is_empty()
|
||||
&& preview.svg.len() <= 16 * 1024 * 1024
|
||||
&& photocraft_io::svg::is_svg(&preview.svg)
|
||||
})
|
||||
.map_or_else(|| bytes.clone(), |preview| preview.svg.clone())
|
||||
}
|
||||
|
||||
/// The box of a smart object's source in source pixels (what its warp's `us`/`vs` span), or `None`
|
||||
/// when the source can't be read.
|
||||
///
|
||||
/// An SVG (or a native source's SVG preview) without Stack Mode is rendered by the bounded
|
||||
/// whole-drawing path, so its box only needs the canvas bounds: no editable layer caches are
|
||||
/// built, and a drawing whose layer caches exceed the budget still gets a warp frame. Stack Mode
|
||||
/// keeps the strict layered import it renders with.
|
||||
pub(crate) fn source_box(doc: &Document, sm: &SmartObject) -> Option<[f64; 4]> {
|
||||
let (name, bytes) = source_bytes(&doc.metadata, &sm.source)?;
|
||||
let bytes = display_source(sm, &bytes);
|
||||
let fmt = doc.pixel_format();
|
||||
let img = match sm.stack_mode {
|
||||
Some(mode) => stack_image(&name, &bytes, fmt, mode),
|
||||
None => source_image(&name, &bytes, fmt),
|
||||
}
|
||||
.ok()?;
|
||||
let r = img.bounds;
|
||||
let r = match sm.stack_mode {
|
||||
None if photocraft_io::svg::is_svg(&bytes) => photocraft_io::svg::bounded_canvas_bounds(&bytes, 24_000_000).ok()?,
|
||||
Some(mode) => stack_image(&name, &bytes, fmt, mode).ok()?.bounds,
|
||||
None => source_image(&name, &bytes, fmt).ok()?.bounds,
|
||||
};
|
||||
Some([f64::from(r.x0), f64::from(r.y0), f64::from(r.x1), f64::from(r.y1)])
|
||||
}
|
||||
|
||||
@@ -213,13 +314,20 @@ pub(super) fn render_in(doc: &Document, sm: &SmartObject, ctx: &JobCtx) -> Resul
|
||||
crate::jobs::authorize_render_command(&filter.command, &filter.params)?;
|
||||
}
|
||||
let Some((name, bytes)) = source_bytes(&doc.metadata, &sm.source) else { return Ok(None) };
|
||||
let bytes = display_source(sm, &bytes);
|
||||
let (fmt, canvas) = (doc.pixel_format(), doc.bounds());
|
||||
let placed_key = stack_cache::placed_key(&bytes, fmt, sm, canvas);
|
||||
let mut placed_key = stack_cache::placed_key(&bytes, fmt, sm, canvas);
|
||||
if photocraft_io::svg::is_svg(&bytes) {
|
||||
let mut h = blake3::Hasher::new();
|
||||
h.update(&placed_key);
|
||||
h.update(&crate::color_cmds::document_profile(doc).to_bytes());
|
||||
placed_key = *h.finalize().as_bytes();
|
||||
}
|
||||
let placed = || -> Result<Stage> {
|
||||
if let Some(stage) = stack_cache::get(&placed_key) {
|
||||
return Ok(stage);
|
||||
}
|
||||
let surface = place(&name, &bytes, fmt, sm)?;
|
||||
let surface = place(&name, &bytes, doc, sm)?;
|
||||
// Edge pixels repeat at the canvas ∪ placed-content edge, as for layer filters.
|
||||
let stage = Stage { extent: canvas.union(&surface.content_bounds()), surface };
|
||||
stack_cache::put(placed_key, &stage);
|
||||
|
||||
@@ -189,15 +189,54 @@ pub fn svg_source_image(bytes: &[u8], fmt: PixelFormat, scale: f64) -> Result<So
|
||||
if let Some(img) = cache_get(&key) {
|
||||
return Ok(img);
|
||||
}
|
||||
let buf = photocraft_io::svg::rasterize(bytes, scale as f32).map_err(|e| other(format!("can't render the SVG smart object: {e}")))?;
|
||||
let buf = photocraft_io::svg::rasterize_bounded(bytes, scale as f32, 24_000_000).map_err(|e| other(format!("can't render the SVG smart object: {e}")))?;
|
||||
let img = SourceImage { surface: Arc::new(buffer_to_surface(&buf, fmt)), bounds: buf.rect };
|
||||
cache_put(key, img.clone());
|
||||
Ok(img)
|
||||
}
|
||||
|
||||
/// Render SVG's sRGB colours into the destination profile before placement. The profile is
|
||||
/// part of the source cache key, so assigning a different profile cannot reuse old colours.
|
||||
pub(super) fn svg_source_image_in(bytes: &[u8], doc: &Document, scale: f64) -> Result<SourceImage> {
|
||||
use photocraft_color::{ColorMode, SampleType};
|
||||
|
||||
let fmt = doc.pixel_format();
|
||||
let profile = crate::color_cmds::document_profile(doc);
|
||||
// An exact built-in sRGB destination needs no CMS conversion. Keep the direct render
|
||||
// path (and its depth-specific cache) without approximating custom profile equality.
|
||||
if doc.mode == ColorMode::Rgb && profile.to_bytes().as_ref() == photocraft_cms::Builtin::Srgb.profile().to_bytes().as_ref() {
|
||||
return svg_source_image(bytes, fmt, scale);
|
||||
}
|
||||
let mut h = blake3::Hasher::new();
|
||||
h.update(b"svg-icc\0");
|
||||
h.update(bytes);
|
||||
h.update(&scale.to_bits().to_le_bytes());
|
||||
h.update(&profile.to_bytes());
|
||||
let key = (*h.finalize().as_bytes(), fmt);
|
||||
if let Some(image) = cache_get(&key) {
|
||||
return Ok(image);
|
||||
}
|
||||
let source = svg_source_image(bytes, PixelFormat::new(ColorMode::Rgb, SampleType::F32, true), scale)?;
|
||||
let image = convert_svg_source(source, doc)?;
|
||||
cache_put(key, image.clone());
|
||||
Ok(image)
|
||||
}
|
||||
|
||||
/// Convert an sRGB SVG rendering into the document's profile and pixel format (relative
|
||||
/// colorimetric, black-point compensation), keeping its bounds.
|
||||
pub(super) fn convert_svg_source(source: SourceImage, doc: &Document) -> Result<SourceImage> {
|
||||
use photocraft_cms::{Builtin, Intent, Transform};
|
||||
let fmt = doc.pixel_format();
|
||||
let profile = crate::color_cmds::document_profile(doc);
|
||||
let transform = Transform::new(Builtin::Srgb.profile(), &profile, Intent::RelativeColorimetric, true)
|
||||
.map_err(|e| other(format!("can't convert the SVG smart object's colours: {e}")))?;
|
||||
let surface = crate::color_cmds::convert_surface(&source.surface, photocraft_color::ColorMode::Rgb, fmt, &transform).convert(fmt);
|
||||
Ok(SourceImage { surface: Arc::new(surface), bounds: source.bounds })
|
||||
}
|
||||
|
||||
/// The scale a vector source is rasterised at for its placement: the larger axis scale of the
|
||||
/// transform in 1/16 steps (so nudges reuse the cache), between 1/16 and 64.
|
||||
pub(super) fn vector_scale(sm: &SmartObject) -> f64 {
|
||||
pub(crate) fn vector_scale(sm: &SmartObject) -> f64 {
|
||||
let [a, b, c, d, ..] = sm.transform.m;
|
||||
let k = (a * a + b * b).sqrt().max((c * c + d * d).sqrt());
|
||||
if !k.is_finite() {
|
||||
|
||||
@@ -3,6 +3,7 @@ use super::*;
|
||||
mod filter_mask_paint;
|
||||
mod selection;
|
||||
mod stack_cache;
|
||||
mod svg_profile;
|
||||
|
||||
const DEPTHS: [u64; 3] = [8, 16, 32];
|
||||
const W: i32 = 64;
|
||||
@@ -1311,3 +1312,80 @@ fn clouds_and_fibers_move_with_the_smart_object() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The native-source digest cache keys by allocation without keeping sources alive, never
|
||||
/// returns a digest for different bytes, and stays within its capacity.
|
||||
#[test]
|
||||
fn native_digest_cache_is_bounded_weak_and_never_stale() {
|
||||
let mut cache = NativeDigests::new(4);
|
||||
let source = Arc::new(b"%PDF-1.7\nfirst".to_vec());
|
||||
let digest = *blake3::hash(&source).as_bytes();
|
||||
assert_eq!(cache.get(&source), None);
|
||||
cache.insert(&source, digest);
|
||||
assert_eq!(cache.get(&source), Some(digest));
|
||||
assert_eq!(Arc::strong_count(&source), 1, "the cache must not own the source");
|
||||
// Equal bytes in another allocation are a different identity: verified separately.
|
||||
let copy = Arc::new(source.as_ref().clone());
|
||||
assert_eq!(cache.get(©), None);
|
||||
// A dropped source's entry is pruned; a new source of the same length is never served
|
||||
// the old digest, even if the allocator reuses the buffer.
|
||||
drop(copy);
|
||||
let address = Arc::as_ptr(&source);
|
||||
drop(source);
|
||||
let replacement = Arc::new(b"%PDF-1.7\nother".to_vec());
|
||||
assert_ne!(Arc::as_ptr(&replacement), address, "the entry pins the old header address");
|
||||
assert_eq!(cache.get(&replacement), None);
|
||||
assert!(cache.entries.is_empty(), "dead entries are pruned");
|
||||
// Capacity is a hard bound with least-recently-used eviction.
|
||||
let live: Vec<Arc<Vec<u8>>> = (0..10u8).map(|n| Arc::new(vec![n; 8])).collect();
|
||||
for bytes in &live {
|
||||
cache.insert(bytes, *blake3::hash(bytes).as_bytes());
|
||||
assert!(cache.entries.len() <= 4);
|
||||
}
|
||||
assert!(live[..6].iter().all(|bytes| cache.get(bytes).is_none()));
|
||||
assert!(live[6..].iter().all(|bytes| cache.get(bytes) == Some(*blake3::hash(bytes).as_bytes())));
|
||||
assert!(live.iter().all(|bytes| Arc::strong_count(bytes) == 1));
|
||||
let mut disabled = NativeDigests::new(0);
|
||||
disabled.insert(&live[0], [0; 32]);
|
||||
assert!(disabled.entries.is_empty());
|
||||
}
|
||||
|
||||
/// Commands edit sources copy-on-write. The cache keys by allocation, so it is only sound if a
|
||||
/// remembered allocation can never be changed in place. A live `Weak` guarantees that:
|
||||
/// `Arc::get_mut` refuses and `Arc::make_mut` moves the bytes to a new allocation (a miss), both
|
||||
/// for a uniquely held source and for one shared with another document. Lookups also refresh
|
||||
/// recency, so a source in active use is not evicted by newer one-off reads.
|
||||
#[test]
|
||||
fn native_digest_cache_misses_after_copy_on_write_and_keeps_recently_used_sources() {
|
||||
let mut cache = NativeDigests::new(2);
|
||||
let mut unique = Arc::new(b"%PDF-1.7\nunique".to_vec());
|
||||
let before = *blake3::hash(&unique).as_bytes();
|
||||
cache.insert(&unique, before);
|
||||
assert!(Arc::get_mut(&mut unique).is_none(), "a remembered allocation cannot be edited in place");
|
||||
let old_address = Arc::as_ptr(&unique);
|
||||
Arc::make_mut(&mut unique).extend_from_slice(b" edited");
|
||||
assert_ne!(Arc::as_ptr(&unique), old_address, "copy-on-write moved the edited bytes");
|
||||
assert_eq!(cache.get(&unique), None, "the edited source must be hashed again");
|
||||
// The entry for the old allocation is dead (its bytes are gone) and is pruned.
|
||||
assert!(cache.entries.is_empty());
|
||||
let mut shared = Arc::new(b"%PDF-1.7\nshared".to_vec());
|
||||
let other_document = Arc::clone(&shared);
|
||||
let digest = *blake3::hash(&shared).as_bytes();
|
||||
cache.insert(&shared, digest);
|
||||
Arc::make_mut(&mut shared).push(b'!');
|
||||
assert_eq!(cache.get(&shared), None);
|
||||
assert_eq!(cache.get(&other_document), Some(digest), "the untouched copy keeps its digest");
|
||||
assert_eq!(Arc::strong_count(&other_document), 1, "only the other document owns it");
|
||||
// Recency: `get` moves an entry to the back, so the next insert evicts the other one.
|
||||
let newer = Arc::new(vec![1u8; 4]);
|
||||
cache.insert(&newer, [1; 32]);
|
||||
assert_eq!(cache.get(&other_document), Some(digest));
|
||||
let newest = Arc::new(vec![2u8; 4]);
|
||||
cache.insert(&newest, [2; 32]);
|
||||
assert_eq!(cache.get(&other_document), Some(digest), "a recently read source survives eviction");
|
||||
assert_eq!(cache.get(&newer), None, "the least recently used entry was evicted");
|
||||
// A remembered source's buffer is released with its last owner: only a dead Weak remains.
|
||||
let weak = Arc::downgrade(&newest);
|
||||
drop(newest);
|
||||
assert!(weak.upgrade().is_none());
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ fn uncached(s: &Session) -> Surface {
|
||||
let doc = &s.active().unwrap().doc;
|
||||
let sm = active_smart(s);
|
||||
let (name, bytes) = source_bytes(&doc.metadata, &sm.source).unwrap();
|
||||
apply_smart_filters(&place(&name, &bytes, doc.pixel_format(), &sm).unwrap(), &sm, doc.bounds()).unwrap()
|
||||
apply_smart_filters(&place(&name, &bytes, doc, &sm).unwrap(), &sm, doc.bounds()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
use super::*;
|
||||
use photocraft_cms::Builtin;
|
||||
|
||||
const SVG: &[u8] = br##"<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8"><rect width="8" height="8" fill="#808080"/></svg>"##;
|
||||
|
||||
#[test]
|
||||
fn svg_builtin_srgb_keeps_direct_render_at_each_depth() {
|
||||
let svg = br##"<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8"><rect width="4" height="8" fill="#336699"/><rect x="4" width="4" height="4" fill="#e65c1a" fill-opacity="0.5"/></svg>"##;
|
||||
for depth in DEPTHS {
|
||||
let s = session(depth);
|
||||
let mut doc = (*s.active().unwrap().doc).clone();
|
||||
for explicit_profile in [false, true] {
|
||||
doc.icc_profile = explicit_profile.then(|| Builtin::Srgb.profile().to_bytes());
|
||||
let expected = svg_source_image(svg, doc.pixel_format(), 1.0).unwrap();
|
||||
let actual = svg_source_image_in(svg, &doc, 1.0).unwrap();
|
||||
assert_eq!(actual.bounds, expected.bounds);
|
||||
assert_eq!(actual.surface, expected.surface, "sRGB at {depth}-bit, explicit={explicit_profile}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn svg_smart_rerender_checks_embedded_raster_dimensions() {
|
||||
let mut header = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".to_vec();
|
||||
header.extend_from_slice(&32768u32.to_be_bytes());
|
||||
header.extend_from_slice(&32768u32.to_be_bytes());
|
||||
header.extend_from_slice(&[8, 6, 0, 0, 0]);
|
||||
let data = header.iter().map(|byte| format!("%{byte:02X}")).collect::<String>();
|
||||
let svg =
|
||||
format!("<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1\" height=\"1\"><image width=\"1\" height=\"1\" href=\"data:image/png,{data}\"/></svg>");
|
||||
assert!(svg_source_image(svg.as_bytes(), PixelFormat::RGBA32F, 1.0).unwrap_err().to_string().contains("decoded pixel budget"));
|
||||
let s = session(8);
|
||||
let smart = SmartObject::new(SmartSource::Embedded { file_name: "Clipboard.svg".into(), bytes: Arc::new(svg.into_bytes()) }, Affine::IDENTITY, None);
|
||||
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_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_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());
|
||||
}
|
||||
|
||||
/// 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_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>",
|
||||
"<rect width=\"256\" height=\"256\" fill=\"#336699\"/>".repeat(400)
|
||||
);
|
||||
let s = session(8);
|
||||
let doc = s.active().unwrap().doc.clone();
|
||||
let mut smart =
|
||||
SmartObject::new(SmartSource::Embedded { file_name: "Clipboard.svg".into(), bytes: Arc::new(svg.clone().into_bytes()) }, Affine::IDENTITY, None);
|
||||
let bounds = [0.0, 0.0, 256.0, 256.0];
|
||||
assert_eq!(source_box(&doc, &smart), Some(bounds));
|
||||
smart.warp = Some(photocraft_geom::warp::Warp::custom(photocraft_geom::warp::BezierMesh::identity(bounds, 1, 1), bounds));
|
||||
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");
|
||||
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), 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| {
|
||||
source_box(&doc, &SmartObject::new(SmartSource::Embedded { file_name: "Big.svg".into(), bytes: Arc::new(svg.into_bytes()) }, Affine::IDENTITY, None))
|
||||
};
|
||||
assert_eq!(frame(canvas(6000)), Some([0.0, 0.0, 4000.0, 6000.0]));
|
||||
assert_eq!(frame(canvas(6001)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn svg_smart_render_cache_tracks_destination_profile_and_transform() {
|
||||
for depth in DEPTHS {
|
||||
let s = session(depth);
|
||||
let srgb = (*s.active().unwrap().doc).clone();
|
||||
let mut linear = srgb.clone();
|
||||
linear.icc_profile = Some(Builtin::LinearSrgb.profile().to_bytes());
|
||||
let mut smart = SmartObject::new(SmartSource::Embedded { file_name: "clipboard.svg".into(), bytes: Arc::new(SVG.to_vec()) }, Affine::IDENTITY, None);
|
||||
let original = render(&srgb, &smart).unwrap().unwrap();
|
||||
let corrected = render(&linear, &smart).unwrap().unwrap();
|
||||
assert!((original.pixel(3, 3)[0] - 128.0 / 255.0).abs() < 0.01);
|
||||
assert!((corrected.pixel(3, 3)[0] - 0.216).abs() < 0.01, "linear profile at {depth}-bit");
|
||||
assert_eq!(render(&srgb, &smart).unwrap().unwrap(), original, "profile-specific placed cache");
|
||||
smart.transform = Affine { m: [2.0, 0.0, 0.0, 2.0, 4.0, 2.0] };
|
||||
let scaled = render(&linear, &smart).unwrap().unwrap();
|
||||
assert!((scaled.pixel(10, 8)[0] - corrected.pixel(3, 3)[0]).abs() < 0.01, "scale must keep corrected colours");
|
||||
assert_eq!(scaled.format(), linear.pixel_format());
|
||||
let bounds = [0.0, 0.0, 8.0, 8.0];
|
||||
smart.warp = Some(photocraft_geom::warp::Warp::custom(photocraft_geom::warp::BezierMesh::identity(bounds, 1, 1), bounds));
|
||||
let warped = render(&linear, &smart).unwrap().unwrap();
|
||||
assert!((warped.pixel(10, 8)[0] - corrected.pixel(3, 3)[0]).abs() < 0.01, "warp must keep corrected colours");
|
||||
linear.layers = vec![Layer::new("Saved SVG", LayerContent::Smart(smart.clone()))];
|
||||
let saved = encode_source(&linear).unwrap();
|
||||
let reopened = decode_source("saved.pcraft", &saved).unwrap();
|
||||
let LayerContent::Smart(restored) = &reopened.layers[0].content else { panic!("smart object lost on save") };
|
||||
assert_eq!(restored.source, smart.source);
|
||||
assert_eq!(restored.transform, smart.transform);
|
||||
let rerendered = render(&reopened, restored).unwrap().unwrap();
|
||||
assert!((rerendered.pixel(10, 8)[0] - corrected.pixel(3, 3)[0]).abs() < 0.01, "saved source keeps ICC and warp");
|
||||
}
|
||||
}
|
||||
@@ -89,6 +89,12 @@ fn place(s: &Session, doc: &Document, sm: &SmartObject, layers: &mut [Layer]) ->
|
||||
}
|
||||
let h = placement(sm);
|
||||
let affine = sm.perspective.is_none().then_some(sm.transform);
|
||||
if let SmartSource::Embedded { bytes, .. } = &sm.source
|
||||
&& photocraft_io::svg::is_svg(bytes)
|
||||
&& let Some(transform) = affine
|
||||
{
|
||||
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))?;
|
||||
crate::transform_cmds::refresh_text(doc, l);
|
||||
@@ -186,3 +192,32 @@ 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:
|
||||
/// 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();
|
||||
while let Some(layer) = pending.pop() {
|
||||
pending.extend(layer.children().into_iter().flatten());
|
||||
let LayerContent::Shape(shape) = &layer.content else { continue };
|
||||
let mut shape = shape.clone();
|
||||
crate::vector_cmds::transform_shape(&mut shape, &transform);
|
||||
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 > max_pixels {
|
||||
return Err("The unpacked shape caches exceed the smart-object cache budget".into());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,453 @@
|
||||
//! Memory-only SVG smart-object handoff. The shell owns editor selection and temporary files;
|
||||
//! commands own optimistic concurrency, bounded rendering and one shared-content transaction.
|
||||
|
||||
use base64::Engine as _;
|
||||
use std::sync::Arc;
|
||||
|
||||
use photocraft_doc::{Document, Layer, LayerContent, LayerId, SmartContentsId, SmartObject, SmartSource};
|
||||
use photocraft_geom::{Rect, Size};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::commands::CommandSpec;
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
const EXPORT: &str = "layer.smartObjects.exportSvg";
|
||||
const UPDATE: &str = "layer.smartObjects.updateSvg";
|
||||
const UPDATE_VECTOR: &str = "layer.smartObjects.updateVectorSource";
|
||||
pub const MAX_VECTOR_SOURCE_BYTES: usize = 16 << 20;
|
||||
const MAX_PIXELS: u64 = 24_000_000;
|
||||
const MAX_FRAME_XML_BYTES: usize = 64 << 20;
|
||||
|
||||
/// A `BadParams` error attributed to command `cmd`.
|
||||
fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
|
||||
EngineError::BadParams { cmd: cmd.into(), msg: msg.into() }
|
||||
}
|
||||
|
||||
/// Read the `layer` param from an object; when it is absent, updates (`required`) fail
|
||||
/// rather than falling back to the active layer.
|
||||
fn layer(s: &Session, p: &Value, cmd: &str, required: bool) -> Result<LayerId> {
|
||||
if !p.is_object() {
|
||||
return Err(bad(cmd, "parameters must be an object"));
|
||||
}
|
||||
match p.get("layer") {
|
||||
Some(v) => v.as_u64().map(LayerId).ok_or_else(|| bad(cmd, "`layer` must be an integer layer id")),
|
||||
None if required => Err(bad(cmd, "missing `layer`")),
|
||||
None => crate::commands::layer_param(s, p),
|
||||
}
|
||||
}
|
||||
|
||||
/// Borrow the smart object on layer `id`; errors if the layer is missing or not a smart object.
|
||||
fn smart(doc: &Document, id: LayerId) -> Result<&SmartObject> {
|
||||
match &doc.layer(id).ok_or(EngineError::NoLayer(id))?.content {
|
||||
LayerContent::Smart(sm) => Ok(sm),
|
||||
_ => Err(EngineError::Other("the layer is not a smart object".into())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Borrow embedded bytes from the object or PSD table without following a linked disk path.
|
||||
fn embedded<'a>(doc: &'a Document, sm: &'a SmartObject) -> Result<(String, &'a [u8])> {
|
||||
let (name, bytes) = match &sm.source {
|
||||
SmartSource::Embedded { file_name, bytes } => (file_name.clone(), bytes.as_slice()),
|
||||
SmartSource::Linked { path } => photocraft_io::linked::embedded_payload(&doc.metadata, path)
|
||||
.ok_or_else(|| EngineError::Other("external editing requires embedded vector contents".into()))?,
|
||||
};
|
||||
validate_source(bytes)?;
|
||||
Ok((name, bytes))
|
||||
}
|
||||
|
||||
/// Reject unsupported magic bytes and oversized native-vector payloads before handoff.
|
||||
fn validate_source(bytes: &[u8]) -> Result<()> {
|
||||
if bytes.is_empty() || bytes.len() > MAX_VECTOR_SOURCE_BYTES || source_extension(bytes).is_none() {
|
||||
return Err(EngineError::Other("external editing requires SVG, PDF-compatible AI, PDF or EPS contents within 16 MiB".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Identify supported vector formats from their payload rather than a filename suffix.
|
||||
fn source_extension(bytes: &[u8]) -> Option<&'static str> {
|
||||
if photocraft_io::svg::is_svg(bytes) {
|
||||
Some("svg")
|
||||
} else if bytes.starts_with(b"%PDF-") {
|
||||
Some("pdf")
|
||||
} else if bytes.starts_with(b"%!PS-Adobe-") || bytes.starts_with(&[0xc5, 0xd0, 0xd3, 0xc6]) {
|
||||
Some("eps")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Recover the bounded original PSD source-pixel frame for the conversion viewport.
|
||||
fn source_size(sm: &SmartObject) -> Option<(f64, f64)> {
|
||||
source_size_with(sm, preview_frame)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! { static PREVIEW_FRAME_CHECKS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) }; }
|
||||
|
||||
/// Reading source geometry never rasterizes an old preview or decodes its embedded images.
|
||||
fn preview_frame(preview: &photocraft_doc::VectorPreview, source: &[u8]) -> Option<(f64, f64)> {
|
||||
if source.len() > MAX_VECTOR_SOURCE_BYTES || !photocraft_io::vector_preview::is_native_source(source) {
|
||||
return None;
|
||||
}
|
||||
preview_frame_hash(preview, *blake3::hash(source).as_bytes())
|
||||
}
|
||||
|
||||
/// Read the frame recorded in preview metadata, accepted only for `source_hash`; the caller
|
||||
/// supplies a digest it has verified against the actual source bytes.
|
||||
fn preview_frame_hash(preview: &photocraft_doc::VectorPreview, source_hash: [u8; 32]) -> Option<(f64, f64)> {
|
||||
#[cfg(test)]
|
||||
PREVIEW_FRAME_CHECKS.with(|count| count.set(count.get() + 1));
|
||||
photocraft_io::vector_preview::frame_metadata(preview, source_hash)
|
||||
}
|
||||
|
||||
/// Prefer the embedded source's preview frame via `frame`, else the PSD placed descriptor's
|
||||
/// stored size, rejected when non-finite or over 16384 px per side or [`MAX_PIXELS`].
|
||||
fn source_size_with(sm: &SmartObject, frame: impl FnOnce(&photocraft_doc::VectorPreview, &[u8]) -> Option<(f64, f64)>) -> Option<(f64, f64)> {
|
||||
if let SmartSource::Embedded { bytes, .. } = &sm.source
|
||||
&& let Some(preview) = sm.vector_preview.as_ref()
|
||||
&& let Some(frame) = frame(preview, bytes)
|
||||
{
|
||||
return Some(frame);
|
||||
}
|
||||
let placed = photocraft_io::smart_map::parse_sold(sm.psd_raw.as_ref()?.as_slice())?;
|
||||
photocraft_io::smart_map::stored_size(&placed.descriptor)
|
||||
.filter(|(w, h)| w.is_finite() && h.is_finite() && *w <= 16384.0 && *h <= 16384.0 && w * h <= MAX_PIXELS as f64)
|
||||
}
|
||||
|
||||
/// Shared by all three commands: needs an active document with no background job and the
|
||||
/// layer composite (not a mask, quick mask or single channel) as the edit target.
|
||||
fn enabled(s: &Session) -> std::result::Result<(), String> {
|
||||
let state = s.active().ok_or("no document open")?;
|
||||
if s.job_on(state.doc.id).is_some() {
|
||||
return Err("the document has a running background job".into());
|
||||
}
|
||||
if s.tools.mask_targeted
|
||||
|| state.doc.quick_mask.is_some()
|
||||
|| state.channel_view.layer_mask.is_some()
|
||||
|| state.channel_view.target != crate::channel_cmds::ChannelTarget::Composite
|
||||
{
|
||||
return Err("SVG contents editing requires the layer composite target".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Return every layer sharing `id`'s contents, which change together. Errors if those contents
|
||||
/// are open in the internal editor, any instance is fully or pixel locked, or any instance
|
||||
/// lacks valid embedded vector bytes.
|
||||
fn check_instances(s: &Session, id: LayerId) -> Result<Vec<LayerId>> {
|
||||
let doc = &s.active().ok_or(EngineError::NoDocument)?.doc;
|
||||
let contents = smart(doc, id)?.contents_id;
|
||||
if s.smart_links.iter().any(|link| link.parent == doc.id && link.contents_id == contents) {
|
||||
return Err(EngineError::Other("these smart-object contents are already open in PhotoCraft".into()));
|
||||
}
|
||||
let ids: Vec<_> =
|
||||
doc.walk().into_iter().filter_map(|(_, _, l)| matches!(&l.content, LayerContent::Smart(sm) if sm.contents_id == contents).then_some(l.id)).collect();
|
||||
for id in &ids {
|
||||
let locks = doc.effective_locks(*id);
|
||||
if locks.all || locks.pixels {
|
||||
return Err(EngineError::Other("a shared smart-object instance is locked".into()));
|
||||
}
|
||||
embedded(doc, smart(doc, *id)?)?;
|
||||
}
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
/// Export a bounded source and optimistic identity entirely in memory.
|
||||
fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = layer(s, p, EXPORT, false)?;
|
||||
check_instances(s, id)?;
|
||||
let doc = &s.active().ok_or(EngineError::NoDocument)?.doc;
|
||||
let sm = smart(doc, id)?;
|
||||
let (name, bytes) = embedded(doc, sm)?;
|
||||
let mut extension = source_extension(bytes).ok_or_else(|| bad(EXPORT, "unsupported vector source"))?;
|
||||
if extension != "svg" && name.to_ascii_lowercase().ends_with(".ai") {
|
||||
extension = "ai";
|
||||
}
|
||||
let frame = source_size(sm);
|
||||
if extension != "svg" && sm.psd_raw.is_some() && frame.is_none() {
|
||||
return Err(bad(EXPORT, "the embedded vector has no supported PSD source-pixel frame"));
|
||||
}
|
||||
let mut result = json!({"document":doc.id.0,"fingerprint":blake3::hash(bytes).to_hex().to_string(),"contentsId":sm.contents_id.0,"extension":extension,"sourceSize":frame});
|
||||
if extension == "svg" {
|
||||
result["svg"] = json!(std::str::from_utf8(bytes).map_err(|_| bad(EXPORT, "SVG contents must be UTF-8"))?);
|
||||
} else {
|
||||
result["sourceBase64"] = json!(base64::engine::general_purpose::STANDARD.encode(bytes));
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Mirror the renderer's mesh vertices before it allocates destination tiles. Without a warp,
|
||||
/// a homography has corner extrema only when its denominator keeps one nonzero sign.
|
||||
fn placed_bounds(sm: &SmartObject, size: Size) -> Result<Rect> {
|
||||
let (mut w, mut h) = (f64::from(size.width), f64::from(size.height));
|
||||
let warp = sm.warp.as_ref().filter(|warp| !warp.is_identity());
|
||||
if sm.warp.is_none() && sm.stack_mode.is_none() {
|
||||
let scale = crate::smart_cmds::vector_scale(sm);
|
||||
w = (w * scale).ceil() / scale;
|
||||
h = (h * scale).ceil() / scale;
|
||||
}
|
||||
let (nx, ny) = if warp.is_some() { (((w / 4.0).ceil() as usize).clamp(1, 512), ((h / 4.0).ceil() as usize).clamp(1, 512)) } else { (1, 1) };
|
||||
let (mut x0, mut y0, mut x1, mut y1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
|
||||
let mut sign = None;
|
||||
for j in 0..=ny {
|
||||
for i in 0..=nx {
|
||||
let (x, y) = (w * i as f64 / nx as f64, h * j as f64 / ny as f64);
|
||||
let (x, y) = warp.map_or((x, y), |warp| warp.map(x, y));
|
||||
let (x, y) = if let Some(m) = sm.perspective {
|
||||
let denominator = m[6] * x + m[7] * y + m[8];
|
||||
let positive = denominator > 0.0;
|
||||
if !denominator.is_finite() || denominator.abs() < 1e-9 || sign.is_some_and(|prev| prev != positive) {
|
||||
return Err(bad(UPDATE, "SVG placement crosses an unbounded perspective"));
|
||||
}
|
||||
sign = Some(positive);
|
||||
((m[0] * x + m[1] * y + m[2]) / denominator, (m[3] * x + m[4] * y + m[5]) / denominator)
|
||||
} else {
|
||||
let [a, b, c, d, e, f] = sm.transform.m;
|
||||
(a * x + c * y + e, b * x + d * y + f)
|
||||
};
|
||||
if !x.is_finite() || !y.is_finite() || x.abs() > 1_000_000.0 || y.abs() > 1_000_000.0 {
|
||||
return Err(bad(UPDATE, "SVG placement exceeds the supported coordinate range"));
|
||||
}
|
||||
x0 = x0.min(x);
|
||||
y0 = y0.min(y);
|
||||
x1 = x1.max(x);
|
||||
y1 = y1.max(y);
|
||||
}
|
||||
}
|
||||
// Unwarped vectors rasterise at a quantised scale. Include its rounded source-pixel
|
||||
// edges and the inverse sampler's border, as well as the mesh renderer's one-pixel border.
|
||||
let padding = if warp.is_some() { 1 } else { 4 };
|
||||
Ok(Rect::new(x0.floor() as i32 - padding, y0.floor() as i32 - padding, x1.ceil() as i32 + padding, y1.ceil() as i32 + padding))
|
||||
}
|
||||
|
||||
/// Validate identity and rendering limits before one undoable shared-content replacement.
|
||||
fn update(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
update_contents(s, p, false)
|
||||
}
|
||||
|
||||
/// Native bytes and their bounded display preview are committed in the same transaction.
|
||||
fn update_vector(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
update_contents(s, p, true)
|
||||
}
|
||||
|
||||
/// Shared SVG (`native == false`) and AI/PDF/EPS save-back with the default frame-metadata budget.
|
||||
fn update_contents(s: &mut Session, p: &Value, native: bool) -> Result<Value> {
|
||||
update_contents_with_frame_budget(s, p, native, MAX_FRAME_XML_BYTES)
|
||||
}
|
||||
|
||||
/// Bound metadata parsing separately from the destination render budget. The explicit budget
|
||||
/// also lets tests exercise exhaustion with small authored fixtures.
|
||||
fn update_contents_with_frame_budget(s: &mut Session, p: &Value, native: bool, frame_xml_bytes: usize) -> Result<Value> {
|
||||
let id = layer(s, p, UPDATE, true)?;
|
||||
let document = p.get("document").and_then(Value::as_u64).ok_or_else(|| bad(UPDATE, "`document` must be an integer document id"))?;
|
||||
if s.active().map(|state| state.doc.id.0) != Some(document) {
|
||||
return Err(bad(UPDATE, "the smart object's parent document is no longer active"));
|
||||
}
|
||||
let svg = p.get(if native { "previewSvg" } else { "svg" }).and_then(Value::as_str).ok_or_else(|| bad(UPDATE, "`svg` must be an SVG string"))?;
|
||||
let expected = p
|
||||
.get("fingerprint")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|hash| hash.len() == 64 && hash.bytes().all(|b| b.is_ascii_hexdigit()))
|
||||
.ok_or_else(|| bad(UPDATE, "`fingerprint` must be a BLAKE3 hex digest"))?;
|
||||
let contents = p.get("contentsId").and_then(Value::as_u64).map(SmartContentsId).ok_or_else(|| bad(UPDATE, "`contentsId` must be an integer"))?;
|
||||
let ids = check_instances(s, id)?;
|
||||
let doc = &s.active().ok_or(EngineError::NoDocument)?.doc;
|
||||
let target = smart(doc, id)?;
|
||||
let (name, original) = embedded(doc, target)?;
|
||||
let original_hash = blake3::hash(original);
|
||||
if target.contents_id != contents || !original_hash.to_hex().as_str().eq_ignore_ascii_case(expected) {
|
||||
return Err(bad(UPDATE, "smart-object contents changed since external editing started"));
|
||||
}
|
||||
for instance in &ids {
|
||||
let (_, bytes) = embedded(doc, smart(doc, *instance)?)?;
|
||||
// Shared instances normally borrow the same immutable payload. Its fingerprint was
|
||||
// checked above; don't re-hash a large source once per layer.
|
||||
let matches = (bytes.len() == original.len() && std::ptr::eq(bytes.as_ptr(), original.as_ptr()))
|
||||
|| blake3::hash(bytes).to_hex().as_str().eq_ignore_ascii_case(expected);
|
||||
if !matches {
|
||||
return Err(bad(UPDATE, "shared smart-object instances have conflicting contents"));
|
||||
}
|
||||
}
|
||||
let replacement_bytes = if native {
|
||||
if !photocraft_io::vector_preview::is_native_source(original) {
|
||||
return Err(bad(UPDATE_VECTOR, "the original source is not AI, PDF or EPS"));
|
||||
}
|
||||
let extension = p.get("extension").and_then(Value::as_str).ok_or_else(|| bad(UPDATE_VECTOR, "missing source extension"))?;
|
||||
let original_extension = if name.to_ascii_lowercase().ends_with(".ai") { "ai" } else { source_extension(original).unwrap_or("") };
|
||||
if extension != original_extension || !matches!(extension, "ai" | "pdf" | "eps") {
|
||||
return Err(bad(UPDATE_VECTOR, "save-back must keep the original vector format"));
|
||||
}
|
||||
let encoded = p.get("sourceBase64").and_then(Value::as_str).ok_or_else(|| bad(UPDATE_VECTOR, "missing native source bytes"))?;
|
||||
if encoded.len() > MAX_VECTOR_SOURCE_BYTES.div_ceil(3) * 4 {
|
||||
return Err(bad(UPDATE_VECTOR, "native source exceeds 16 MiB"));
|
||||
}
|
||||
let bytes = base64::engine::general_purpose::STANDARD.decode(encoded).map_err(|_| bad(UPDATE_VECTOR, "invalid native source encoding"))?;
|
||||
validate_source(&bytes)?;
|
||||
let family = source_extension(&bytes);
|
||||
if !match extension {
|
||||
"pdf" => family == Some("pdf"),
|
||||
"eps" => family == Some("eps"),
|
||||
"ai" => matches!(family, Some("pdf" | "eps")),
|
||||
_ => false,
|
||||
} {
|
||||
return Err(bad(UPDATE_VECTOR, "saved bytes do not match the original vector format"));
|
||||
}
|
||||
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 {
|
||||
return Ok(json!({"layer":id.0,"contentsId":contents.0,"fingerprint":expected,"updated":false}));
|
||||
}
|
||||
// Persisted smart filters are executable input too. A source replacement replays them
|
||||
// without Session::execute, so authorize every filter that render_in will apply, across
|
||||
// all shared instances, before parsing/rendering any replacement. Desktop sessions keep
|
||||
// their normal user-selected file access; untrusted automation must not inherit it.
|
||||
if let Some(authorize) = s.authorize {
|
||||
for instance in &ids {
|
||||
let sm = smart(doc, *instance)?;
|
||||
if sm.filters_enabled {
|
||||
for filter in sm.smart_filters.iter().filter(|filter| filter.visible) {
|
||||
authorize(&filter.command, &filter.params)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if svg.is_empty() || svg.len() > MAX_VECTOR_SOURCE_BYTES || !photocraft_io::svg::is_svg(svg.as_bytes()) {
|
||||
return Err(bad(UPDATE, "SVG contents must contain a valid SVG document within 16 MiB"));
|
||||
}
|
||||
// Save-back can intentionally remove the last drawable object. The bounded importer
|
||||
// accepts an empty drawing so deleting the final object preserves transparency.
|
||||
let (imported, _) = photocraft_io::svg::import_svg_bounded_with_work(&name, svg.as_bytes(), 4 * MAX_PIXELS)
|
||||
.map_err(|error| bad(UPDATE, format!("can't import edited SVG contents: {error}")))?;
|
||||
let size = imported.document.size;
|
||||
if native {
|
||||
// Shared instances normally borrow one immutable source and preview allocation. The
|
||||
// check is pure in those bytes and the claimed hash, so run it once per identity while
|
||||
// `doc` stays borrowed (no allocation can be freed and reused). Every instance's own
|
||||
// frame, including a differing PSD fallback, is still compared.
|
||||
let mut frames = std::collections::HashMap::new();
|
||||
let mut remaining_xml = frame_xml_bytes;
|
||||
let mut exhausted = false;
|
||||
for instance in &ids {
|
||||
let frame = source_size_with(smart(doc, *instance)?, |preview, bytes| {
|
||||
let key = (bytes.as_ptr() as usize, bytes.len(), preview.svg.as_ptr() as usize, preview.svg.len(), preview.source_hash);
|
||||
*frames.entry(key).or_insert_with(|| {
|
||||
let Some(remaining) = remaining_xml.checked_sub(preview.svg.len()) else {
|
||||
exhausted = true;
|
||||
return None;
|
||||
};
|
||||
remaining_xml = remaining;
|
||||
// Every shared source was compared with these actual bytes above. Reuse
|
||||
// their verified digest, not the preview's untrusted claimed digest.
|
||||
preview_frame_hash(preview, *original_hash.as_bytes())
|
||||
})
|
||||
});
|
||||
if exhausted {
|
||||
return Err(bad(UPDATE_VECTOR, "original vector preview metadata exceeds the 64 MiB validation budget"));
|
||||
}
|
||||
if let Some((w, h)) = frame
|
||||
&& (f64::from(size.width) != f64::from((w as f32).max(1.0).round()) || f64::from(size.height) != f64::from((h as f32).max(1.0).round()))
|
||||
{
|
||||
return Err(bad(UPDATE_VECTOR, "the saved preview must keep the original source-pixel frame"));
|
||||
}
|
||||
}
|
||||
}
|
||||
if size.area() > MAX_PIXELS {
|
||||
return Err(bad(UPDATE, "SVG contents exceed the 24 million pixel limit"));
|
||||
}
|
||||
let source = SmartSource::Embedded {
|
||||
file_name: if native || photocraft_io::svg::is_svg(original) { name } else { "Artwork.svg".into() },
|
||||
bytes: Arc::new(replacement_bytes.clone()),
|
||||
};
|
||||
let vector_preview =
|
||||
native.then(|| photocraft_doc::VectorPreview { source_hash: *blake3::hash(&replacement_bytes).as_bytes(), svg: Arc::new(svg.as_bytes().to_vec()) });
|
||||
let mut replacements: Vec<Layer> = Vec::with_capacity(ids.len());
|
||||
let mut pixels = 0u64;
|
||||
for instance in ids {
|
||||
let mut replacement = doc.layer(instance).cloned().ok_or(EngineError::NoLayer(instance))?;
|
||||
let LayerContent::Smart(sm) = &mut replacement.content else { return Err(EngineError::NoLayer(instance)) };
|
||||
let bounds = placed_bounds(sm, size)?;
|
||||
let extent = if sm.filters_enabled && sm.smart_filters.iter().any(|filter| filter.visible) { bounds.union(&doc.bounds()) } else { bounds };
|
||||
pixels = pixels.checked_add(extent.size().area()).ok_or_else(|| bad(UPDATE, "SVG placement cache budget exceeded"))?;
|
||||
if pixels > MAX_PIXELS {
|
||||
return Err(bad(UPDATE, "SVG placement caches exceed the 24 million pixel limit"));
|
||||
}
|
||||
sm.source = source.clone();
|
||||
sm.vector_preview = vector_preview.clone();
|
||||
replacements.push(replacement);
|
||||
}
|
||||
// Finish every fallible render before committing any document state.
|
||||
for replacement in &mut replacements {
|
||||
if let Some(preview) = vector_preview.as_ref() {
|
||||
// This exact SVG was fully validated above. Render only a disposable display
|
||||
// clone, avoiding a full native-payload hash for every shared instance. Only its
|
||||
// cache is copied back: committed sources, previews and placement stay native.
|
||||
let mut display = replacement.clone();
|
||||
let LayerContent::Smart(sm) = &mut display.content else { return Err(EngineError::NoLayer(display.id)) };
|
||||
sm.source = SmartSource::Embedded { file_name: "Artwork.svg".into(), bytes: preview.svg.clone() };
|
||||
sm.vector_preview = None;
|
||||
if !crate::smart_cmds::refresh_layer(doc, &mut display)? {
|
||||
return Err(bad(UPDATE, "SVG contents could not be rendered"));
|
||||
}
|
||||
let LayerContent::Smart(rendered) = display.content else { return Err(EngineError::NoLayer(display.id)) };
|
||||
let LayerContent::Smart(sm) = &mut replacement.content else { return Err(EngineError::NoLayer(replacement.id)) };
|
||||
sm.cache = rendered.cache;
|
||||
} else if !crate::smart_cmds::refresh_layer(doc, replacement)? {
|
||||
return Err(bad(UPDATE, "SVG contents could not be rendered"));
|
||||
}
|
||||
crate::smart_cmds::detach_psd(replacement);
|
||||
}
|
||||
s.edit(if native { "Update Vector Contents" } else { "Update SVG Contents" }, |doc, _| {
|
||||
for replacement in replacements {
|
||||
let id = replacement.id;
|
||||
*doc.layer_mut(id).ok_or(EngineError::NoLayer(id))? = replacement;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"layer":id.0,"contentsId":contents.0,"fingerprint":blake3::hash(&replacement_bytes).to_hex().to_string(),"updated":true}))
|
||||
}
|
||||
|
||||
/// Menu-less export/update commands the shell uses for external editor save-back; none is
|
||||
/// journaled.
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec {
|
||||
id: EXPORT,
|
||||
label: "Export SVG Contents",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r#"{"layer":id?} → {document,svg?|sourceBase64?,extension,sourceSize,fingerprint,contentsId}"#,
|
||||
enabled,
|
||||
run: export,
|
||||
journal: false,
|
||||
},
|
||||
CommandSpec {
|
||||
id: UPDATE_VECTOR,
|
||||
label: "Update Vector Contents",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r#"{"document":id,"layer":id,"sourceBase64":"native bytes","extension":"ai|pdf|eps","previewSvg":"bounded display SVG","fingerprint":"original BLAKE3 hex","contentsId":id}"#,
|
||||
enabled,
|
||||
run: update_vector,
|
||||
journal: false,
|
||||
},
|
||||
CommandSpec {
|
||||
id: UPDATE,
|
||||
label: "Update SVG Contents",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r#"{"document":id,"layer":id,"svg":"<svg …>","fingerprint":"original BLAKE3 hex","contentsId":id} → {layer,contentsId,fingerprint,updated}"#,
|
||||
enabled,
|
||||
run: update,
|
||||
journal: false,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,694 @@
|
||||
use super::*;
|
||||
use photocraft_doc::{DocId, Locks, SmartFilter};
|
||||
use photocraft_geom::Affine;
|
||||
|
||||
const RED: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="8" height="6"><rect width="8" height="6" fill="red"/></svg>"#;
|
||||
const BLUE: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="8" height="6"><rect width="8" height="6" fill="blue"/></svg>"#;
|
||||
const GREEN: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="8" height="6"><rect width="8" height="6" fill="lime"/></svg>"#;
|
||||
|
||||
fn session(depth: u32) -> (Session, LayerId) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width":32,"height":24,"depth":depth})).unwrap();
|
||||
let result = crate::file_cmds::place_bytes(&mut s, "Artwork.svg", RED.as_bytes().to_vec(), None, &json!({"smartObject":true})).unwrap();
|
||||
let id = LayerId(result["layer"].as_u64().unwrap());
|
||||
(s, id)
|
||||
}
|
||||
|
||||
fn request(s: &mut Session, id: LayerId, svg: &str) -> Value {
|
||||
let handoff = s.execute(EXPORT, json!({"layer":id.0})).unwrap();
|
||||
json!({"document":handoff["document"],"layer":id.0,"svg":svg,"fingerprint":handoff["fingerprint"],"contentsId":handoff["contentsId"]})
|
||||
}
|
||||
|
||||
fn source(s: &Session, id: LayerId) -> Vec<u8> {
|
||||
embedded(&s.active().unwrap().doc, smart(&s.active().unwrap().doc, id).unwrap()).unwrap().1.to_vec()
|
||||
}
|
||||
|
||||
fn fails_without_edit(s: &mut Session, params: Value) {
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
let revision = s.active().unwrap().revision;
|
||||
assert!(s.execute(UPDATE, params).is_err());
|
||||
assert_eq!(s.active().unwrap().doc, before);
|
||||
assert_eq!(s.active().unwrap().revision, revision);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_and_unchanged_save_have_no_history_step() {
|
||||
let (mut s, id) = session(8);
|
||||
let revision = s.active().unwrap().revision;
|
||||
let params = request(&mut s, id, RED);
|
||||
let result = s.execute(UPDATE, params).unwrap();
|
||||
assert_eq!(result["updated"], false);
|
||||
assert_eq!(s.active().unwrap().revision, revision);
|
||||
assert!(s.undo());
|
||||
assert!(s.active().unwrap().doc.layer(id).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_last_svg_object_saves_transparency_and_undo_restores_source() {
|
||||
let (mut s, id) = session(8);
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
let empty = r#"<svg xmlns="http://www.w3.org/2000/svg" width="8" height="6"/>"#;
|
||||
let params = request(&mut s, id, empty);
|
||||
s.execute(UPDATE, params).unwrap();
|
||||
assert_eq!(source(&s, id), empty.as_bytes());
|
||||
assert_eq!(smart(&s.active().unwrap().doc, id).unwrap().cache.as_ref().unwrap().pixel(15, 11), vec![0.0; 4]);
|
||||
assert!(s.undo());
|
||||
assert_eq!(s.active().unwrap().doc, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_updates_repeat_at_all_depths_and_roundtrip_original_svg() {
|
||||
for depth in [8, 16, 32] {
|
||||
let (mut s, id) = session(depth);
|
||||
let original = s.active().unwrap().doc.clone();
|
||||
let journal_len = s.journal.len();
|
||||
let params = request(&mut s, id, BLUE);
|
||||
let result = s.execute(UPDATE, params).unwrap();
|
||||
assert_eq!(result["updated"], true);
|
||||
let sm = smart(&s.active().unwrap().doc, id).unwrap();
|
||||
assert_eq!(sm.cache.as_ref().unwrap().format(), s.active().unwrap().doc.pixel_format());
|
||||
assert_eq!(sm.cache.as_ref().unwrap().pixel(15, 11), vec![0.0, 0.0, 1.0, 1.0]);
|
||||
assert_eq!(source(&s, id), BLUE.as_bytes());
|
||||
let after = s.active().unwrap().doc.clone();
|
||||
assert!(s.undo());
|
||||
assert_eq!(s.active().unwrap().doc, original);
|
||||
assert!(s.redo());
|
||||
assert_eq!(s.active().unwrap().doc, after);
|
||||
let params = request(&mut s, id, GREEN);
|
||||
s.execute(UPDATE, params).unwrap();
|
||||
assert_eq!(source(&s, id), GREEN.as_bytes());
|
||||
assert_eq!(s.journal.len(), journal_len, "vector saves must not accumulate full artwork in the replay journal");
|
||||
let bytes = photocraft_format::save_to_bytes(&s.active().unwrap().doc, &Default::default()).unwrap();
|
||||
let loaded = photocraft_format::load_from_bytes(&bytes).unwrap();
|
||||
let loaded_source = &smart(&loaded, id).unwrap().source;
|
||||
assert!(matches!(loaded_source, SmartSource::Embedded {file_name,bytes} if file_name == "Artwork.svg" && bytes.as_slice() == GREEN.as_bytes()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_instances_keep_placement_filters_masks_and_one_undo_transaction() {
|
||||
let (mut s, id) = session(8);
|
||||
let duplicate = {
|
||||
let doc = Arc::make_mut(&mut s.active_mut().unwrap().doc);
|
||||
let mut layer = doc.layer(id).unwrap().duplicate();
|
||||
let LayerContent::Smart(sm) = &mut layer.content else { panic!("smart object") };
|
||||
sm.transform = Affine::translate(2.0, 3.0);
|
||||
sm.smart_filters.push(SmartFilter {
|
||||
command: "filter.invert".into(),
|
||||
params: json!({}),
|
||||
blend: photocraft_color::BlendMode::Normal,
|
||||
opacity: 0.5,
|
||||
visible: true,
|
||||
});
|
||||
sm.filters_enabled = false;
|
||||
sm.filter_mask = Some(photocraft_doc::LayerMask {
|
||||
surface: photocraft_raster::Surface::new(photocraft_color::PixelFormat::GRAY8),
|
||||
enabled: true,
|
||||
linked: true,
|
||||
density: 0.5,
|
||||
feather: 1.0,
|
||||
});
|
||||
let duplicate = layer.id;
|
||||
doc.layers.push(layer);
|
||||
duplicate
|
||||
};
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
let preserved = smart(&before, duplicate).unwrap().clone();
|
||||
let revision = s.active().unwrap().revision;
|
||||
let params = request(&mut s, id, BLUE);
|
||||
s.execute(UPDATE, params).unwrap();
|
||||
assert_eq!(s.active().unwrap().revision, revision + 1);
|
||||
assert_eq!(source(&s, id), source(&s, duplicate));
|
||||
let edited = smart(&s.active().unwrap().doc, duplicate).unwrap();
|
||||
assert_eq!(edited.transform, preserved.transform);
|
||||
assert_eq!(edited.smart_filters, preserved.smart_filters);
|
||||
assert_eq!(edited.filter_mask, preserved.filter_mask);
|
||||
assert_eq!(edited.filters_enabled, preserved.filters_enabled);
|
||||
assert_eq!(edited.contents_id, preserved.contents_id);
|
||||
assert_eq!(edited.cache.as_ref().unwrap().pixel(4, 4), vec![0.0, 0.0, 1.0, 1.0]);
|
||||
assert!(s.undo());
|
||||
assert_eq!(s.active().unwrap().doc, before);
|
||||
assert!(s.redo());
|
||||
assert_eq!(source(&s, id), BLUE.as_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_oversized_and_external_resources_are_bounded_atomic() {
|
||||
let (mut s, id) = session(8);
|
||||
for svg in ["garbage", "<svg>", "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"99999\" height=\"99999\"><rect width=\"99999\" height=\"99999\"/></svg>"]
|
||||
{
|
||||
let params = request(&mut s, id, svg);
|
||||
fails_without_edit(&mut s, params);
|
||||
}
|
||||
let huge = " ".repeat(super::MAX_VECTOR_SOURCE_BYTES + 1);
|
||||
let params = request(&mut s, id, &huge);
|
||||
fails_without_edit(&mut s, params);
|
||||
// External images cannot trigger ambient filesystem or network access.
|
||||
let external = r#"<svg xmlns="http://www.w3.org/2000/svg" width="8" height="6"><image href="file:///secret.png" width="8" height="6"/></svg>"#;
|
||||
let params = request(&mut s, id, external);
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
match s.execute(UPDATE, params) {
|
||||
Err(_) => assert_eq!(s.active().unwrap().doc, before),
|
||||
Ok(_) => assert_eq!(smart(&s.active().unwrap().doc, id).unwrap().cache.as_ref().unwrap().pixel(15, 11), vec![0.0; 4]),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_hash_content_identity_wrong_parent_deleted_and_locked_reject() {
|
||||
let (mut s, id) = session(8);
|
||||
let params = request(&mut s, id, BLUE);
|
||||
for (key, value) in [("fingerprint", json!("0".repeat(64))), ("contentsId", json!(0)), ("document", json!(DocId::fresh().0)), ("layer", json!("wrong"))] {
|
||||
let mut bad = params.clone();
|
||||
bad[key] = value;
|
||||
fails_without_edit(&mut s, bad);
|
||||
}
|
||||
let mut missing = params.clone();
|
||||
missing.as_object_mut().unwrap().remove("document");
|
||||
fails_without_edit(&mut s, missing);
|
||||
Arc::make_mut(&mut s.active_mut().unwrap().doc).layer_mut(id).unwrap().locks = Locks { all: true, ..Default::default() };
|
||||
fails_without_edit(&mut s, params.clone());
|
||||
Arc::make_mut(&mut s.active_mut().unwrap().doc).layer_mut(id).unwrap().locks = Default::default();
|
||||
s.execute(UPDATE, params.clone()).unwrap();
|
||||
fails_without_edit(&mut s, params.clone());
|
||||
Arc::make_mut(&mut s.active_mut().unwrap().doc).remove(id).unwrap();
|
||||
fails_without_edit(&mut s, params);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linked_sources_and_internal_editor_conflicts_reject_without_reads() {
|
||||
let (mut s, id) = session(8);
|
||||
let params = request(&mut s, id, BLUE);
|
||||
let parent = s.active().unwrap().doc.id;
|
||||
let contents_id = smart(&s.active().unwrap().doc, id).unwrap().contents_id;
|
||||
s.smart_links.push(crate::smart_cmds::SmartLink { child: DocId::fresh(), parent, layer: id, contents_id });
|
||||
fails_without_edit(&mut s, params.clone());
|
||||
assert!(s.execute(EXPORT, json!({"layer":id.0})).is_err());
|
||||
s.smart_links.clear();
|
||||
let doc = Arc::make_mut(&mut s.active_mut().unwrap().doc);
|
||||
let LayerContent::Smart(sm) = &mut doc.layer_mut(id).unwrap().content else { panic!("smart object") };
|
||||
sm.source = SmartSource::Linked { path: "C:/not-authorized/source.svg".into() };
|
||||
fails_without_edit(&mut s, params);
|
||||
assert!(s.execute(EXPORT, json!({"layer":id.0})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inherited_shared_instance_lock_and_unbounded_placement_reject() {
|
||||
let (mut s, id) = session(8);
|
||||
let params = request(&mut s, id, BLUE);
|
||||
{
|
||||
let doc = Arc::make_mut(&mut s.active_mut().unwrap().doc);
|
||||
let mut duplicate = doc.layer(id).unwrap().duplicate();
|
||||
duplicate.locks.pixels = true;
|
||||
doc.layers.push(duplicate);
|
||||
}
|
||||
fails_without_edit(&mut s, params.clone());
|
||||
{
|
||||
let doc = Arc::make_mut(&mut s.active_mut().unwrap().doc);
|
||||
doc.layers.pop();
|
||||
let LayerContent::Smart(sm) = &mut doc.layer_mut(id).unwrap().content else { panic!("smart object") };
|
||||
sm.transform = Affine { m: [10000.0, 0.0, 0.0, 10000.0, 0.0, 0.0] };
|
||||
}
|
||||
fails_without_edit(&mut s, params);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diverged_shared_source_is_never_overwritten() {
|
||||
let (mut s, id) = session(8);
|
||||
let params = request(&mut s, id, BLUE);
|
||||
let doc = Arc::make_mut(&mut s.active_mut().unwrap().doc);
|
||||
let mut duplicate = doc.layer(id).unwrap().duplicate();
|
||||
let LayerContent::Smart(sm) = &mut duplicate.content else { panic!("smart object") };
|
||||
sm.source = SmartSource::Embedded { file_name: "Other.svg".into(), bytes: Arc::new(GREEN.as_bytes().to_vec()) };
|
||||
doc.layers.push(duplicate);
|
||||
fails_without_edit(&mut s, params);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_second_instance_render_does_not_commit_first_instance() {
|
||||
let (mut s, id) = session(8);
|
||||
{
|
||||
let doc = Arc::make_mut(&mut s.active_mut().unwrap().doc);
|
||||
let mut duplicate = doc.layer(id).unwrap().duplicate();
|
||||
let LayerContent::Smart(sm) = &mut duplicate.content else { panic!("smart object") };
|
||||
sm.transform = Affine { m: [100.0, 0.0, 0.0, 0.001, 0.0, 0.0] };
|
||||
doc.layers.push(duplicate);
|
||||
let LayerContent::Smart(sm) = &mut doc.layer_mut(id).unwrap().content else { panic!("smart object") };
|
||||
sm.transform = Affine { m: [0.001, 0.0, 0.0, 0.001, 0.0, 0.0] };
|
||||
}
|
||||
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="2000" height="2000"><rect width="2000" height="2000" fill="blue"/></svg>"#;
|
||||
let params = request(&mut s, id, svg);
|
||||
fails_without_edit(&mut s, params);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inherited_parent_lock_refuses_content_edits() {
|
||||
let (mut s, id) = session(8);
|
||||
let params = request(&mut s, id, BLUE);
|
||||
let doc = Arc::make_mut(&mut s.active_mut().unwrap().doc);
|
||||
let layer = doc.remove(id).unwrap();
|
||||
let mut group =
|
||||
Layer::new("Locked parent", LayerContent::Group(photocraft_doc::Group { children: vec![layer], expanded: true, artboard: None, frame_stroke: None }));
|
||||
group.locks.pixels = true;
|
||||
doc.layers.push(group);
|
||||
fails_without_edit(&mut s, params);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn perspective_and_warp_geometry_survive_an_update() {
|
||||
for warp in [false, true] {
|
||||
let (mut s, id) = session(8);
|
||||
let doc = Arc::make_mut(&mut s.active_mut().unwrap().doc);
|
||||
let LayerContent::Smart(sm) = &mut doc.layer_mut(id).unwrap().content else { panic!("smart object") };
|
||||
sm.perspective = Some([1.0, 0.0, 12.0, 0.0, 1.0, 9.0, 0.01, 0.0, 1.0]);
|
||||
if warp {
|
||||
sm.warp = Some(photocraft_geom::warp::Warp::preset(photocraft_geom::warp::WarpStyle::Arc, 10.0, [0.0, 0.0, 8.0, 6.0]));
|
||||
}
|
||||
let before = sm.clone();
|
||||
let params = request(&mut s, id, BLUE);
|
||||
s.execute(UPDATE, params).unwrap();
|
||||
let after = smart(&s.active().unwrap().doc, id).unwrap();
|
||||
assert_eq!(after.warp, before.warp);
|
||||
assert_eq!(after.perspective, before.perspective);
|
||||
assert_eq!(after.transform, before.transform);
|
||||
assert_eq!(source(&s, id), BLUE.as_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn perspective_crossing_zero_is_rejected_before_rendering() {
|
||||
let (mut s, id) = session(8);
|
||||
let params = request(&mut s, id, BLUE);
|
||||
let doc = Arc::make_mut(&mut s.active_mut().unwrap().doc);
|
||||
let LayerContent::Smart(sm) = &mut doc.layer_mut(id).unwrap().content else { panic!("smart object") };
|
||||
sm.perspective = Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, -0.5, 0.0, 1.0]);
|
||||
fails_without_edit(&mut s, params);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_params_are_errors_for_both_commands() {
|
||||
let (mut s, _) = session(8);
|
||||
for p in [Value::Null, json!([]), json!({"layer":-1}), json!({"layer":"invalid"})] {
|
||||
assert!(s.execute(EXPORT, p.clone()).is_err());
|
||||
fails_without_edit(&mut s, p);
|
||||
}
|
||||
fails_without_edit(&mut s, json!({}));
|
||||
}
|
||||
|
||||
/// Embedded PSD link-table vectors are exported in memory, then atomically replaced by SVG.
|
||||
/// The original payload and shared identities survive undo and native-format save/reload.
|
||||
#[test]
|
||||
fn embedded_pdf_ai_and_eps_update_shared_instances_without_reading_linked_paths() {
|
||||
for depth in [8, 16, 32] {
|
||||
for (name, bytes) in [("Logo.ai", b"%PDF-1.6\nowned synthetic payload".as_slice()), ("Logo.eps", b"%!PS-Adobe-3.0 EPSF-3.0\n".as_slice())] {
|
||||
let (mut s, id) = session(depth);
|
||||
let duplicate;
|
||||
{
|
||||
let doc = Arc::make_mut(&mut s.active_mut().unwrap().doc);
|
||||
let payload = photocraft_io::linked::encode_linked_file(&photocraft_io::linked::LinkedFile {
|
||||
uuid: "owned-vector".into(),
|
||||
file_name: name.into(),
|
||||
bytes: bytes.to_vec(),
|
||||
});
|
||||
doc.metadata.psd_global_blocks.push((*b"8BIM", *b"lnk2", Arc::new(payload)));
|
||||
let LayerContent::Smart(sm) = &mut doc.layer_mut(id).unwrap().content else { panic!("smart") };
|
||||
sm.source = SmartSource::Linked { path: "owned-vector".into() };
|
||||
let layer = doc.layer(id).unwrap().duplicate();
|
||||
duplicate = layer.id;
|
||||
doc.layers.push(layer);
|
||||
}
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
let exported = s.execute(EXPORT, json!({"layer":id.0})).unwrap();
|
||||
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);
|
||||
// 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"]});
|
||||
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), 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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vector_handoff_never_reads_a_linked_disk_path_or_accepts_disguised_raster() {
|
||||
let (mut s, id) = session(8);
|
||||
for source in
|
||||
[SmartSource::Linked { path: "/dev/zero".into() }, SmartSource::Embedded { file_name: "Logo.ai".into(), bytes: Arc::new(b"not a vector".to_vec()) }]
|
||||
{
|
||||
let doc = Arc::make_mut(&mut s.active_mut().unwrap().doc);
|
||||
let LayerContent::Smart(sm) = &mut doc.layer_mut(id).unwrap().content else { panic!("smart") };
|
||||
sm.source = source;
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
assert!(s.execute(EXPORT, json!({"layer":id.0})).is_err());
|
||||
assert_eq!(s.active().unwrap().doc, before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quick_mask_blocks_source_updates_without_mutating_the_document() {
|
||||
let (mut s, id) = session(8);
|
||||
let params = request(&mut s, id, BLUE);
|
||||
s.execute("select.editInQuickMaskMode", json!({})).unwrap();
|
||||
fails_without_edit(&mut s, params);
|
||||
}
|
||||
|
||||
fn native_session(extension: &str, depth: u32) -> (Session, LayerId, Vec<u8>) {
|
||||
let (mut s, id) = session(depth);
|
||||
let bytes = if extension == "eps" { b"%!PS-Adobe-3.0 EPSF-3.0\noriginal".to_vec() } else { b"%PDF-1.7\noriginal".to_vec() };
|
||||
let doc = Arc::make_mut(&mut s.active_mut().unwrap().doc);
|
||||
let LayerContent::Smart(sm) = &mut doc.layer_mut(id).unwrap().content else { panic!("smart") };
|
||||
sm.source = SmartSource::Embedded { file_name: format!("Logo.{extension}"), bytes: Arc::new(bytes.clone()) };
|
||||
(s, id, bytes)
|
||||
}
|
||||
|
||||
fn native_request(s: &mut Session, id: LayerId, ext: &str, bytes: &[u8]) -> Value {
|
||||
let handoff = s.execute(EXPORT, json!({"layer":id.0})).unwrap();
|
||||
json!({"document":handoff["document"],"layer":id.0,"contentsId":handoff["contentsId"],"fingerprint":handoff["fingerprint"],"sourceBase64":base64::engine::general_purpose::STANDARD.encode(bytes),"extension":ext,"previewSvg":BLUE})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn original_vector_formats_save_atomically_and_survive_undo_and_persistence() {
|
||||
for ext in ["ai", "pdf", "eps"] {
|
||||
for depth in [8, 16, 32] {
|
||||
let (mut s, id, original) = native_session(ext, depth);
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
let changed = [original.as_slice(), b" edited"].concat();
|
||||
let params = native_request(&mut s, id, ext, &changed);
|
||||
assert_eq!(s.execute(UPDATE_VECTOR, params.clone()).unwrap()["updated"], true);
|
||||
let after = s.active().unwrap().doc.clone();
|
||||
let sm = smart(&after, id).unwrap();
|
||||
assert!(matches!(&sm.source,SmartSource::Embedded {file_name,bytes} if file_name == &format!("Logo.{ext}") && bytes.as_slice() == changed));
|
||||
assert_eq!(sm.vector_preview.as_ref().unwrap().source_hash, *blake3::hash(&changed).as_bytes());
|
||||
assert_eq!(sm.cache.as_ref().unwrap().pixel(15, 11), vec![0.0, 0.0, 1.0, 1.0]);
|
||||
assert!(s.undo());
|
||||
assert_eq!(s.active().unwrap().doc, before);
|
||||
assert!(s.redo());
|
||||
assert_eq!(s.active().unwrap().doc, after);
|
||||
let bundle = photocraft_format::save_to_bytes(&after, &Default::default()).unwrap();
|
||||
let reopened = photocraft_format::load_from_bytes(&bundle).unwrap();
|
||||
assert_eq!(smart(&reopened, id).unwrap(), sm);
|
||||
let mut repeated = native_request(&mut s, id, ext, &changed);
|
||||
let revision = s.active().unwrap().revision;
|
||||
assert_eq!(s.execute(UPDATE_VECTOR, repeated.clone()).unwrap()["updated"], false);
|
||||
assert_eq!(s.active().unwrap().revision, revision);
|
||||
repeated["sourceBase64"] = json!(base64::engine::general_purpose::STANDARD.encode([changed.as_slice(), b" twice"].concat()));
|
||||
s.execute(UPDATE_VECTOR, repeated).unwrap();
|
||||
assert!(s.execute(UPDATE_VECTOR, params).is_err(), "stale session must not overwrite later changes");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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);
|
||||
let changed = [original.as_slice(), b" edited"].concat();
|
||||
let valid = native_request(&mut s, id, "ai", &changed);
|
||||
for (key, value) in [
|
||||
("extension", json!("svg")),
|
||||
("sourceBase64", json!("bad base64!")),
|
||||
("sourceBase64", json!(base64::engine::general_purpose::STANDARD.encode(RED))),
|
||||
("previewSvg", json!("not an SVG")),
|
||||
("previewSvg", json!(r#"<svg xmlns="http://www.w3.org/2000/svg" width="1000000" height="1000000"/>"#)),
|
||||
("document", json!(0)),
|
||||
] {
|
||||
let mut params = valid.clone();
|
||||
params[key] = value;
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
assert!(s.execute(UPDATE_VECTOR, params).is_err());
|
||||
assert_eq!(s.active().unwrap().doc, before);
|
||||
}
|
||||
Arc::make_mut(&mut s.active_mut().unwrap().doc).layer_mut(id).unwrap().locks.all = true;
|
||||
assert!(s.execute(UPDATE_VECTOR, valid.clone()).is_err());
|
||||
Arc::make_mut(&mut s.active_mut().unwrap().doc).layer_mut(id).unwrap().locks.all = false;
|
||||
let mut duplicate = s.active().unwrap().doc.layer(id).unwrap().duplicate();
|
||||
let LayerContent::Smart(sm) = &mut duplicate.content else { panic!("smart") };
|
||||
sm.transform = Affine::translate(2.0, 3.0);
|
||||
let duplicate_id = Arc::make_mut(&mut s.active_mut().unwrap().doc).insert_above(Some(id), duplicate);
|
||||
s.execute(UPDATE_VECTOR, valid).unwrap();
|
||||
assert_eq!(source(&s, duplicate_id), changed);
|
||||
assert!(
|
||||
Arc::ptr_eq(
|
||||
&smart(&s.active().unwrap().doc, id).unwrap().vector_preview.as_ref().unwrap().svg,
|
||||
&smart(&s.active().unwrap().doc, duplicate_id).unwrap().vector_preview.as_ref().unwrap().svg
|
||||
),
|
||||
"shared instances must share the preview allocation"
|
||||
);
|
||||
assert_eq!(smart(&s.active().unwrap().doc, duplicate_id).unwrap().transform, Affine::translate(2.0, 3.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_save_keeps_fractional_psd_frame_and_rejects_a_resized_preview() {
|
||||
let (mut s, id, original) = native_session("ai", 8);
|
||||
let spec = photocraft_io::smart_map::PlacedSpec {
|
||||
idnt: "authored",
|
||||
placed: "authored",
|
||||
transform: Affine::IDENTITY,
|
||||
perspective: None,
|
||||
size: (8.3, 6.2),
|
||||
dpi: 72.0,
|
||||
warp: None,
|
||||
filter_fx: None,
|
||||
};
|
||||
let sold = photocraft_io::smart_map::sold_bytes(None, &spec, &mut vec![]);
|
||||
let state = s.active_mut().unwrap();
|
||||
let LayerContent::Smart(sm) = &mut Arc::make_mut(&mut state.doc).layer_mut(id).unwrap().content else { panic!("smart") };
|
||||
sm.psd_raw = Some(Arc::new(sold));
|
||||
let changed = [original.as_slice(), b" changed"].concat();
|
||||
let mut params = native_request(&mut s, id, "ai", &changed);
|
||||
params["previewSvg"] = json!(BLUE.replace("width=\"8\" height=\"6\"", "width=\"8.3\" height=\"6.2\""));
|
||||
s.execute(UPDATE_VECTOR, params).unwrap();
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
let mut params = native_request(&mut s, id, "ai", &[changed.as_slice(), b" next"].concat());
|
||||
params["previewSvg"] = json!(BLUE.replace("width=\"8\" height=\"6\"", "width=\"10\" height=\"6\""));
|
||||
assert!(s.execute(UPDATE_VECTOR, params).is_err());
|
||||
assert_eq!(s.active().unwrap().doc, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_native_instances_check_one_preview_identity_once_and_keep_per_instance_frames() {
|
||||
let (mut s, id, original) = native_session("ai", 8);
|
||||
let changed = [original.as_slice(), b" changed"].concat();
|
||||
let params = native_request(&mut s, id, "ai", &changed);
|
||||
s.execute(UPDATE_VECTOR, params).unwrap();
|
||||
// 40 shared instances borrow one source and preview allocation; one more has an equal
|
||||
// preview in its own allocation, so it is a separate identity and checked separately.
|
||||
let template = s.active().unwrap().doc.layer(id).unwrap().clone();
|
||||
let doc = Arc::make_mut(&mut s.active_mut().unwrap().doc);
|
||||
for _ in 0..40 {
|
||||
doc.insert_above(Some(id), template.duplicate());
|
||||
}
|
||||
let mut copied = template.duplicate();
|
||||
let LayerContent::Smart(sm) = &mut copied.content else { panic!("smart") };
|
||||
let preview = sm.vector_preview.as_mut().unwrap();
|
||||
preview.svg = Arc::new(preview.svg.as_ref().clone());
|
||||
doc.insert_above(Some(id), copied);
|
||||
let next = [changed.as_slice(), b" next"].concat();
|
||||
let params = native_request(&mut s, id, "ai", &next);
|
||||
let before = PREVIEW_FRAME_CHECKS.with(std::cell::Cell::get);
|
||||
s.execute(UPDATE_VECTOR, params).unwrap();
|
||||
assert_eq!(PREVIEW_FRAME_CHECKS.with(std::cell::Cell::get) - before, 2, "one check per source and preview identity");
|
||||
// An instance without a preview still validates its own PSD frame.
|
||||
let spec = photocraft_io::smart_map::PlacedSpec {
|
||||
idnt: "authored",
|
||||
placed: "authored",
|
||||
transform: Affine::IDENTITY,
|
||||
perspective: None,
|
||||
size: (10.0, 6.0),
|
||||
dpi: 72.0,
|
||||
warp: None,
|
||||
filter_fx: None,
|
||||
};
|
||||
let sold = photocraft_io::smart_map::sold_bytes(None, &spec, &mut vec![]);
|
||||
let mut framed = s.active().unwrap().doc.layer(id).unwrap().duplicate();
|
||||
let LayerContent::Smart(sm) = &mut framed.content else { panic!("smart") };
|
||||
sm.vector_preview = None;
|
||||
sm.psd_raw = Some(Arc::new(sold));
|
||||
Arc::make_mut(&mut s.active_mut().unwrap().doc).insert_above(Some(id), framed);
|
||||
let params = native_request(&mut s, id, "ai", &[next.as_slice(), b" again"].concat());
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
let error = s.execute(UPDATE_VECTOR, params).unwrap_err().to_string();
|
||||
assert!(error.contains("source-pixel frame"), "a differing PSD frame on one shared instance must reject: {error}");
|
||||
assert_eq!(s.active().unwrap().doc, before);
|
||||
}
|
||||
|
||||
/// Distinct old previews share a byte budget even when destination surfaces are tiny.
|
||||
#[test]
|
||||
fn distinct_native_preview_metadata_exhausts_budget_without_editing_any_instance() {
|
||||
let (mut s, id, original) = native_session("ai", 8);
|
||||
let first_save = [original.as_slice(), b" first"].concat();
|
||||
let params = native_request(&mut s, id, "ai", &first_save);
|
||||
s.execute(UPDATE_VECTOR, params).unwrap();
|
||||
let template = s.active().unwrap().doc.layer(id).unwrap().clone();
|
||||
let doc = Arc::make_mut(&mut s.active_mut().unwrap().doc);
|
||||
let mut preview_length = 0;
|
||||
for n in 0..3 {
|
||||
let mut instance = template.duplicate();
|
||||
let LayerContent::Smart(sm) = &mut instance.content else { panic!("smart") };
|
||||
let preview = sm.vector_preview.as_mut().unwrap();
|
||||
let svg = RED.replace("</svg>", &format!("<!--{n}--></svg>"));
|
||||
preview_length = svg.len();
|
||||
preview.svg = Arc::new(svg.into_bytes());
|
||||
if n == 0 {
|
||||
let layer = doc.layer_mut(id).unwrap();
|
||||
layer.content = instance.content;
|
||||
} else {
|
||||
doc.insert_above(Some(id), instance);
|
||||
}
|
||||
}
|
||||
let next = [first_save.as_slice(), b" next"].concat();
|
||||
let params = native_request(&mut s, id, "ai", &next);
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
let revision = s.active().unwrap().revision;
|
||||
let checks = PREVIEW_FRAME_CHECKS.with(std::cell::Cell::get);
|
||||
let error = update_contents_with_frame_budget(&mut s, ¶ms, true, preview_length * 2).unwrap_err().to_string();
|
||||
assert!(error.contains("validation budget"), "{error}");
|
||||
assert_eq!(PREVIEW_FRAME_CHECKS.with(std::cell::Cell::get) - checks, 2, "exhausted metadata is never parsed");
|
||||
assert_eq!(s.active().unwrap().doc, before);
|
||||
assert_eq!(s.active().unwrap().revision, revision);
|
||||
assert_eq!(s.execute(UPDATE_VECTOR, params).unwrap()["updated"], true);
|
||||
assert!(s.undo());
|
||||
assert_eq!(s.active().unwrap().doc, before);
|
||||
}
|
||||
|
||||
/// A large opaque native payload is shared data, not work repeated for every display cache.
|
||||
#[test]
|
||||
fn native_save_refreshes_shared_caches_without_rehashing_the_native_payload_per_instance() {
|
||||
let (mut s, id, original) = native_session("eps", 8);
|
||||
let template = s.active().unwrap().doc.layer(id).unwrap().clone();
|
||||
let doc = Arc::make_mut(&mut s.active_mut().unwrap().doc);
|
||||
for _ in 1..100 {
|
||||
doc.insert_above(Some(id), template.duplicate());
|
||||
}
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
let mut changed = vec![b' '; MAX_VECTOR_SOURCE_BYTES];
|
||||
changed[..original.len()].copy_from_slice(&original);
|
||||
let params = native_request(&mut s, id, "eps", &changed);
|
||||
let hashed = crate::smart_cmds::DISPLAY_NATIVE_HASH_BYTES.with(std::cell::Cell::get);
|
||||
assert_eq!(s.execute(UPDATE_VECTOR, params).unwrap()["updated"], true);
|
||||
assert_eq!(crate::smart_cmds::DISPLAY_NATIVE_HASH_BYTES.with(std::cell::Cell::get) - hashed, 0, "cache refresh must not hash 16 MiB for every copy");
|
||||
let after = s.active().unwrap().doc.clone();
|
||||
let target = smart(&after, id).unwrap();
|
||||
let SmartSource::Embedded { file_name, bytes } = &target.source else { panic!("native source lost") };
|
||||
assert_eq!(file_name, "Logo.eps");
|
||||
assert_eq!(bytes.as_slice(), changed.as_slice());
|
||||
assert_eq!(target.vector_preview.as_ref().unwrap().source_hash, *blake3::hash(&changed).as_bytes());
|
||||
for (_, _, layer) in after.walk() {
|
||||
let LayerContent::Smart(sm) = &layer.content else { continue };
|
||||
let SmartSource::Embedded { bytes: shared, .. } = &sm.source else { panic!("native source lost") };
|
||||
assert!(Arc::ptr_eq(shared, bytes));
|
||||
assert_eq!(sm.contents_id, target.contents_id);
|
||||
assert_eq!(sm.cache.as_ref().unwrap().pixel(15, 11), vec![0.0, 0.0, 1.0, 1.0]);
|
||||
let old = smart(&before, layer.id).unwrap();
|
||||
assert_eq!(sm.transform, old.transform);
|
||||
assert_eq!(sm.smart_filters, old.smart_filters);
|
||||
assert_eq!(sm.filter_mask, old.filter_mask);
|
||||
}
|
||||
assert!(s.undo());
|
||||
assert_eq!(s.active().unwrap().doc, before);
|
||||
assert!(s.redo());
|
||||
assert_eq!(s.active().unwrap().doc, after);
|
||||
}
|
||||
|
||||
/// Explicit refreshes and warp frames of one shared native source verify its preview hash once,
|
||||
/// not once per call; a replaced source is verified again and a forged preview still fails.
|
||||
#[test]
|
||||
fn native_preview_digest_is_reused_per_source_allocation_and_rechecked_after_replacement() {
|
||||
let (mut s, id, original) = native_session("eps", 8);
|
||||
let mut changed = vec![b' '; 1024 * 1024];
|
||||
changed[..original.len()].copy_from_slice(&original);
|
||||
let params = native_request(&mut s, id, "eps", &changed);
|
||||
s.execute(UPDATE_VECTOR, params).unwrap();
|
||||
let doc = s.active().unwrap().doc.clone();
|
||||
let mut sm = smart(&doc, id).unwrap().clone();
|
||||
// A fresh allocation of the same bytes: nothing about it can be remembered yet.
|
||||
sm.source = SmartSource::Embedded { file_name: "Logo.eps".into(), bytes: Arc::new(changed.clone()) };
|
||||
let hashed = || crate::smart_cmds::DISPLAY_NATIVE_HASH_BYTES.with(std::cell::Cell::get);
|
||||
let start = hashed();
|
||||
let frame = crate::smart_cmds::source_box(&doc, &sm).unwrap();
|
||||
for _ in 0..3 {
|
||||
assert_eq!(crate::smart_cmds::source_box(&doc, &sm), Some(frame));
|
||||
assert_eq!(crate::smart_cmds::render(&doc, &sm).unwrap().unwrap().pixel(15, 11), vec![0.0, 0.0, 1.0, 1.0], "the verified preview is drawn");
|
||||
}
|
||||
assert_eq!(hashed() - start, changed.len(), "one verification for one source allocation");
|
||||
// Different bytes in a new allocation never inherit the verified digest.
|
||||
let mut other = changed.clone();
|
||||
other[original.len()] = b'%';
|
||||
sm.source = SmartSource::Embedded { file_name: "Logo.eps".into(), bytes: Arc::new(other) };
|
||||
let before = hashed();
|
||||
assert!(crate::smart_cmds::render(&doc, &sm).is_err(), "a stale preview must not draw a different source");
|
||||
assert_eq!(hashed() - before, changed.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_source_replacement_never_uses_a_forged_or_stale_preview() {
|
||||
let (mut s, id, original) = native_session("ai", 8);
|
||||
let changed = [original.as_slice(), b" changed"].concat();
|
||||
let params = native_request(&mut s, id, "ai", &changed);
|
||||
s.execute(UPDATE_VECTOR, params).unwrap();
|
||||
let mut sm = smart(&s.active().unwrap().doc, id).unwrap().clone();
|
||||
sm.vector_preview.as_mut().unwrap().svg = Arc::new(b"not SVG".to_vec());
|
||||
assert!(crate::smart_cmds::render(&s.active().unwrap().doc, &sm).is_err());
|
||||
sm.vector_preview.as_mut().unwrap().svg = Arc::new(BLUE.as_bytes().to_vec());
|
||||
sm.source = SmartSource::Embedded { file_name: "Changed.ai".into(), bytes: Arc::new(b"%PDF-1.7\nnot the same source".to_vec()) };
|
||||
assert!(crate::smart_cmds::render(&s.active().unwrap().doc, &sm).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_update_authorizes_filters_on_every_shared_instance_before_editing() {
|
||||
let (mut s, id, original) = native_session("ai", 8);
|
||||
let mut duplicate = s.active().unwrap().doc.layer(id).unwrap().duplicate();
|
||||
let LayerContent::Smart(sm) = &mut duplicate.content else { panic!("smart") };
|
||||
sm.smart_filters.push(SmartFilter {
|
||||
command: "image.adjustments.colorLookup".into(),
|
||||
params: json!({"file":"/not-authorized/private.cube"}),
|
||||
blend: photocraft_color::BlendMode::Normal,
|
||||
opacity: 1.0,
|
||||
visible: true,
|
||||
});
|
||||
Arc::make_mut(&mut s.active_mut().unwrap().doc).insert_above(Some(id), duplicate);
|
||||
let params = native_request(&mut s, id, "ai", &[original.as_slice(), b" changed"].concat());
|
||||
s.authorize =
|
||||
Some(|command, _| if command == "image.adjustments.colorLookup" { Err(EngineError::Other("denied stored native filter".into())) } else { Ok(()) });
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
assert!(s.execute(UPDATE_VECTOR, params).unwrap_err().to_string().contains("denied stored native filter"));
|
||||
assert_eq!(s.active().unwrap().doc, before);
|
||||
}
|
||||
@@ -75,7 +75,7 @@ impl Coverage {
|
||||
fn holds(&self, x: f64, y: f64) -> bool {
|
||||
let Some(surf) = &self.held else { return false };
|
||||
let format = surf.format();
|
||||
if !format.alpha || !(x.is_finite() && y.is_finite()) {
|
||||
if !(format.alpha && x.is_finite() && y.is_finite()) {
|
||||
return false;
|
||||
}
|
||||
let a = surf.channels().saturating_sub(1);
|
||||
|
||||
@@ -363,6 +363,7 @@ fn smart_object_records_smart_filter() {
|
||||
smart_filters: vec![],
|
||||
cache: Some(cache),
|
||||
psd_raw: None,
|
||||
vector_preview: None,
|
||||
filters_enabled: true,
|
||||
filter_mask: None,
|
||||
warp: None,
|
||||
|
||||
@@ -138,6 +138,7 @@ pub use cmd::select_extra_cmds;
|
||||
pub use cmd::selection_cmds;
|
||||
pub use cmd::slice_cmds;
|
||||
pub use cmd::smart_cmds;
|
||||
pub use cmd::smart_svg_cmds;
|
||||
pub use cmd::smartselect_cmds;
|
||||
pub use cmd::solid_fill_cmds;
|
||||
pub use cmd::stamp_cmds;
|
||||
|
||||
@@ -13,7 +13,7 @@ use photocraft_raster::{Surface, Tile, decode_pixel, encode_pixel};
|
||||
|
||||
use crate::manifest::{
|
||||
ChannelM, CompAppearanceM, CompStateM, ContentM, DocM, EffectsM, FillCacheM, Hash, LayerCompM, LayerM, MaskM, MetadataM, NamedPathM, PatternM,
|
||||
SmartSourceM, SurfaceM, TileRef,
|
||||
SmartSourceM, SurfaceM, TileRef, VectorPreviewM,
|
||||
};
|
||||
use crate::{FormatError, Result};
|
||||
|
||||
@@ -45,6 +45,8 @@ pub(crate) trait Fetch {
|
||||
/// Canonical (little-endian) tile bytes of the given expected length.
|
||||
fn tile(&mut self, hash: &str, len: usize) -> Result<Vec<u8>>;
|
||||
fn blob(&mut self, hash: &str) -> Result<Arc<Vec<u8>>>;
|
||||
/// Fetch an optional display payload without allocating beyond its stricter limit.
|
||||
fn blob_bounded(&mut self, hash: &str, limit: usize) -> Result<Arc<Vec<u8>>>;
|
||||
}
|
||||
|
||||
pub(crate) fn hex(bytes: &[u8]) -> String {
|
||||
@@ -167,6 +169,7 @@ fn layer_m(l: &Layer, sink: &mut dyn Sink) -> LayerM {
|
||||
SmartSource::Embedded { file_name, bytes } => SmartSourceM::Embedded { file_name: file_name.clone(), blob: sink.blob(bytes) },
|
||||
SmartSource::Linked { path } => SmartSourceM::Linked { path: path.clone() },
|
||||
},
|
||||
vector_preview: s.vector_preview.as_ref().map(|preview| VectorPreviewM { source_hash: preview.source_hash, svg: sink.blob(&preview.svg) }),
|
||||
transform: s.transform,
|
||||
smart_filters: s.smart_filters.clone(),
|
||||
cache: s.cache.as_ref().map(|c| surface_m(c, sink)),
|
||||
@@ -487,33 +490,52 @@ impl Loader<'_> {
|
||||
density: *density,
|
||||
feather: *feather,
|
||||
}),
|
||||
ContentM::Smart { contents_id, source, transform, smart_filters, cache, psd_raw, filters_enabled, filter_mask, warp, stack_mode, perspective } => {
|
||||
LayerContent::Smart(SmartObject {
|
||||
contents_id: self.contents_id(*contents_id, source),
|
||||
source: match source {
|
||||
SmartSourceM::Embedded { file_name, blob } => SmartSource::Embedded { file_name: file_name.clone(), bytes: self.fetch.blob(blob)? },
|
||||
SmartSourceM::Linked { path } => SmartSource::Linked { path: path.clone() },
|
||||
},
|
||||
transform: *transform,
|
||||
smart_filters: smart_filters.clone(),
|
||||
cache: self.opt_surface(cache)?,
|
||||
psd_raw: self.opt_blob(psd_raw)?,
|
||||
filters_enabled: *filters_enabled,
|
||||
filter_mask: match filter_mask {
|
||||
Some(mm) => Some(LayerMask {
|
||||
surface: self.surface(&mm.surface)?,
|
||||
enabled: mm.enabled,
|
||||
linked: mm.linked,
|
||||
density: mm.density,
|
||||
feather: mm.feather,
|
||||
}),
|
||||
None => None,
|
||||
},
|
||||
warp: warp.clone().filter(|w| w.mesh.as_ref().is_none_or(|m| m.is_valid())),
|
||||
stack_mode: *stack_mode,
|
||||
perspective: perspective.filter(|p| p.iter().all(|v| v.is_finite())),
|
||||
})
|
||||
}
|
||||
ContentM::Smart {
|
||||
contents_id,
|
||||
source,
|
||||
vector_preview,
|
||||
transform,
|
||||
smart_filters,
|
||||
cache,
|
||||
psd_raw,
|
||||
filters_enabled,
|
||||
filter_mask,
|
||||
warp,
|
||||
stack_mode,
|
||||
perspective,
|
||||
} => LayerContent::Smart(SmartObject {
|
||||
contents_id: self.contents_id(*contents_id, source),
|
||||
source: match source {
|
||||
SmartSourceM::Embedded { file_name, blob } => SmartSource::Embedded { file_name: file_name.clone(), bytes: self.fetch.blob(blob)? },
|
||||
SmartSourceM::Linked { path } => SmartSource::Linked { path: path.clone() },
|
||||
},
|
||||
vector_preview: match vector_preview {
|
||||
// A display-only preview must not prevent recovery of the native source
|
||||
// and cached pixels if its optional blob is missing or damaged.
|
||||
Some(preview) => {
|
||||
self.fetch.blob_bounded(&preview.svg, 16 << 20).ok().map(|svg| photocraft_doc::VectorPreview { source_hash: preview.source_hash, svg })
|
||||
}
|
||||
None => None,
|
||||
},
|
||||
transform: *transform,
|
||||
smart_filters: smart_filters.clone(),
|
||||
cache: self.opt_surface(cache)?,
|
||||
psd_raw: self.opt_blob(psd_raw)?,
|
||||
filters_enabled: *filters_enabled,
|
||||
filter_mask: match filter_mask {
|
||||
Some(mm) => Some(LayerMask {
|
||||
surface: self.surface(&mm.surface)?,
|
||||
enabled: mm.enabled,
|
||||
linked: mm.linked,
|
||||
density: mm.density,
|
||||
feather: mm.feather,
|
||||
}),
|
||||
None => None,
|
||||
},
|
||||
warp: warp.clone().filter(|w| w.mesh.as_ref().is_none_or(|m| m.is_valid())),
|
||||
stack_mode: *stack_mode,
|
||||
perspective: perspective.filter(|p| p.iter().all(|v| v.is_finite())),
|
||||
}),
|
||||
};
|
||||
let mask = match &m.mask {
|
||||
Some(mm) => {
|
||||
|
||||
@@ -312,6 +312,8 @@ pub enum ContentM {
|
||||
#[serde(default)]
|
||||
contents_id: Option<u64>,
|
||||
source: SmartSourceM,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
vector_preview: Option<VectorPreviewM>,
|
||||
transform: Affine,
|
||||
smart_filters: Vec<SmartFilter>,
|
||||
cache: Option<SurfaceM>,
|
||||
@@ -330,6 +332,13 @@ pub enum ContentM {
|
||||
},
|
||||
}
|
||||
|
||||
/// The native source remains separate from this optional display-only SVG blob.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct VectorPreviewM {
|
||||
pub source_hash: [u8; 32],
|
||||
pub svg: Hash,
|
||||
}
|
||||
|
||||
fn full_density() -> f32 {
|
||||
1.0
|
||||
}
|
||||
|
||||
@@ -277,17 +277,25 @@ impl Fetch for LoadFetch<'_> {
|
||||
}
|
||||
|
||||
fn blob(&mut self, hash: &str) -> Result<Arc<Vec<u8>>> {
|
||||
self.blob_bounded(hash, self.opts.max_blob_bytes)
|
||||
}
|
||||
|
||||
/// A content-addressed blob of at most `limit` bytes (never more than `max_blob_bytes`):
|
||||
/// read once, decompressed within the limit, counted against the load's memory budget and
|
||||
/// checked against its hash, then shared. Optional vector previews pass a 16 MiB limit.
|
||||
fn blob_bounded(&mut self, hash: &str, limit: usize) -> Result<Arc<Vec<u8>>> {
|
||||
let limit = limit.min(self.opts.max_blob_bytes);
|
||||
if let Some(b) = self.blobs.get(hash) {
|
||||
return Ok(b.clone());
|
||||
return if b.len() <= limit { Ok(b.clone()) } else { Err(FormatError::LimitExceeded("optional preview blob exceeds its limit".into())) };
|
||||
}
|
||||
if !is_valid_hash(hash) {
|
||||
return Err(FormatError::corrupt(format!("invalid blob hash `{hash}`")));
|
||||
}
|
||||
let path = blob_path(hash);
|
||||
let z = self.src.get(&path, self.opts.max_blob_bytes)?;
|
||||
let z = self.src.get(&path, limit)?;
|
||||
// Blob length is not in the manifest; the frame header bounds it and
|
||||
// we cap at max_blob_bytes.
|
||||
let data = decompress_bounded(&z, self.opts.max_blob_bytes, None, &path)?;
|
||||
let data = decompress_bounded(&z, limit, None, &path)?;
|
||||
self.account(data.len())?;
|
||||
if hash_bytes(&data) != hash {
|
||||
return Err(FormatError::corrupt(format!("{path}: content does not match its hash")));
|
||||
|
||||
@@ -174,6 +174,7 @@ pub fn rich_doc(mode: ColorMode, depth: SampleType) -> Document {
|
||||
"Smart",
|
||||
LayerContent::Smart(SmartObject {
|
||||
contents_id: SmartContentsId::fresh(),
|
||||
vector_preview: None,
|
||||
source: SmartSource::Embedded { file_name: "inner.png".into(), bytes: blob(11, 2000) },
|
||||
transform: Affine::scale(0.5),
|
||||
smart_filters: vec![SmartFilter {
|
||||
@@ -203,6 +204,7 @@ pub fn rich_doc(mode: ColorMode, depth: SampleType) -> Document {
|
||||
"Linked",
|
||||
LayerContent::Smart(SmartObject {
|
||||
contents_id: SmartContentsId::fresh(),
|
||||
vector_preview: None,
|
||||
source: SmartSource::Linked { path: "/tmp/linked.psd".into() },
|
||||
transform: Affine::IDENTITY,
|
||||
smart_filters: vec![],
|
||||
|
||||
@@ -141,3 +141,37 @@ fn extreme_smart_contents_ids_remap_without_poisoning_allocator() {
|
||||
assert!(SmartContentsId::fresh().0 < u64::MAX / 2);
|
||||
assert!(LayerId::fresh().0 < u64::MAX / 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_vector_display_preview_roundtrips_separately_from_original_payload() {
|
||||
let source = Arc::new(b"%PDF-1.7\noriginal editable source".to_vec());
|
||||
let svg = Arc::new(br#"<svg xmlns="http://www.w3.org/2000/svg" width="8" height="6"/>"#.to_vec());
|
||||
let mut smart = SmartObject::new(SmartSource::Embedded { file_name: "Artwork.ai".into(), bytes: source.clone() }, Affine::IDENTITY, None);
|
||||
smart.vector_preview = Some(photocraft_doc::VectorPreview { source_hash: *blake3::hash(&source).as_bytes(), svg });
|
||||
let mut doc = Document::new("native vector", Size::new(8, 6), ColorMode::Rgb, SampleType::U8);
|
||||
doc.layers.push(Layer::new("vector", LayerContent::Smart(smart)));
|
||||
let bytes = save_to_bytes(&doc, &Default::default()).unwrap();
|
||||
assert_eq!(load_from_bytes(&bytes).unwrap(), doc);
|
||||
let missing_preview = rewrite_manifest(&bytes, |manifest| {
|
||||
manifest["document"]["layers"][0]["content"]["vector_preview"]["svg"] = serde_json::json!("0".repeat(64));
|
||||
});
|
||||
let recovered = load_from_bytes(&missing_preview).unwrap();
|
||||
let LayerContent::Smart(recovered_smart) = &recovered.layers[0].content else { panic!("smart object lost") };
|
||||
assert!(recovered_smart.vector_preview.is_none(), "an unavailable display preview must not hide the original vector");
|
||||
assert_eq!(recovered_smart.source, SmartSource::Embedded { file_name: "Artwork.ai".into(), bytes: source.clone() });
|
||||
let mut oversized = doc.clone();
|
||||
let LayerContent::Smart(oversized_smart) = &mut oversized.layers[0].content else { panic!("smart object lost") };
|
||||
oversized_smart.vector_preview.as_mut().unwrap().svg = Arc::new(vec![b'x'; (16 << 20) + 1]);
|
||||
let oversized = save_to_bytes(&oversized, &Default::default()).unwrap();
|
||||
let recovered = load_from_bytes(&oversized).unwrap();
|
||||
let LayerContent::Smart(recovered_smart) = &recovered.layers[0].content else { panic!("smart object lost") };
|
||||
assert!(recovered_smart.vector_preview.is_none(), "an oversized optional preview is ignored before unbounded decompression");
|
||||
assert_eq!(recovered_smart.source, SmartSource::Embedded { file_name: "Artwork.ai".into(), bytes: source.clone() });
|
||||
let legacy = rewrite_manifest(&bytes, |manifest| {
|
||||
manifest["document"]["layers"][0]["content"].as_object_mut().unwrap().remove("vector_preview");
|
||||
});
|
||||
let back = load_from_bytes(&legacy).unwrap();
|
||||
let LayerContent::Smart(smart) = &back.layers[0].content else { panic!("smart object lost") };
|
||||
assert!(smart.vector_preview.is_none());
|
||||
assert_eq!(smart.source, SmartSource::Embedded { file_name: "Artwork.ai".into(), bytes: source });
|
||||
}
|
||||
|
||||
@@ -40,6 +40,8 @@ blake3 = { version = "1.8", default-features = false, features = ["pure"] }
|
||||
flate2 = { workspace = true }
|
||||
# ora.rs reads OpenRaster's stack.xml (already in the tree through usvg).
|
||||
roxmltree = "0.21"
|
||||
# Reuse usvg's locked header reader to bound embedded SVG raster data before decoding.
|
||||
imagesize = "0.13"
|
||||
# SVG import (svg.rs): usvg parses, resvg rasterises what the shape model can't hold. Both are
|
||||
# already in the tree through the UI's icon loader; text shaping needs the fonts feature set.
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
|
||||
@@ -74,6 +74,7 @@ mod text_import;
|
||||
pub mod text_styles_map;
|
||||
pub mod tiff_layers;
|
||||
pub mod vector_map;
|
||||
pub mod vector_preview;
|
||||
mod xcf;
|
||||
pub mod xml;
|
||||
|
||||
|
||||
@@ -40,8 +40,12 @@ impl<'a> Rd<'a> {
|
||||
fn u64(&mut self) -> Option<u64> {
|
||||
self.take(8).map(|b| u64::from_be_bytes(b.try_into().unwrap_or([0; 8])))
|
||||
}
|
||||
/// Decode a bounded metadata filename without trusting its declared allocation size.
|
||||
fn unicode(&mut self) -> Option<String> {
|
||||
let n = self.u32()? as usize;
|
||||
if n > 32_768 {
|
||||
return None;
|
||||
} // A file name is metadata, never an unbounded allocation.
|
||||
let raw = self.take(n.checked_mul(2)?)?;
|
||||
let units: Vec<u16> = raw.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]])).collect();
|
||||
Some(String::from_utf16_lossy(&units).trim_end_matches('\0').to_string())
|
||||
@@ -95,6 +99,12 @@ pub fn embedded_file_name(meta: &Metadata, uuid: &str) -> Option<String> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Borrow the selected item payload without copying it.
|
||||
fn item_data(item: &[u8]) -> Option<(String, String, &[u8])> {
|
||||
let (uuid, name, start, len) = item_header(item)?;
|
||||
Some((uuid, name, item.get(start..start.checked_add(len)?)?))
|
||||
}
|
||||
|
||||
/// Every embedded (`liFD`) file in one linked-layer block's data.
|
||||
pub fn parse_linked_files(data: &[u8]) -> Vec<LinkedFile> {
|
||||
let mut out = Vec::new();
|
||||
@@ -113,6 +123,34 @@ pub fn find_linked_file(meta: &Metadata, uuid: &str) -> Option<LinkedFile> {
|
||||
find_in_blocks(meta.psd_global_blocks.iter().map(|(_, k, d)| (k, d.as_slice())), uuid)
|
||||
}
|
||||
|
||||
/// Borrow only the selected embedded payload, without copying other smart objects or reading disk.
|
||||
pub fn embedded_payload<'a>(meta: &'a Metadata, uuid: &str) -> Option<(String, &'a [u8])> {
|
||||
embedded_payload_in_blocks(meta.psd_global_blocks.iter().map(|(_, key, data)| (key, data.as_slice())), uuid)
|
||||
}
|
||||
|
||||
/// Borrow one selected payload from PSD global blocks without copying unrelated files.
|
||||
pub fn embedded_payload_in_blocks<'a>(blocks: impl IntoIterator<Item = (&'a [u8; 4], &'a [u8])>, uuid: &str) -> Option<(String, &'a [u8])> {
|
||||
if uuid.is_empty() {
|
||||
return None;
|
||||
}
|
||||
for (key, data) in blocks {
|
||||
if !matches!(key, b"lnk2" | b"lnk3" | b"lnkD") {
|
||||
continue;
|
||||
}
|
||||
let mut r = Rd { d: data, p: 0 };
|
||||
while let Some(len) = r.u64().and_then(|n| usize::try_from(n).ok()) {
|
||||
let Some(item) = r.take(len) else { break };
|
||||
if let Some((id, name, bytes)) = item_data(item)
|
||||
&& id == uuid
|
||||
{
|
||||
return Some((name, bytes));
|
||||
}
|
||||
r.p = r.p.checked_add(3)? & !3;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The embedded file for smart-object `uuid` among PSD global blocks given as (key, data).
|
||||
pub fn find_in_blocks<'a>(blocks: impl IntoIterator<Item = (&'a [u8; 4], &'a [u8])>, uuid: &str) -> Option<LinkedFile> {
|
||||
if uuid.is_empty() {
|
||||
@@ -597,3 +635,26 @@ mod tests {
|
||||
assert!(select_items(&valid_unicode, &|uuid, _| uuid == "\u{fffd}").unwrap().is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod bounded_payload_tests {
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[test]
|
||||
fn selected_payload_is_borrowed_and_other_sources_are_not_copied() {
|
||||
let a = LinkedFile { uuid: "a".into(), file_name: "Other.psb".into(), bytes: vec![1; 1024 * 1024] };
|
||||
let b = LinkedFile { uuid: "b".into(), file_name: "Logo.ai".into(), bytes: b"%PDF-1.6".to_vec() };
|
||||
let mut data = encode_linked_file(&a);
|
||||
data.extend(encode_linked_file(&b));
|
||||
let mut meta = Metadata::default();
|
||||
meta.psd_global_blocks.push((*b"8BIM", *b"lnk2", Arc::new(data)));
|
||||
let (name, bytes) = embedded_payload(&meta, "b").unwrap();
|
||||
assert_eq!(name, "Logo.ai");
|
||||
assert_eq!(bytes, b.bytes);
|
||||
let original = meta.psd_global_blocks[0].2.as_slice();
|
||||
assert!((original.as_ptr_range()).contains(&bytes.as_ptr()));
|
||||
assert!(embedded_payload(&meta, "").is_none());
|
||||
assert!(embedded_payload(&meta, "/dev/zero").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ pub use document::{document_to_psd, document_to_psd_with};
|
||||
use document::{document_to_psd_nested, mask_parameters};
|
||||
pub(crate) use estimate::{fit_psd_size, psd_version, sign_global_blocks};
|
||||
pub(crate) use resources::{global_block, guides_resource, keep_imported_resource, legacy_name, pascal_names_resource, path_resources, unicode_names_resource};
|
||||
use smart::{LINK_KEYS, SmartOut, Source, contents_uuid, effects_unchanged, masked, size_from_layer, smart_keys, upsert};
|
||||
use smart::{LINK_KEYS, SmartOut, Source, contents_uuid, effects_unchanged, embed_source_hash, masked, size_from_layer, smart_keys, upsert};
|
||||
|
||||
/// Blocks that define a layer's content as something other than pixels: fill (`SoCo`, `GdFl`,
|
||||
/// `PtFl`), placed / smart object (`SoLd`, `PlLd`, `SoLE`), type (`TySh`) and shape fill (`vscg`).
|
||||
@@ -412,6 +412,9 @@ impl Ex {
|
||||
if effects_unchanged(l) == Some(false) {
|
||||
raw.retain(|(k, _)| k != b"lmfx");
|
||||
}
|
||||
// Regenerate the private preview from its typed, source-bound value. Never retain a
|
||||
// stale block after source replacement or rasterization.
|
||||
raw.retain(|(key, _)| key != &crate::vector_preview::PSD_KEY);
|
||||
let mut regenerated: Vec<([u8; 4], Vec<u8>)> = Vec::new();
|
||||
// `keys` is in the importer's priority order (`psd_raw` came from the first key present),
|
||||
// not file order: Photoshop writes `PlLd` before `SoLd`, and overwriting the first match
|
||||
@@ -521,6 +524,9 @@ impl Ex {
|
||||
}
|
||||
}
|
||||
LayerContent::Smart(sm) => {
|
||||
if let Some(preview) = self.smart.vector_preview(sm) {
|
||||
raw.push((crate::vector_preview::PSD_KEY, preview.block.clone()));
|
||||
}
|
||||
let parsed = sm.psd_raw.as_deref().and_then(|d| crate::smart_map::parse_sold(d));
|
||||
if sm.psd_raw.is_some() && parsed.is_none() && !self.plld_edited(sm) {
|
||||
// Placed-layer data that isn't `SoLd` (a CS2 `PlLd` record, or one we can't
|
||||
@@ -796,7 +802,8 @@ impl Ex {
|
||||
/// they are; a linked file is read and embedded).
|
||||
fn smart_source(&mut self, sm: &photocraft_doc::SmartObject, template: Option<&crate::smart_map::Placed>) -> Result<Source, String> {
|
||||
use photocraft_doc::SmartSource;
|
||||
match &sm.source {
|
||||
let preview = self.smart.vector_preview(sm).and_then(|preview| Some((preview.document.clone(), preview.frame?)));
|
||||
let mut source = match &sm.source {
|
||||
// A file of the imported document, or one its placed-layer data names (a missing
|
||||
// link stays a smart object, as in Photoshop).
|
||||
SmartSource::Linked { path } if self.smart.known.contains(path) || template.is_some_and(|t| t.idnt == *path) => {
|
||||
@@ -849,8 +856,17 @@ impl Ex {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
Err(format!("the linked file {path} can't be read here"))
|
||||
}
|
||||
SmartSource::Embedded { file_name, bytes } => self.embed(sm.contents_id, file_name, bytes, false),
|
||||
SmartSource::Embedded { file_name, bytes } => {
|
||||
let identity = std::sync::Arc::as_ptr(bytes).addr();
|
||||
let hash = self.smart.embedded_hashes.entry(identity).or_insert_with(|| (bytes.clone(), embed_source_hash(bytes))).1;
|
||||
self.embed_hashed(sm.contents_id, file_name, bytes, false, hash)
|
||||
}
|
||||
}?;
|
||||
if let Some((preview, frame)) = preview {
|
||||
source.size = frame;
|
||||
self.smart.docs.insert(source.uuid.clone(), preview);
|
||||
}
|
||||
Ok(source)
|
||||
}
|
||||
|
||||
/// The composite of the source `uuid` (an embedded file of this export or of the imported
|
||||
@@ -896,8 +912,14 @@ impl Ex {
|
||||
/// per contents identity and bytes. Equal bytes in independent objects stay independent.
|
||||
/// `linked`: the bytes were read from a path the document names, so only a file PhotoCraft
|
||||
/// can open is embedded (a link to a text or settings file is written as pixels instead).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn embed(&mut self, contents_id: SmartContentsId, file_name: &str, bytes: &[u8], linked: bool) -> Result<Source, String> {
|
||||
let key = (contents_id, *blake3::hash(bytes).as_bytes(), linked);
|
||||
self.embed_hashed(contents_id, file_name, bytes, linked, embed_source_hash(bytes))
|
||||
}
|
||||
|
||||
/// Reuse conversion only for the same contents identity, actual-byte digest and linked-file policy.
|
||||
fn embed_hashed(&mut self, contents_id: SmartContentsId, file_name: &str, bytes: &[u8], linked: bool, hash: [u8; 32]) -> Result<Source, String> {
|
||||
let key = (contents_id, hash, linked);
|
||||
if let Some(r) = self.smart.converted.get(&key) {
|
||||
return r.clone();
|
||||
}
|
||||
@@ -1033,3 +1055,6 @@ impl Ex {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) use smart::EMBED_HASHED_SOURCE_BYTES;
|
||||
|
||||
@@ -16,6 +16,18 @@ pub(super) fn contents_uuid(contents_id: SmartContentsId, bytes: &[u8]) -> Strin
|
||||
crate::smart_map::uuid_from(identity.finalize().as_bytes())
|
||||
}
|
||||
|
||||
type OwnedSourceHash = (std::sync::Arc<Vec<u8>>, [u8; 32]);
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! { pub(crate) static EMBED_HASHED_SOURCE_BYTES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) }; }
|
||||
|
||||
/// The actual payload digest used by the source-conversion cache, separate from preview checks.
|
||||
pub(super) fn embed_source_hash(bytes: &[u8]) -> [u8; 32] {
|
||||
#[cfg(test)]
|
||||
EMBED_HASHED_SOURCE_BYTES.with(|count| count.set(count.get().saturating_add(bytes.len())));
|
||||
*blake3::hash(bytes).as_bytes()
|
||||
}
|
||||
|
||||
/// Smart-object state collected while emitting layers, written into the global blocks at the end.
|
||||
#[derive(Default)]
|
||||
pub(super) struct SmartOut {
|
||||
@@ -34,6 +46,8 @@ pub(super) struct SmartOut {
|
||||
/// Sources converted so far, by contents identity, bytes and whether they were read from a
|
||||
/// link (`Err` = can't be embedded).
|
||||
pub(super) converted: std::collections::HashMap<(SmartContentsId, [u8; 32], bool), Result<Source, String>>,
|
||||
/// Only immutable embedded owners are memoized for this export; pinning prevents address reuse.
|
||||
pub(super) embedded_hashes: std::collections::HashMap<usize, OwnedSourceHash>,
|
||||
/// Each placed layer has its own id, even when it duplicates an imported layer.
|
||||
pub(super) placed_used: std::collections::HashSet<String>,
|
||||
/// Original instances reserve their imported placement regardless of layer order.
|
||||
@@ -57,6 +71,8 @@ pub(super) struct SmartOut {
|
||||
/// The unfiltered pixels of each smart object whose filter mask shows: its layer record holds
|
||||
/// the filtered pixels without the mask, as Photoshop's do (`compose::filter_mask`).
|
||||
pub(super) unfiltered: std::collections::HashMap<photocraft_doc::LayerId, Surface>,
|
||||
/// Source-bound previews shared by private blocks and smart-filter source documents.
|
||||
pub(super) vector_previews: crate::vector_preview::ExportCache,
|
||||
}
|
||||
|
||||
/// Whether a smart object's filter mask shows: enabled over at least one visible, enabled filter.
|
||||
@@ -109,6 +125,20 @@ impl SmartOut {
|
||||
SmartOut { depth, globals, known, prune_ok: true, old_fx, linked_contents, placed_owners, unplaced_owners, ..Default::default() }
|
||||
}
|
||||
|
||||
/// The validated native preview of `sm`, checked against its embedded bytes or the preserved
|
||||
/// embedded file it links to (never a disk path).
|
||||
pub(super) fn vector_preview(&mut self, sm: &photocraft_doc::SmartObject) -> Option<std::sync::Arc<crate::vector_preview::Exported>> {
|
||||
let preview = sm.vector_preview.as_ref()?;
|
||||
let (owner, source) = match &sm.source {
|
||||
photocraft_doc::SmartSource::Embedded { bytes, .. } => (bytes, bytes.as_slice()),
|
||||
photocraft_doc::SmartSource::Linked { path } => self
|
||||
.globals
|
||||
.iter()
|
||||
.find_map(|(_, key, data)| crate::linked::embedded_payload_in_blocks([(key, data.as_slice())], path).map(|(_, bytes)| (data, bytes)))?,
|
||||
};
|
||||
self.vector_previews.get(preview, owner, source)
|
||||
}
|
||||
|
||||
/// The document's global blocks with the linked-layer files and filter caches brought up to
|
||||
/// date: unreferenced files dropped (when every reference is known), new files appended,
|
||||
/// filter caches kept or regenerated per smart object. Unchanged blocks stay byte-identical.
|
||||
|
||||
@@ -75,6 +75,8 @@ pub(crate) struct Ctx<'a> {
|
||||
pub smart_contents: std::collections::HashMap<String, SmartContentsId>,
|
||||
/// Source sizes of `PlLd`-only placed layers, by source id (read once per embedded file).
|
||||
pub plld_sizes: std::collections::HashMap<String, Option<(f64, f64)>>,
|
||||
/// Shared vector previews are hashed/parsed once, under a per-file work budget.
|
||||
pub vector_previews: crate::vector_preview::ImportCache,
|
||||
/// Cancellation and progress for background opens (checked per layer record).
|
||||
pub ctl: photocraft_raster::Interrupt<'a>,
|
||||
/// Layer records decoded so far, out of `total` (progress).
|
||||
@@ -645,9 +647,17 @@ 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 global = &self.global;
|
||||
self.vector_previews.decode(&id, &block.data, || {
|
||||
crate::linked::embedded_payload_in_blocks(global.iter().map(|(_, key, data)| (key, data.as_slice())), &id).map(|(_, source)| source)
|
||||
})
|
||||
});
|
||||
LayerContent::Smart(SmartObject {
|
||||
contents_id,
|
||||
source: SmartSource::Linked { path: id },
|
||||
vector_preview,
|
||||
transform,
|
||||
smart_filters: stack.filters,
|
||||
cache: Some(cache),
|
||||
@@ -1190,6 +1200,7 @@ fn psd_to_document_global(
|
||||
unfiltered_bytes: 0,
|
||||
smart_contents: Default::default(),
|
||||
plld_sizes: Default::default(),
|
||||
vector_previews: Default::default(),
|
||||
ctl: *ctl,
|
||||
done: 0,
|
||||
uniform: Default::default(),
|
||||
|
||||
+597
-16
@@ -10,7 +10,7 @@
|
||||
//! stops and direction in the layer's own gradient fill, with a warning that the exact geometry
|
||||
//! is not kept.
|
||||
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
use photocraft_color::blend::BlendMode;
|
||||
use photocraft_color::{Color, ColorMode, PixelFormat, SampleType};
|
||||
@@ -333,6 +333,7 @@ fn options(fontdb: Arc<usvg::fontdb::Database>, depth_left: usize) -> usvg::Opti
|
||||
}
|
||||
|
||||
/// A parsed drawing.
|
||||
#[derive(Debug)]
|
||||
struct Parsed {
|
||||
tree: usvg::Tree,
|
||||
/// The drawing has text but no font at all was available to shape it. `usvg` then drops the
|
||||
@@ -368,18 +369,414 @@ fn parse_with(bytes: &[u8], fonts: fn() -> Arc<usvg::fontdb::Database>) -> Resul
|
||||
Ok(Parsed { tree, fontless_text })
|
||||
}
|
||||
|
||||
const MAX_BOUNDED_SVG_BYTES: u64 = 16 << 20;
|
||||
const MAX_EMBEDDED_SVG_DEPTH: usize = 32;
|
||||
|
||||
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>) {
|
||||
let mut budget = budget.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if budget.error.is_none() {
|
||||
budget.error = Some(error.into());
|
||||
}
|
||||
}
|
||||
|
||||
/// Deduct one image's `width × height` decoded pixels from the shared budget before it is
|
||||
/// decoded. A zero-sized, overflowing or over-budget image records an error and returns `false`.
|
||||
fn charge_image(budget: &Mutex<ImageBudget>, width: usize, height: usize) -> bool {
|
||||
let pixels = u64::try_from(width).ok().zip(u64::try_from(height).ok()).and_then(|(w, h)| w.checked_mul(h));
|
||||
let mut budget = budget.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
match pixels.filter(|pixels| *pixels != 0).and_then(|pixels| budget.pixels.checked_sub(pixels)) {
|
||||
Some(remaining) => {
|
||||
budget.pixels = remaining;
|
||||
true
|
||||
}
|
||||
None => {
|
||||
budget.error.get_or_insert_with(|| "embedded SVG raster images exceed the decoded pixel budget".into());
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The size declared by a GIF's first image descriptor, skipping extension blocks with checked
|
||||
/// offsets; `None` when the stream is truncated or malformed.
|
||||
///
|
||||
/// A GIF's first frame can exceed its logical screen. resvg allocates the frame's RGBA buffer,
|
||||
/// so inspect its descriptor too, without reading any LZW pixels.
|
||||
fn gif_frame_size(bytes: &[u8]) -> Option<(usize, usize)> {
|
||||
let packed = *bytes.get(10)?;
|
||||
let table = if packed & 0x80 != 0 { 3usize.checked_mul(1usize.checked_shl(u32::from((packed & 7) + 1))?)? } else { 0 };
|
||||
let mut at = 13usize.checked_add(table)?;
|
||||
loop {
|
||||
match *bytes.get(at)? {
|
||||
0x2c => {
|
||||
let word = |offset: usize| -> Option<usize> {
|
||||
let pair: [u8; 2] = bytes.get(at.checked_add(offset)?..at.checked_add(offset)?.checked_add(2)?)?.try_into().ok()?;
|
||||
Some(usize::from(u16::from_le_bytes(pair)))
|
||||
};
|
||||
return Some((word(5)?, word(7)?));
|
||||
}
|
||||
0x21 => {
|
||||
at = at.checked_add(2)?;
|
||||
loop {
|
||||
let length = usize::from(*bytes.get(at)?);
|
||||
at = at.checked_add(1)?;
|
||||
if length == 0 {
|
||||
break;
|
||||
}
|
||||
bytes.get(at..at.checked_add(length)?)?;
|
||||
at = at.checked_add(length)?;
|
||||
}
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Widen `dimensions` to the largest frame declared by the WebP RIFF `chunks` (VP8/VP8L
|
||||
/// bitstreams and one level of ANMF animation frames); `None` when a chunk is malformed.
|
||||
///
|
||||
/// VP8X declares a container canvas, while a malformed lossy VP8 chunk may declare a larger
|
||||
/// frame which the downstream decoder allocates before comparing it with that canvas.
|
||||
fn webp_frame_sizes(chunks: &[u8], dimensions: &mut (usize, usize), nested: bool) -> Option<()> {
|
||||
let mut at = 0usize;
|
||||
while at < chunks.len() {
|
||||
let header = chunks.get(at..at.checked_add(8)?)?;
|
||||
let length = usize::try_from(u32::from_le_bytes(header.get(4..8)?.try_into().ok()?)).ok()?;
|
||||
let from = at.checked_add(8)?;
|
||||
let end = from.checked_add(length)?;
|
||||
let payload = chunks.get(from..end)?;
|
||||
match header.get(..4)? {
|
||||
b"VP8 " | b"VP8L" => {
|
||||
let mut probe = b"RIFF\0\0\0\0WEBP".to_vec();
|
||||
probe.extend_from_slice(header);
|
||||
probe.extend_from_slice(payload.get(..payload.len().min(10))?);
|
||||
let size = imagesize::blob_size(&probe).ok()?;
|
||||
dimensions.0 = dimensions.0.max(size.width);
|
||||
dimensions.1 = dimensions.1.max(size.height);
|
||||
}
|
||||
b"ANMF" if !nested => {
|
||||
let word = |at: usize| -> Option<usize> {
|
||||
let bytes: [u8; 3] = payload.get(at..at.checked_add(3)?)?.try_into().ok()?;
|
||||
usize::try_from(u32::from_le_bytes([bytes[0], bytes[1], bytes[2], 0]).checked_add(1)?).ok()
|
||||
};
|
||||
dimensions.0 = dimensions.0.max(word(6)?);
|
||||
dimensions.1 = dimensions.1.max(word(9)?);
|
||||
webp_frame_sizes(payload.get(16..)?, dimensions, true)?;
|
||||
}
|
||||
b"ANMF" => return None,
|
||||
_ => {}
|
||||
}
|
||||
at = end.checked_add(length & 1)?;
|
||||
if at > chunks.len() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// usvg's data-URL image resolver with budgets: nested SVG images are parsed with the same
|
||||
/// 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;
|
||||
}
|
||||
let format = photocraft_codecs::detect(&data);
|
||||
if mime == "image/svg+xml" || (mime == "text/plain" && format.is_none()) {
|
||||
if depth >= MAX_EMBEDDED_SVG_DEPTH {
|
||||
image_error(budget, "embedded SVG images exceed the nesting limit");
|
||||
return 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)
|
||||
);
|
||||
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));
|
||||
}
|
||||
if format == Some(photocraft_codecs::Format::WebP) && data.get(12..).and_then(|chunks| webp_frame_sizes(chunks, &mut dimensions, false)).is_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;
|
||||
}
|
||||
Some(image)
|
||||
}
|
||||
}
|
||||
|
||||
/// [`parse_image_bounded_with`] using the shared system font database.
|
||||
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(overrun(IoError::Svg("embedded SVG source exceeds the 16 MiB byte limit".into())));
|
||||
}
|
||||
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);
|
||||
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();
|
||||
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()))?;
|
||||
let tree = usvg::Tree::from_str(text, &opt);
|
||||
if let Some(error) = budget.lock().unwrap_or_else(std::sync::PoisonError::into_inner).error.clone() {
|
||||
return Err(IoError::Svg(error));
|
||||
}
|
||||
let tree = tree.map_err(|e| IoError::Svg(e.to_string()))?;
|
||||
// Include nested SVG trees too. Paint servers can reuse one parsed raster in many
|
||||
// drawing nodes, so the work estimate must not assume every source is decoded once.
|
||||
let nodes = u64::try_from(count_nodes_with_limit(tree.root(), true, usize::MAX, false)).unwrap_or(u64::MAX);
|
||||
let mut counter = budget.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
counter.draw_nodes = counter.draw_nodes.saturating_add(nodes);
|
||||
let (raster, svg) = repeatable_image_resources(&tree);
|
||||
counter.repeatable_raster |= raster;
|
||||
counter.repeatable_svg |= svg;
|
||||
Ok(tree)
|
||||
}
|
||||
|
||||
/// Used paint/effect resources can decode an image once per use. Ordinary paths, gradients,
|
||||
/// clipping paths and blur filters without image references do not multiply unrelated photos.
|
||||
fn repeatable_image_resources(tree: &usvg::Tree) -> (bool, bool) {
|
||||
/// Mark `kinds` (raster, SVG) for images reachable from `group`, visiting each group once
|
||||
/// and turning conservative (both kinds) past the depth or step bound.
|
||||
fn visit(group: &usvg::Group, depth: usize, seen: &mut std::collections::HashSet<usize>, steps: &mut usize, kinds: &mut (bool, bool)) {
|
||||
if depth > MAX_RECURSION || *steps >= 1_000_000 {
|
||||
*kinds = (true, true); // Conservative when resource inspection itself reaches its bound.
|
||||
return;
|
||||
}
|
||||
if !seen.insert((group as *const usvg::Group).addr()) {
|
||||
return;
|
||||
}
|
||||
for node in group.children() {
|
||||
*steps = steps.saturating_add(1);
|
||||
match node {
|
||||
usvg::Node::Image(image) => match image.kind() {
|
||||
usvg::ImageKind::SVG(_) => kinds.1 = true,
|
||||
_ => kinds.0 = true,
|
||||
},
|
||||
usvg::Node::Group(child) => visit(child, depth + 1, seen, steps, kinds),
|
||||
_ => {}
|
||||
}
|
||||
node.subroots(|root| visit(root, depth + 1, seen, steps, kinds));
|
||||
if (kinds.0 && kinds.1) || *steps >= 1_000_000 {
|
||||
if *steps >= 1_000_000 {
|
||||
*kinds = (true, true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
let (mut seen, mut steps, mut kinds) = (std::collections::HashSet::new(), 0, (false, false));
|
||||
for root in tree.patterns().iter().map(|p| p.root()).chain(tree.masks().iter().map(|m| m.root())).chain(tree.clip_paths().iter().map(|c| c.root())) {
|
||||
visit(root, 0, &mut seen, &mut steps, &mut kinds);
|
||||
}
|
||||
for filter in tree.filters() {
|
||||
for primitive in filter.primitives() {
|
||||
if let usvg::filter::Kind::Image(image) = primitive.kind() {
|
||||
visit(image.root(), 0, &mut seen, &mut steps, &mut kinds);
|
||||
}
|
||||
}
|
||||
}
|
||||
kinds
|
||||
}
|
||||
|
||||
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. 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> {
|
||||
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`] 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, 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, 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, MAX_XML_DEPTH, fonts)?;
|
||||
let counter = budget.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
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,
|
||||
repeatable_svg: counter.repeatable_svg,
|
||||
})
|
||||
}
|
||||
|
||||
/// The plain XML of `bytes`: itself, or the inflated content of a gzip `.svgz`, refused past
|
||||
/// [`MAX_INFLATED_BYTES`] (a decompression bomb) or when the stream is corrupt.
|
||||
fn inflate(bytes: &[u8]) -> Result<std::borrow::Cow<'_, [u8]>, IoError> {
|
||||
use std::io::Read;
|
||||
if !bytes.starts_with(&[0x1f, 0x8b]) {
|
||||
return Ok(std::borrow::Cow::Borrowed(bytes));
|
||||
}
|
||||
inflate_with_limit(bytes, MAX_INFLATED_BYTES)
|
||||
}
|
||||
|
||||
/// [`inflate`] with an explicit `limit`, which also applies to uncompressed input.
|
||||
fn inflate_with_limit(bytes: &[u8], limit: u64) -> Result<std::borrow::Cow<'_, [u8]>, IoError> {
|
||||
use std::io::Read;
|
||||
if !bytes.starts_with(&[0x1f, 0x8b]) {
|
||||
if u64::try_from(bytes.len()).unwrap_or(u64::MAX) > limit {
|
||||
return Err(IoError::Svg(format!("the SVG exceeds the {limit} byte limit")));
|
||||
}
|
||||
return Ok(std::borrow::Cow::Borrowed(bytes));
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let mut reader = flate2::bufread::MultiGzDecoder::new(bytes).take(MAX_INFLATED_BYTES.saturating_add(1));
|
||||
let mut reader = flate2::bufread::MultiGzDecoder::new(bytes).take(limit.saturating_add(1));
|
||||
reader.read_to_end(&mut out).map_err(|e| IoError::Svg(format!("the compressed SVG couldn't be inflated: {e}")))?;
|
||||
if u64::try_from(out.len()).unwrap_or(u64::MAX) > MAX_INFLATED_BYTES {
|
||||
return Err(IoError::Svg(format!("the compressed SVG inflates past {} MB", MAX_INFLATED_BYTES >> 20)));
|
||||
if u64::try_from(out.len()).unwrap_or(u64::MAX) > limit {
|
||||
return Err(IoError::Svg(format!("the compressed SVG inflates past {} MB", limit >> 20)));
|
||||
}
|
||||
Ok(std::borrow::Cow::Owned(out))
|
||||
}
|
||||
@@ -466,28 +863,181 @@ pub fn import_svg(name: &str, bytes: &[u8]) -> Result<ImportResult, IoError> {
|
||||
/// Opens an SVG while rasterising any subtrees deeper than `max_group_depth` into single pixel
|
||||
/// layers. The document's hard nesting limit remains [`MAX_GROUP_DEPTH`].
|
||||
pub fn import_svg_with_group_depth(name: &str, bytes: &[u8], max_group_depth: usize) -> Result<ImportResult, IoError> {
|
||||
import_parsed(name, parse(bytes)?, max_group_depth)
|
||||
import_svg_inner(name, bytes, max_group_depth, None, false)
|
||||
}
|
||||
|
||||
fn import_parsed(name: &str, parsed: Parsed, max_group_depth: usize) -> Result<ImportResult, IoError> {
|
||||
import_parsed_with_budget(name, parsed, max_group_depth, MAX_PIXEL_BYTES)
|
||||
/// Opens an SVG with a conservative budget for eagerly rendered layer caches. A canvas above
|
||||
/// the budget (or the rasterisation limit) is rejected before any layer cache is allocated.
|
||||
/// When the canvas fits but its elements' combined caches would exceed the budget, the whole
|
||||
/// drawing becomes one pixel layer, with an explicit editability warning.
|
||||
pub fn import_svg_bounded(name: &str, bytes: &[u8], max_cached_pixels: u64) -> Result<ImportResult, IoError> {
|
||||
import_svg_inner(name, bytes, MAX_GROUP_DEPTH, Some(max_cached_pixels), false)
|
||||
}
|
||||
|
||||
/// Import a drawing with one aggregate pixel-work allowance and return its conservative charge.
|
||||
/// This includes decoded raster pixels (including repeated paints), eager layer-cache surfaces
|
||||
/// and embedded-SVG intermediate surfaces. Image decoding and each canvas/cache component keep
|
||||
/// 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. 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), 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
|
||||
// vector paths alongside a photo do not decode that photo again for each path.
|
||||
let decoded_work = decoded
|
||||
.checked_mul(if repeatable_raster { uses } else { 1 })
|
||||
.filter(|pixels| *pixels <= max_work)
|
||||
.ok_or_else(|| IoError::Svg("SVG image decoding exceeds the validation work budget".into()))?;
|
||||
let cache_limit = (max_work.saturating_sub(decoded_work) / intermediate_surfaces).min(MAX_RASTER_PIXELS);
|
||||
if area > cache_limit {
|
||||
return Err(IoError::Svg("SVG preview decoding and rendering exceed the validation work budget".into()));
|
||||
}
|
||||
let nodes = count_nodes(parsed.tree.root(), true);
|
||||
let layers = area.checked_mul(u64::try_from(nodes.max(1)).unwrap_or(u64::MAX));
|
||||
// import_parsed uses this same bound to select one whole-drawing fallback before it
|
||||
// renders any layer caches. A tiny canvas cannot hide a huge decoded source image.
|
||||
let rendered = if nodes > MAX_NODES || layers.is_none_or(|pixels| pixels > cache_limit) { area } else { layers.unwrap_or(area) };
|
||||
let work = rendered
|
||||
.checked_mul(intermediate_surfaces)
|
||||
.and_then(|pixels| decoded_work.checked_add(pixels))
|
||||
.filter(|pixels| *pixels <= max_work)
|
||||
.ok_or_else(|| IoError::Svg("SVG preview validation work budget exceeded".into()))?;
|
||||
import_parsed(name, parsed, MAX_GROUP_DEPTH, Some(cache_limit), false).map(|imported| (imported, work))
|
||||
}
|
||||
|
||||
/// The same cache bound without a lossy aggregate-budget fallback. Smart-object operations
|
||||
/// use this variant so editing contents or changing Stack Mode fails before excessive caches
|
||||
/// are rendered, rather than silently changing the source's layer structure.
|
||||
pub fn import_svg_bounded_layers(name: &str, bytes: &[u8], max_cached_pixels: u64) -> Result<ImportResult, IoError> {
|
||||
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
|
||||
/// pixel does not. A smart object's warp frame needs only these bounds, so an SVG whose editable
|
||||
/// layer caches exceed the budget (rejected by [`import_svg_bounded_layers`]) still has one.
|
||||
pub fn bounded_canvas_bounds(bytes: &[u8], max_cached_pixels: u64) -> Result<Rect, IoError> {
|
||||
let parsed = parse_bounded(bytes, max_cached_pixels)?;
|
||||
let cv = canvas(&parsed.tree, &mut Vec::new());
|
||||
let limit = max_cached_pixels.min(MAX_RASTER_PIXELS);
|
||||
if cv.size.area() > limit {
|
||||
return Err(IoError::Svg(format!("{} × {} px exceeds the SVG layer cache budget of {limit} pixels", cv.size.width, cv.size.height)));
|
||||
}
|
||||
Ok(Rect::from_size(cv.size))
|
||||
}
|
||||
|
||||
/// Parse (bounded when `max_cached_pixels` is set) and convert to layers; see [`import_parsed_with_limits`].
|
||||
fn import_svg_inner(
|
||||
name: &str,
|
||||
bytes: &[u8],
|
||||
max_group_depth: usize,
|
||||
max_cached_pixels: Option<u64>,
|
||||
reject_cache_overflow: bool,
|
||||
) -> Result<ImportResult, IoError> {
|
||||
let parsed = match max_cached_pixels {
|
||||
Some(limit) => parse_bounded(bytes, limit)?,
|
||||
None => parse(bytes)?,
|
||||
};
|
||||
import_parsed(name, parsed, max_group_depth, max_cached_pixels, reject_cache_overflow)
|
||||
}
|
||||
|
||||
/// [`import_parsed_with_limits`] with the default retained-pixel byte budget.
|
||||
fn import_parsed(
|
||||
name: &str,
|
||||
parsed: Parsed,
|
||||
max_group_depth: usize,
|
||||
max_cached_pixels: Option<u64>,
|
||||
reject_cache_overflow: bool,
|
||||
) -> Result<ImportResult, IoError> {
|
||||
import_parsed_with_limits(name, parsed, max_group_depth, max_cached_pixels, reject_cache_overflow, MAX_PIXEL_BYTES)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn import_parsed_with_budget(name: &str, parsed: Parsed, max_group_depth: usize, pixel_budget: u64) -> Result<ImportResult, IoError> {
|
||||
import_parsed_with_limits(name, parsed, max_group_depth, None, false, pixel_budget)
|
||||
}
|
||||
|
||||
/// Convert a parsed tree into a document. With `max_cached_pixels`, a canvas over the budget is
|
||||
/// an error and caches over it either fail (`reject_cache_overflow`) or collapse the drawing into
|
||||
/// one pixel layer with a warning; more than [`MAX_NODES`] elements always collapse.
|
||||
/// `pixel_budget` bounds the bytes of retained layer pixels.
|
||||
fn import_parsed_with_limits(
|
||||
name: &str,
|
||||
parsed: Parsed,
|
||||
max_group_depth: usize,
|
||||
max_cached_pixels: Option<u64>,
|
||||
reject_cache_overflow: bool,
|
||||
pixel_budget: u64,
|
||||
) -> Result<ImportResult, IoError> {
|
||||
let Parsed { tree, fontless_text } = parsed;
|
||||
let mut warnings = Vec::new();
|
||||
if fontless_text {
|
||||
warnings.push("SVG: the text needs a font and none is installed, so it was dropped".to_string());
|
||||
}
|
||||
let cv = canvas(&tree, &mut warnings);
|
||||
let nodes = count_nodes(tree.root(), max_cached_pixels.is_some());
|
||||
let over_cache_budget = if let Some(budget) = max_cached_pixels {
|
||||
let area = cv.size.area();
|
||||
if area > budget.min(MAX_RASTER_PIXELS) {
|
||||
return Err(IoError::Svg(format!(
|
||||
"{} × {} px exceeds the SVG layer cache budget of {} pixels",
|
||||
cv.size.width,
|
||||
cv.size.height,
|
||||
budget.min(MAX_RASTER_PIXELS)
|
||||
)));
|
||||
}
|
||||
let count = u64::try_from(nodes.min(MAX_NODES)).unwrap_or(u64::MAX);
|
||||
area.checked_mul(count).is_none_or(|pixels| pixels > budget)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if over_cache_budget && reject_cache_overflow {
|
||||
return Err(IoError::Svg("the source's editable layer caches exceed the SVG cache budget".into()));
|
||||
}
|
||||
let mut doc = Document::new(name, cv.size, ColorMode::Rgb, SampleType::U8);
|
||||
let fmt = doc.pixel_format();
|
||||
let global = tiny_skia::Transform::from_scale(cv.scale, cv.scale);
|
||||
let max_group_depth = (max_group_depth != 0).then_some(max_group_depth.clamp(1, MAX_GROUP_DEPTH));
|
||||
let mut conv =
|
||||
Conv { fmt, canvas: doc.bounds(), global, max_group_depth, warnings, noted: Vec::new(), counts: [0; 4], pixel_budget, retained_pixel_bytes: 0 };
|
||||
if count_nodes(tree.root()) > MAX_NODES {
|
||||
conv.warnings.push(format!("SVG: more than {MAX_NODES} elements; opened as one pixel layer instead of shapes"));
|
||||
if nodes > MAX_NODES || over_cache_budget {
|
||||
if over_cache_budget {
|
||||
conv.warnings.push("SVG: combined layer caches exceed the clipboard cache budget; opened as one pixel layer instead of editable shapes".into());
|
||||
} else {
|
||||
conv.warnings.push(format!("SVG: more than {MAX_NODES} elements; opened as one pixel layer instead of shapes"));
|
||||
}
|
||||
let (w, h) = (cv.size.width, cv.size.height);
|
||||
if let Some(mut pixmap) = pixmap(w, h) {
|
||||
resvg::render(&tree, global, &mut pixmap.as_mut());
|
||||
@@ -506,8 +1056,29 @@ fn import_parsed_with_budget(name: &str, parsed: Parsed, max_group_depth: usize,
|
||||
/// the origin. Smart objects use it to stay sharp at any placement.
|
||||
pub fn rasterize(bytes: &[u8], scale: f32) -> Result<photocraft_compose::Buffer, IoError> {
|
||||
let tree = parse(bytes)?.tree;
|
||||
let cv = canvas(&tree, &mut Vec::new());
|
||||
let pixmap = render(&tree, cv.scale * scale.clamp(1.0 / 64.0, 64.0))?;
|
||||
rasterize_tree(&tree, scale)
|
||||
}
|
||||
|
||||
/// 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, chain) = parse_bounded_inner(bytes, max_decoded_pixels)?;
|
||||
let tree = parsed.tree;
|
||||
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);
|
||||
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)
|
||||
}
|
||||
|
||||
/// Render the whole tree at `scale` (clamped to 1/64–64) times its canvas size into a straight-alpha
|
||||
/// buffer at the origin; a result over the 24 MP rasterisation limit is an error.
|
||||
fn rasterize_tree(tree: &usvg::Tree, scale: f32) -> Result<photocraft_compose::Buffer, IoError> {
|
||||
let cv = canvas(tree, &mut Vec::new());
|
||||
let pixmap = render(tree, cv.scale * scale.clamp(1.0 / 64.0, 64.0))?;
|
||||
let rect = Rect::new(0, 0, pixmap.width() as i32, pixmap.height() as i32);
|
||||
let px = pixmap.pixels().iter().map(|p| rgba(p.demultiply())).collect();
|
||||
Ok(photocraft_compose::Buffer { rect, px })
|
||||
@@ -578,16 +1149,26 @@ fn surface(pixmap: &tiny_skia::Pixmap, rect: Rect, fmt: PixelFormat) -> Surface
|
||||
}
|
||||
|
||||
/// Drawable elements in the tree (iterative: the nesting can be anything).
|
||||
fn count_nodes(root: &usvg::Group) -> usize {
|
||||
let mut n = 0;
|
||||
fn count_nodes(root: &usvg::Group, include_text: bool) -> usize {
|
||||
count_nodes_with_limit(root, include_text, MAX_NODES, true)
|
||||
}
|
||||
|
||||
/// Count structural cache nodes or drawing nodes, stopping only at the caller's limit.
|
||||
fn count_nodes_with_limit(root: &usvg::Group, include_text: bool, limit: usize, count_groups: bool) -> usize {
|
||||
let mut n: usize = 0;
|
||||
let mut stack: Vec<&usvg::Group> = vec![root];
|
||||
while let Some(g) = stack.pop() {
|
||||
for node in g.children() {
|
||||
n += 1;
|
||||
if count_groups || !matches!(node, usvg::Node::Group(_)) {
|
||||
n = n.saturating_add(1);
|
||||
}
|
||||
if let usvg::Node::Group(child) = node {
|
||||
stack.push(child);
|
||||
}
|
||||
if n > MAX_NODES {
|
||||
if include_text && let usvg::Node::Text(text) = node {
|
||||
stack.push(text.flattened());
|
||||
}
|
||||
if n > limit {
|
||||
return n;
|
||||
}
|
||||
}
|
||||
|
||||
+417
-7
@@ -1,5 +1,161 @@
|
||||
use super::*;
|
||||
|
||||
fn embedded_image(mime: &str, bytes: &[u8]) -> String {
|
||||
let data = bytes.iter().map(|byte| format!("%{byte:02X}")).collect::<String>();
|
||||
format!("<image width=\"1\" height=\"1\" href=\"data:{mime},{data}\"/>")
|
||||
}
|
||||
|
||||
fn image_svg(images: &str) -> String {
|
||||
format!("<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1\" height=\"1\">{images}</svg>")
|
||||
}
|
||||
|
||||
fn png_dimensions(width: u32, height: u32) -> Vec<u8> {
|
||||
let mut bytes = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".to_vec();
|
||||
bytes.extend_from_slice(&width.to_be_bytes());
|
||||
bytes.extend_from_slice(&height.to_be_bytes());
|
||||
bytes.extend_from_slice(&[8, 6, 0, 0, 0]);
|
||||
bytes
|
||||
}
|
||||
|
||||
/// Source-image decoding and canvas rendering are separate work components.
|
||||
#[test]
|
||||
fn preview_work_counts_large_compressed_rasters_on_tiny_canvases() {
|
||||
let image = photocraft_codecs::Image::from_u8(64, 64, photocraft_codecs::ChannelLayout::Rgb, vec![255; 64 * 64 * 3]).unwrap();
|
||||
let png = photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).unwrap();
|
||||
let svg = image_svg(&embedded_image("image/png", &png));
|
||||
let (imported, work) = import_svg_bounded_with_work("work", svg.as_bytes(), 8192).unwrap();
|
||||
assert_eq!(imported.document.size, Size::new(1, 1));
|
||||
assert!(work >= 4097, "a one-pixel canvas must pay for its 4096-pixel decoded raster: {work}");
|
||||
assert!(import_svg_bounded_with_work("work", svg.as_bytes(), 4096).is_err(), "decoder work cannot leave the canvas unpaid");
|
||||
let (same, exact_work) = import_svg_bounded_with_work("work", svg.as_bytes(), work).unwrap();
|
||||
assert_eq!(exact_work, work);
|
||||
assert_eq!(same.document.size, imported.document.size);
|
||||
}
|
||||
|
||||
/// A full-size input and output may each use the existing 24 MP component allowance.
|
||||
#[test]
|
||||
fn preview_work_can_pay_for_both_a_24mp_image_and_a_24mp_canvas() {
|
||||
let image = photocraft_codecs::Image::from_u8(6000, 4000, photocraft_codecs::ChannelLayout::Rgb, vec![255; 6000 * 4000 * 3]).unwrap();
|
||||
let png = photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).unwrap();
|
||||
let data = png.iter().map(|byte| format!("%{byte:02X}")).collect::<String>();
|
||||
let svg =
|
||||
format!(r#"<svg xmlns="http://www.w3.org/2000/svg" width="6000" height="4000"><image width="6000" height="4000" href="data:image/png,{data}"/></svg>"#);
|
||||
assert!(import_svg_bounded_with_work("work", svg.as_bytes(), 48_000_000 - 1).is_err());
|
||||
let (imported, work) = import_svg_bounded_with_work("work", svg.as_bytes(), 48_000_000).unwrap();
|
||||
assert_eq!(work, 48_000_000);
|
||||
assert_eq!(imported.document.size, Size::new(6000, 4000));
|
||||
assert!(!imported.document.layers.is_empty(), "the legal component limits must still retain the display");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_work_plain_paths_and_artboard_clip_do_not_repeat_photo_decode() {
|
||||
let image = photocraft_codecs::Image::from_u8(6000, 4000, photocraft_codecs::ChannelLayout::Rgb, vec![255; 6000 * 4000 * 3]).unwrap();
|
||||
let png = photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).unwrap();
|
||||
let data = png.iter().map(|byte| format!("%{byte:02X}")).collect::<String>();
|
||||
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, 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}");
|
||||
assert_eq!(imported.document.size, Size::new(6000, 4000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_work_repeatable_raster_patterns_pay_for_repainting() {
|
||||
let image = photocraft_codecs::Image::from_u8(64, 64, photocraft_codecs::ChannelLayout::Rgb, vec![255; 64 * 64 * 3]).unwrap();
|
||||
let png = photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).unwrap();
|
||||
let data = png.iter().map(|byte| format!("%{byte:02X}")).collect::<String>();
|
||||
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, true, fonts).unwrap();
|
||||
assert!(parsed.repeatable_raster);
|
||||
assert!(
|
||||
import_svg_bounded_with_work("pattern", svg.as_bytes(), 8192).is_err(),
|
||||
"repeated pattern painting cannot be charged as one decode plus a tiny canvas"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_work_use_images_charge_each_resolved_occurrence() {
|
||||
let image = photocraft_codecs::Image::from_u8(64, 64, photocraft_codecs::ChannelLayout::Rgb, vec![255; 64 * 64 * 3]).unwrap();
|
||||
let png = photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).unwrap();
|
||||
let data = png.iter().map(|byte| format!("%{byte:02X}")).collect::<String>();
|
||||
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, 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, true, fonts).unwrap();
|
||||
assert!(!parsed.repeatable_raster && !parsed.repeatable_svg, "blur without image resources must not multiply source decoding");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_raster_intrinsic_sizes_are_rejected_before_pixel_decode() {
|
||||
let png = png_dimensions(32768, 32768);
|
||||
let jpeg = vec![0xff, 0xd8, 0xff, 0xc0, 0, 17, 8, 0x80, 0, 0x80, 0, 3, 1, 0x11, 0, 2, 0x11, 0, 3, 0x11, 0];
|
||||
let mut gif = b"GIF89a\x01\0\x01\0\0\0\0\x2c\0\0\0\0".to_vec();
|
||||
gif.extend_from_slice(&[0, 0x80, 0, 0x80, 0]);
|
||||
let mut webp = b"RIFF\x16\0\0\0WEBPVP8X\x0a\0\0\0\0\0\0\0".to_vec();
|
||||
webp.extend_from_slice(&[0xff, 0x7f, 0, 0xff, 0x7f, 0]);
|
||||
for (mime, bytes) in [("image/png", png), ("image/jpeg", jpeg), ("image/gif", gif), ("image/webp", webp)] {
|
||||
// These are dimension headers, not valid compressed pixel streams. The budget
|
||||
// error proves no downstream image decoder was asked to allocate their pixels.
|
||||
let svg = image_svg(&embedded_image(mime, &bytes));
|
||||
let error = import_svg_bounded("clipboard", svg.as_bytes(), MAX_RASTER_PIXELS).unwrap_err();
|
||||
assert!(error.to_string().contains("decoded pixel budget"), "{mime}: {error}");
|
||||
assert!(rasterize_bounded(svg.as_bytes(), 1.0, MAX_RASTER_PIXELS).unwrap_err().to_string().contains("decoded pixel budget"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_images_share_budget_and_nested_svg_cannot_hide_raster_bombs() {
|
||||
let image = embedded_image("image/png", &png_dimensions(2, 2));
|
||||
let aggregate = image_svg(&image.repeat(2));
|
||||
assert!(parse_bounded(aggregate.as_bytes(), 7).unwrap_err().to_string().contains("decoded pixel budget"));
|
||||
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().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");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webp_container_cannot_hide_a_larger_lossy_frame() {
|
||||
let mut webp = b"RIFF\x28\0\0\0WEBPVP8X\x0a\0\0\0\0\0\0\0\0\0\0\0\0\0".to_vec();
|
||||
webp.extend_from_slice(b"VP8 \x0a\0\0\0\0\0\0\x9d\x01\x2a");
|
||||
webp.extend_from_slice(&8000u16.to_le_bytes());
|
||||
webp.extend_from_slice(&8000u16.to_le_bytes());
|
||||
assert_eq!(imagesize::blob_size(&webp).unwrap().width, 1, "container claims one pixel");
|
||||
let svg = image_svg(&embedded_image("image/webp", &webp));
|
||||
assert!(parse_bounded(svg.as_bytes(), MAX_RASTER_PIXELS).unwrap_err().to_string().contains("decoded pixel budget"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounded_images_keep_small_pixels_and_external_references_stay_disabled() {
|
||||
let image = photocraft_codecs::Image::from_u8(1, 1, photocraft_codecs::ChannelLayout::Rgba, vec![255, 0, 0, 255]).unwrap();
|
||||
let bytes = photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).unwrap();
|
||||
let inner = image_svg(&embedded_image("image/png", &bytes));
|
||||
let nested = image_svg(&embedded_image("image/svg+xml", inner.as_bytes()));
|
||||
for svg in [&inner, &nested] {
|
||||
let rendered = rasterize_bounded(svg.as_bytes(), 1.0, 16).unwrap();
|
||||
assert_eq!(rendered.px, vec![[1.0, 0.0, 0.0, 1.0]]);
|
||||
}
|
||||
assert!(rasterize_bounded(nested.as_bytes(), 32.0, 16).unwrap_err().to_string().contains("rendered pixel budget"));
|
||||
let external = image_svg("<rect width=\"1\" height=\"1\" fill=\"blue\"/><image href=\"file:///never-read.png\" width=\"1\" height=\"1\"/>");
|
||||
assert_eq!(rasterize_bounded(external.as_bytes(), 1.0, 16).unwrap().px, vec![[0.0, 0.0, 1.0, 1.0]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sniffing_accepts_svg_and_rejects_other_xml() {
|
||||
assert!(is_svg(b"<svg xmlns=\"http://www.w3.org/2000/svg\"/>"));
|
||||
@@ -42,14 +198,20 @@ fn text_without_any_font_is_reported_not_silently_dropped() {
|
||||
let svg = b"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"120\" height=\"60\"><text x=\"5\" y=\"45\" font-size=\"48\">II</text></svg>";
|
||||
let parsed = parse_with(svg, no_fonts).unwrap();
|
||||
assert!(parsed.fontless_text);
|
||||
let r = import_parsed("t", parsed, MAX_GROUP_DEPTH).unwrap();
|
||||
let r = import_parsed("t", parsed, MAX_GROUP_DEPTH, None, false).unwrap();
|
||||
assert!(r.document.layers.is_empty());
|
||||
assert!(r.warnings.iter().any(|w| w.contains("font")), "{:?}", r.warnings);
|
||||
// Clipboard/import paths use the bounded parser and retain the same diagnostic.
|
||||
let (parsed, _) = parse_bounded_inner_with(svg, MAX_RASTER_PIXELS, no_fonts).unwrap();
|
||||
assert!(parsed.fontless_text);
|
||||
let r = import_parsed("t", parsed, MAX_GROUP_DEPTH, Some(MAX_RASTER_PIXELS), false).unwrap();
|
||||
assert!(r.document.layers.is_empty());
|
||||
assert!(r.warnings.iter().any(|w| w.contains("font")), "{:?}", r.warnings);
|
||||
// A drawing without text never loads or misses fonts.
|
||||
let parsed =
|
||||
parse_with(b"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"10\" height=\"10\"><rect width=\"10\" height=\"10\"/></svg>", no_fonts).unwrap();
|
||||
assert!(!parsed.fontless_text);
|
||||
let r = import_parsed("t", parsed, MAX_GROUP_DEPTH).unwrap();
|
||||
let r = import_parsed("t", parsed, MAX_GROUP_DEPTH, None, false).unwrap();
|
||||
assert!(r.warnings.iter().all(|w| !w.contains("font")), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
@@ -84,7 +246,7 @@ fn transformed_text_lands_where_resvg_draws_it() {
|
||||
assert!(!parsed.fontless_text, "{c}");
|
||||
let mut reference = tiny_skia::Pixmap::new(120, 60).unwrap();
|
||||
resvg::render(&parsed.tree, tiny_skia::Transform::default(), &mut reference.as_mut());
|
||||
let r = import_parsed("t", parsed, MAX_GROUP_DEPTH).unwrap();
|
||||
let r = import_parsed("t", parsed, MAX_GROUP_DEPTH, None, false).unwrap();
|
||||
let [layer] = r.document.layers.as_slice() else { panic!("one text layer, got {}: {c}", r.document.layers.len()) };
|
||||
assert!(matches!(layer.content, LayerContent::Group(_)), "text stays outline shapes: {c}");
|
||||
let imported = photocraft_compose::flatten(&r.document);
|
||||
@@ -178,7 +340,7 @@ fn text_in_missing_fonts_falls_back_to_an_installed_one() {
|
||||
);
|
||||
let parsed = parse_with(svg.as_bytes(), inter_only).unwrap();
|
||||
assert!(!parsed.fontless_text);
|
||||
let r = import_parsed("t", parsed, MAX_GROUP_DEPTH).unwrap();
|
||||
let r = import_parsed("t", parsed, MAX_GROUP_DEPTH, None, false).unwrap();
|
||||
assert_eq!(r.document.layers.first().map(|l| l.name.as_str()), Some("II"), "{family}: {:?}", r.warnings);
|
||||
}
|
||||
}
|
||||
@@ -201,8 +363,8 @@ fn text_in_a_font_on_disk_is_set_from_memory() {
|
||||
let parsed = parse_with(svg, inter_on_disk).unwrap();
|
||||
let faces: Vec<_> = parsed.tree.fontdb().faces().map(|f| matches!(f.source, usvg::fontdb::Source::Binary(_))).collect();
|
||||
assert_eq!(faces, [true], "the face text was set in is held in memory");
|
||||
let on_disk = import_parsed("t", parsed, MAX_GROUP_DEPTH).unwrap();
|
||||
let in_memory = import_parsed("t", parse_with(svg, inter_only).unwrap(), MAX_GROUP_DEPTH).unwrap();
|
||||
let on_disk = import_parsed("t", parsed, MAX_GROUP_DEPTH, None, false).unwrap();
|
||||
let in_memory = import_parsed("t", parse_with(svg, inter_only).unwrap(), MAX_GROUP_DEPTH, None, false).unwrap();
|
||||
assert_eq!(on_disk.document.layers.first().map(|l| l.name.as_str()), Some("IHI"));
|
||||
let outlines = |r: &ImportResult| {
|
||||
let groups = r.document.layers.iter().filter_map(|l| if let LayerContent::Group(g) = &l.content { Some(g) } else { None });
|
||||
@@ -238,7 +400,7 @@ fn a_font_file_gone_bad_since_indexing_is_not_cached() {
|
||||
let parsed = parse_with(svg, indexed_then_overwritten).unwrap();
|
||||
let sources: Vec<_> = parsed.tree.fontdb().faces().map(|f| matches!(f.source, usvg::fontdb::Source::Binary(_))).collect();
|
||||
assert_eq!(sources, [true], "the whole file is read into memory now");
|
||||
let layers = import_parsed("t", parsed, MAX_GROUP_DEPTH).unwrap().document.layers;
|
||||
let layers = import_parsed("t", parsed, MAX_GROUP_DEPTH, None, false).unwrap().document.layers;
|
||||
assert_eq!(layers.first().map(|l| l.name.as_str()), Some("IHI"));
|
||||
assert!(
|
||||
layers.iter().any(|l| matches!(&l.content, LayerContent::Group(g) if g.children.iter().any(|c| matches!(&c.content, LayerContent::Shape(s) if !s.path.subpaths.is_empty())))),
|
||||
@@ -436,6 +598,88 @@ fn rgba8_renders_at_its_own_size_within_the_pixel_limit() {
|
||||
assert!(rasterize_rgba8(b"<svg xmlns=\"http://www.w3.org/2000/svg\"><rect").is_err(), "malformed markup is an error");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounded_import_preserves_shapes_at_budget_and_rasterises_aggregate_overflow() {
|
||||
let bytes = br#"<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8"><rect width="8" height="8" fill="red"/><rect x="4" width="4" height="8" fill="blue"/></svg>"#;
|
||||
let at_limit = import_svg_bounded("bounded", bytes, 128).unwrap();
|
||||
assert_eq!(at_limit.document.layers.len(), 2);
|
||||
assert!(at_limit.document.layers.iter().all(|l| matches!(l.content, LayerContent::Shape(_))));
|
||||
let over = import_svg_bounded("bounded", bytes, 127).unwrap();
|
||||
assert!(import_svg_bounded_layers("bounded", bytes, 127).unwrap_err().to_string().contains("cache budget"));
|
||||
assert_eq!(over.document.layers.len(), 1);
|
||||
assert!(matches!(over.document.layers[0].content, LayerContent::Raster(_)));
|
||||
assert!(over.warnings.iter().any(|w| w.contains("cache budget") && w.contains("editable shapes")));
|
||||
let surface = over.document.layers[0].surface().unwrap();
|
||||
assert_eq!(surface.read_region(Rect::new(1, 1, 2, 2)), vec![1.0, 0.0, 0.0, 1.0]);
|
||||
assert_eq!(surface.read_region(Rect::new(6, 1, 7, 2)), vec![0.0, 0.0, 1.0, 1.0]);
|
||||
// Ordinary file imports retain their existing editable-layer behavior.
|
||||
assert_eq!(import_svg("unbounded", bytes).unwrap().document.layers.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounded_import_rejects_canvas_before_rendering_and_respects_raster_limit() {
|
||||
let bytes = br#"<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8"><rect width="8" height="8"/></svg>"#;
|
||||
assert!(import_svg_bounded("bounded", bytes, 0).is_err());
|
||||
assert!(import_svg_bounded("bounded", bytes, 63).is_err());
|
||||
assert!(import_svg_bounded("bounded", bytes, 64).is_ok());
|
||||
let oversized = br#"<svg xmlns="http://www.w3.org/2000/svg" width="5000" height="5000"><rect width="5000" height="5000"/></svg>"#;
|
||||
assert!(import_svg_bounded("bounded", oversized, u64::MAX).unwrap_err().to_string().contains("cache budget"));
|
||||
}
|
||||
|
||||
/// The smart-object budget is 24,000,000 pixels: a total exactly equal to it fits, one more
|
||||
/// node of caches does not. Bounds-only callers keep a frame past the layer-cache budget, and
|
||||
/// the canvas itself is limited at the same exact boundary.
|
||||
#[test]
|
||||
fn bounded_canvas_bounds_match_the_import_at_the_exact_24_mp_boundary() {
|
||||
const BUDGET: u64 = 24_000_000;
|
||||
let rects = |n: usize| {
|
||||
format!("<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"2000\" height=\"2000\">{}</svg>", "<rect width=\"2000\" height=\"2000\"/>".repeat(n))
|
||||
};
|
||||
// 2000 × 2000 × 6 = 24,000,000: exactly at the budget, still editable layers.
|
||||
let at_budget = rects(6);
|
||||
let strict = import_svg_bounded_layers("at", at_budget.as_bytes(), BUDGET).unwrap();
|
||||
assert_eq!(strict.document.layers.len(), 6);
|
||||
assert_eq!(bounded_canvas_bounds(at_budget.as_bytes(), BUDGET).unwrap(), strict.document.bounds());
|
||||
// A seventh node is 28,000,000 pixels of caches: strict layers refuse it, bounds do not.
|
||||
let over = rects(7);
|
||||
assert!(import_svg_bounded_layers("over", over.as_bytes(), BUDGET).unwrap_err().to_string().contains("cache budget"));
|
||||
assert_eq!(bounded_canvas_bounds(over.as_bytes(), BUDGET).unwrap(), Rect::new(0, 0, 2000, 2000));
|
||||
// The canvas: 4000 × 6000 is exactly 24 MP and fits; 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>");
|
||||
assert_eq!(bounded_canvas_bounds(canvas(6000).as_bytes(), BUDGET).unwrap(), Rect::new(0, 0, 4000, 6000));
|
||||
assert!(bounded_canvas_bounds(canvas(6001).as_bytes(), BUDGET).unwrap_err().to_string().contains("cache budget"));
|
||||
// A larger request never lifts the 24 MP rasterisation limit, and parse budgets still apply.
|
||||
assert!(bounded_canvas_bounds(canvas(6001).as_bytes(), u64::MAX).is_err());
|
||||
assert!(bounded_canvas_bounds(b"<svg", BUDGET).is_err());
|
||||
let huge_image = format!(
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1\" height=\"1\">{}</svg>",
|
||||
embedded_image(
|
||||
"image/png",
|
||||
&[b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".as_slice(), &32768u32.to_be_bytes(), &32768u32.to_be_bytes(), &[8, 6, 0, 0, 0]].concat()
|
||||
)
|
||||
);
|
||||
assert!(bounded_canvas_bounds(huge_image.as_bytes(), BUDGET).is_err(), "decoded images keep their budget");
|
||||
}
|
||||
/// A one-font database reproduces Linux hosts without Times New Roman. A missing authored
|
||||
/// family must still produce text geometry; it must not silently disappear on save-back.
|
||||
#[test]
|
||||
fn missing_svg_font_uses_an_available_generic_instead_of_dropping_text() {
|
||||
fn inter() -> Arc<usvg::fontdb::Database> {
|
||||
let mut db = usvg::fontdb::Database::new();
|
||||
db.load_font_data(include_bytes!("../../../../assets/fonts/Inter-Regular.ttf").to_vec());
|
||||
resolve_generic_families(&mut db, |_| None);
|
||||
Arc::new(db)
|
||||
}
|
||||
let svg=br#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="30"><text x="2" y="24" font-size="20" font-family="Unavailable Authored Font">LOGO</text></svg>"#;
|
||||
let parsed = parse_with(svg, inter).unwrap();
|
||||
assert!(!parsed.fontless_text);
|
||||
assert!(!parsed.tree.root().children().is_empty(), "missing-family fallback must preserve text geometry");
|
||||
let mut db = (*inter()).clone();
|
||||
let before = db.query(&usvg::fontdb::Query { families: &[usvg::fontdb::Family::Serif], ..Default::default() });
|
||||
resolve_generic_families(&mut db, |_| None);
|
||||
assert_eq!(db.query(&usvg::fontdb::Query { families: &[usvg::fontdb::Family::Serif], ..Default::default() }), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_data_rejects_oversized_and_malformed_embedded_png_and_webp() {
|
||||
let opt = options(Arc::new(usvg::fontdb::Database::new()), MAX_XML_DEPTH);
|
||||
@@ -497,3 +741,169 @@ fn resolve_data_rejects_nested_gzip_and_handles_text_plain_svgz() {
|
||||
assert!(resolve("image/svg+xml", Arc::new(twice.clone()), &opt).is_none());
|
||||
assert!(resolve("text/plain", Arc::new(twice), &opt).is_none());
|
||||
}
|
||||
|
||||
/// The outer gzip is size-limited by PhotoCraft. Its result must be XML, never another
|
||||
/// compressed stream which usvg could inflate independently without that byte limit.
|
||||
#[test]
|
||||
fn bounded_svg_rejects_nested_gzip_at_top_level_and_inside_data_urls() {
|
||||
use std::io::Write;
|
||||
let gz = |bytes: &[u8]| {
|
||||
let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
|
||||
encoder.write_all(bytes).unwrap();
|
||||
encoder.finish().unwrap()
|
||||
};
|
||||
let svg = b"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"4\" height=\"4\"><rect width=\"4\" height=\"4\"/></svg>";
|
||||
let once = gz(svg);
|
||||
let twice = gz(&once);
|
||||
assert!(parse_bounded(&once, 64).is_ok(), "ordinary SVGZ remains accepted");
|
||||
assert!(parse_bounded(&twice, 64).unwrap_err().to_string().contains("UTF-8"));
|
||||
assert!(rasterize_bounded(&twice, 1.0, 64).is_err());
|
||||
assert!(bounded_canvas_bounds(&twice, 64).is_err());
|
||||
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().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());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,567 @@
|
||||
//! PhotoCraft's private PSD display-preview block. The linked-layer payload is never replaced.
|
||||
//!
|
||||
//! `pcVP` carries a versioned header, BLAKE3 of the native AI/PDF/EPS source, the SVG byte
|
||||
//! length and a bounded display-only SVG. Other readers may ignore it and use PSD cached pixels.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use photocraft_doc::VectorPreview;
|
||||
use photocraft_geom::Size;
|
||||
|
||||
pub const PSD_KEY: [u8; 4] = *b"pcVP";
|
||||
const HEADER: &[u8; 8] = b"PCVP\0\0\0\x01";
|
||||
const MAX_BYTES: usize = 16 * 1024 * 1024;
|
||||
const MAX_PIXELS: u64 = 24_000_000;
|
||||
const PREFIX: usize = 44;
|
||||
|
||||
/// Recognize native-vector payloads by content; names never authorize a preview.
|
||||
pub fn is_native_source(source: &[u8]) -> bool {
|
||||
source.starts_with(b"%PDF-") || source.starts_with(b"%!PS-Adobe-") || source.starts_with(&[0xc5, 0xd0, 0xd3, 0xc6])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! { static HASHED_SOURCE_BYTES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) }; }
|
||||
|
||||
/// Hash only a supported, bounded source. PSD import caches this result per source UUID.
|
||||
pub(crate) fn source_hash(source: &[u8]) -> Option<[u8; 32]> {
|
||||
if source.is_empty() || source.len() > MAX_BYTES || !is_native_source(source) {
|
||||
return None;
|
||||
}
|
||||
#[cfg(test)]
|
||||
HASHED_SOURCE_BYTES.with(|count| count.set(count.get().saturating_add(source.len())));
|
||||
Some(*blake3::hash(source).as_bytes())
|
||||
}
|
||||
|
||||
/// Verify the source binding and render limits before retaining a preview from a file.
|
||||
pub fn validate(preview: &VectorPreview, source: &[u8]) -> Option<Size> {
|
||||
document(preview, source).map(|document| document.size)
|
||||
}
|
||||
|
||||
/// Recover only the unitless source-pixel frame of a normalized preview, without drawing it.
|
||||
/// The caller must compute `expected_source_hash` from the actual immutable native source;
|
||||
/// the preview's claimed digest alone never establishes that binding. Rendering callers must
|
||||
/// still use [`validate`] before drawing. Unsupported metadata leaves the PSD frame available.
|
||||
pub fn frame_metadata(preview: &VectorPreview, expected_source_hash: [u8; 32]) -> Option<(f64, f64)> {
|
||||
if preview.source_hash != expected_source_hash || preview.svg.is_empty() || preview.svg.len() > MAX_BYTES || !crate::svg::is_svg(&preview.svg) {
|
||||
return None;
|
||||
}
|
||||
let text = std::str::from_utf8(&preview.svg).ok()?;
|
||||
let parsed = roxmltree::Document::parse(text).ok()?;
|
||||
let root = parsed.root_element();
|
||||
if root.tag_name().name() != "svg" {
|
||||
return None;
|
||||
}
|
||||
let dimension = |key| root.attribute(key)?.parse::<f64>().ok();
|
||||
let (width, height) = dimension("width").zip(dimension("height"))?;
|
||||
(width.is_finite() && height.is_finite() && width > 0.0 && height > 0.0 && width <= 16384.0 && height <= 16384.0 && width * height <= MAX_PIXELS as f64)
|
||||
.then_some((width, height))
|
||||
}
|
||||
|
||||
/// Exact source-pixel frame of a normalized preview. Native PSD dimensions can be fractional;
|
||||
/// the integer display canvas alone must not replace their placement coordinate system.
|
||||
pub fn frame(preview: &VectorPreview, source: &[u8]) -> Option<(f64, f64)> {
|
||||
frame_for_svg(&preview.svg, validate(preview, source)?)
|
||||
}
|
||||
|
||||
/// The source-pixel frame written by a preview's root `width`/`height`, accepted only when it
|
||||
/// rounds to the validated display canvas `size`. Without unitless dimensions the display canvas
|
||||
/// itself is the frame; non-finite, oversized or mismatched dimensions give `None`.
|
||||
pub(crate) fn frame_for_svg(svg: &[u8], size: Size) -> Option<(f64, f64)> {
|
||||
let text = std::str::from_utf8(svg).ok()?;
|
||||
let parsed = roxmltree::Document::parse(text).ok()?;
|
||||
let root = parsed.root_element();
|
||||
let dimension = |key| root.attribute(key).and_then(|value| value.parse::<f64>().ok());
|
||||
let Some((width, height)) = dimension("width").zip(dimension("height")) else {
|
||||
// A general SVG may spell lengths in physical units. Its validated display canvas is
|
||||
// the fallback; our normalized native previews always write unitless source pixels.
|
||||
return Some((f64::from(size.width), f64::from(size.height)));
|
||||
};
|
||||
if !width.is_finite() || !height.is_finite() || width <= 0.0 || height <= 0.0 || width > 16384.0 || height > 16384.0 || width * height > MAX_PIXELS as f64 {
|
||||
return None;
|
||||
}
|
||||
// Match svg::canvas's f32 conversion, minimum size and rounding exactly. A mismatched
|
||||
// viewport cannot supply trusted source geometry even when the SVG itself can render.
|
||||
let rounded = |value: f64| ((value as f32).max(1.0).round() as u32).clamp(1, 16384);
|
||||
(rounded(width) == size.width && rounded(height) == size.height).then_some((width, height))
|
||||
}
|
||||
|
||||
/// Bounded display document used for PSD smart-filter source caches, never as embedded contents.
|
||||
pub(crate) fn document(preview: &VectorPreview, source: &[u8]) -> Option<photocraft_doc::Document> {
|
||||
if preview.source_hash != source_hash(source)? {
|
||||
return None;
|
||||
}
|
||||
display_document(&preview.svg)
|
||||
}
|
||||
|
||||
/// Bounded preview parsing after the caller has checked the original-source binding.
|
||||
fn display_document(svg: &[u8]) -> Option<photocraft_doc::Document> {
|
||||
display_document_with_budget(svg, 4 * MAX_PIXELS)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! { static RENDERED_PREVIEWS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) }; }
|
||||
|
||||
/// Keep callers that need only a display document separate from aggregate-work accounting.
|
||||
fn display_document_with_budget(svg: &[u8], max_pixels: u64) -> Option<photocraft_doc::Document> {
|
||||
display_document_with_work(svg, max_pixels).map(|(document, _)| document)
|
||||
}
|
||||
|
||||
/// Validate a display-only SVG and report the decode/render allowance it consumed.
|
||||
fn display_document_with_work(svg: &[u8], max_pixels: u64) -> Option<(photocraft_doc::Document, u64)> {
|
||||
if max_pixels == 0 || svg.is_empty() || svg.len() > MAX_BYTES || !crate::svg::is_svg(svg) {
|
||||
return None;
|
||||
}
|
||||
#[cfg(test)]
|
||||
RENDERED_PREVIEWS.with(|count| count.set(count.get().saturating_add(1)));
|
||||
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.
|
||||
pub fn encode(preview: &VectorPreview, source: &[u8]) -> Option<Vec<u8>> {
|
||||
validate(preview, source)?;
|
||||
block_bytes(preview)
|
||||
}
|
||||
|
||||
/// The `pcVP` blob of a preview whose source binding and SVG the caller has validated.
|
||||
fn block_bytes(preview: &VectorPreview) -> Option<Vec<u8>> {
|
||||
let mut out = Vec::with_capacity(PREFIX + preview.svg.len() + 3);
|
||||
out.extend_from_slice(HEADER);
|
||||
out.extend_from_slice(&preview.source_hash);
|
||||
out.extend_from_slice(&u32::try_from(preview.svg.len()).ok()?.to_be_bytes());
|
||||
out.extend_from_slice(&preview.svg);
|
||||
out.resize(out.len().next_multiple_of(4), 0);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Reject unknown versions, stale hashes, malformed SVG, oversize payloads and trailing data.
|
||||
pub fn decode(block: &[u8], source: &[u8]) -> Option<VectorPreview> {
|
||||
decode_with_hash(block, source_hash(source)?)
|
||||
}
|
||||
|
||||
/// PSD imports reuse the hash of their immutable, bounded embedded source. This function
|
||||
/// never hashes the large source again and still verifies every block's source binding.
|
||||
pub(crate) fn decode_with_hash(block: &[u8], expected_hash: [u8; 32]) -> Option<VectorPreview> {
|
||||
decode_with_hash_budget(block, expected_hash, 4 * MAX_PIXELS).map(|(preview, _)| preview)
|
||||
}
|
||||
|
||||
/// Check the bounded private block before rendering, retaining its actual validation charge.
|
||||
fn decode_with_hash_budget(block: &[u8], expected_hash: [u8; 32], max_pixels: u64) -> Option<(VectorPreview, u64)> {
|
||||
if block.len() > PREFIX + MAX_BYTES + 3 || block.get(..8)? != HEADER {
|
||||
return None;
|
||||
}
|
||||
let source_hash: [u8; 32] = block.get(8..40)?.try_into().ok()?;
|
||||
let length = usize::try_from(u32::from_be_bytes(block.get(40..44)?.try_into().ok()?)).ok()?;
|
||||
if length == 0 || length > MAX_BYTES {
|
||||
return None;
|
||||
}
|
||||
let end = PREFIX.checked_add(length)?;
|
||||
if block.len() != end.next_multiple_of(4) || block.get(end..)?.iter().any(|byte| *byte != 0) {
|
||||
return None;
|
||||
}
|
||||
// Reject mismatched native contents before allocating or parsing any SVG data.
|
||||
if source_hash != expected_hash {
|
||||
return None;
|
||||
}
|
||||
let svg = block.get(PREFIX..end)?;
|
||||
let (_, work) = display_document_with_work(svg, max_pixels)?;
|
||||
Some((VectorPreview { source_hash, svg: Arc::new(svg.to_vec()) }, work))
|
||||
}
|
||||
|
||||
/// One PSD import's source/preview identities and aggregate validation budget. Cached instances
|
||||
/// share the preview allocation and do not spend the work budget again.
|
||||
pub(crate) struct ImportCache {
|
||||
source_hashes: std::collections::HashMap<String, Option<[u8; 32]>>,
|
||||
previews: std::collections::HashMap<([u8; 32], [u8; 32]), Option<VectorPreview>>,
|
||||
remaining_pixels: u64,
|
||||
}
|
||||
|
||||
impl Default for ImportCache {
|
||||
/// One file import's cache with the standard 96 MP aggregate preview-work allowance.
|
||||
fn default() -> Self {
|
||||
Self::new(96_000_000)
|
||||
}
|
||||
}
|
||||
|
||||
impl ImportCache {
|
||||
/// Start one file operation with an independent aggregate preview-work allowance.
|
||||
fn new(max_pixels: u64) -> Self {
|
||||
Self { source_hashes: Default::default(), previews: Default::default(), remaining_pixels: max_pixels }
|
||||
}
|
||||
|
||||
/// The source callback borrows the first matching immutable linked-layer payload. No disk
|
||||
/// paths are followed, and an exhausted import does not hash additional unknown sources.
|
||||
pub(crate) fn decode<'a>(&mut self, id: &str, block: &[u8], source: impl FnOnce() -> Option<&'a [u8]>) -> Option<VectorPreview> {
|
||||
if block.len() > PREFIX + MAX_BYTES + 3 || block.get(..8)? != HEADER {
|
||||
return None;
|
||||
}
|
||||
if self.remaining_pixels == 0 && !self.source_hashes.contains_key(id) {
|
||||
return None;
|
||||
}
|
||||
let hash = (*self.source_hashes.entry(id.to_string()).or_insert_with(|| source_hash(source()?)))?;
|
||||
let key = (hash, *blake3::hash(block).as_bytes());
|
||||
if let Some(cached) = self.previews.get(&key) {
|
||||
return cached.clone();
|
||||
}
|
||||
let limit = self.remaining_pixels;
|
||||
let decoded = decode_with_hash_budget(block, hash, limit);
|
||||
let preview = match decoded {
|
||||
Some((preview, work)) => {
|
||||
self.remaining_pixels = self.remaining_pixels.saturating_sub(work);
|
||||
Some(preview)
|
||||
}
|
||||
None => {
|
||||
// A failed parse can already have decoded images. Reserve its full attempted
|
||||
// allowance, even when no display document was returned.
|
||||
self.remaining_pixels = self.remaining_pixels.saturating_sub(limit);
|
||||
None
|
||||
}
|
||||
};
|
||||
self.previews.insert(key, preview.clone());
|
||||
preview
|
||||
}
|
||||
}
|
||||
|
||||
/// A native preview validated once per PSD export: its bounded display document (the smart-filter
|
||||
/// source cache), the exact source-pixel frame (`None` when the SVG's own size does not supply
|
||||
/// one) and the encoded `pcVP` block.
|
||||
pub(crate) struct Exported {
|
||||
pub(crate) document: photocraft_doc::Document,
|
||||
pub(crate) frame: Option<(f64, f64)>,
|
||||
pub(crate) block: Vec<u8>,
|
||||
}
|
||||
|
||||
type SourceIdentity = (usize, usize, usize);
|
||||
type SourceEntry = (Arc<Vec<u8>>, Option<[u8; 32]>);
|
||||
type PreviewIdentity = ([u8; 32], [u8; 32]);
|
||||
|
||||
/// One PSD export's source/preview identities and aggregate validation budget, mirroring
|
||||
/// [`ImportCache`]. Instances sharing one immutable source and preview allocation hash and render
|
||||
/// once; distinct previews share the import's 96 MP budget.
|
||||
pub(crate) struct ExportCache {
|
||||
/// Hash of the actual source bytes by (owning allocation, offset, length). Each entry keeps
|
||||
/// its owner alive, so an address can never be reused for other bytes during the export.
|
||||
sources: std::collections::HashMap<SourceIdentity, SourceEntry>,
|
||||
/// Content hash of each preview SVG allocation, kept alive in the same way.
|
||||
svgs: std::collections::HashMap<usize, (Arc<Vec<u8>>, [u8; 32])>,
|
||||
previews: std::collections::HashMap<PreviewIdentity, Option<Arc<Exported>>>,
|
||||
remaining_pixels: u64,
|
||||
}
|
||||
|
||||
impl Default for ExportCache {
|
||||
/// One PSD export's cache with the standard 96 MP aggregate preview-work allowance.
|
||||
fn default() -> Self {
|
||||
Self::new(96_000_000)
|
||||
}
|
||||
}
|
||||
|
||||
impl ExportCache {
|
||||
/// Start one file operation with an independent aggregate preview-work allowance.
|
||||
fn new(max_pixels: u64) -> Self {
|
||||
Self { sources: Default::default(), svgs: Default::default(), previews: Default::default(), remaining_pixels: max_pixels }
|
||||
}
|
||||
|
||||
/// The validated preview of `source`, a byte range of the immutable `owner`. The preview's
|
||||
/// claimed hash is compared with the hash of those actual bytes on every call; only that
|
||||
/// hash and the render are reused.
|
||||
pub(crate) fn get(&mut self, preview: &VectorPreview, owner: &Arc<Vec<u8>>, source: &[u8]) -> Option<Arc<Exported>> {
|
||||
let base = owner.as_ptr().addr();
|
||||
let offset = source.as_ptr().addr().checked_sub(base)?;
|
||||
// Only a range of the pinned owner may be identified by its address.
|
||||
if offset.checked_add(source.len())? > owner.len() || preview.svg.is_empty() || preview.svg.len() > MAX_BYTES {
|
||||
return None;
|
||||
}
|
||||
let key = (base, offset, source.len());
|
||||
let svg_key = Arc::as_ptr(&preview.svg).addr();
|
||||
// An exhausted export does not hash additional unknown sources or previews.
|
||||
if self.remaining_pixels == 0 && !(self.sources.contains_key(&key) && self.svgs.contains_key(&svg_key)) {
|
||||
return None;
|
||||
}
|
||||
let hash = self.sources.entry(key).or_insert_with(|| (Arc::clone(owner), source_hash(source))).1?;
|
||||
if preview.source_hash != hash {
|
||||
return None;
|
||||
}
|
||||
let svg_hash = self.svgs.entry(svg_key).or_insert_with(|| (Arc::clone(&preview.svg), *blake3::hash(&preview.svg).as_bytes())).1;
|
||||
if let Some(cached) = self.previews.get(&(hash, svg_hash)) {
|
||||
return cached.clone();
|
||||
}
|
||||
let limit = self.remaining_pixels;
|
||||
let exported = match display_document_with_work(&preview.svg, limit) {
|
||||
Some((document, work)) => {
|
||||
self.remaining_pixels = self.remaining_pixels.saturating_sub(work);
|
||||
let frame = frame_for_svg(&preview.svg, document.size);
|
||||
block_bytes(preview).map(|block| Arc::new(Exported { document, frame, block }))
|
||||
}
|
||||
None => {
|
||||
// Failed validation may already have spent its whole decode allowance.
|
||||
self.remaining_pixels = self.remaining_pixels.saturating_sub(limit);
|
||||
None
|
||||
}
|
||||
};
|
||||
self.previews.insert((hash, svg_hash), exported.clone());
|
||||
exported
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
/// Frame recovery parses bounded geometry only; display validation remains a separate gate.
|
||||
#[test]
|
||||
fn metadata_frame_is_source_bound_and_does_not_decode_old_preview_images() {
|
||||
let source = b"%PDF-1.7\noriginal native vector";
|
||||
let hash = *blake3::hash(source).as_bytes();
|
||||
let mut preview = photocraft_doc::VectorPreview {
|
||||
source_hash: hash,
|
||||
svg: std::sync::Arc::new(
|
||||
br#"<svg xmlns="http://www.w3.org/2000/svg" width="8.3" height="6.2"><image href="data:image/png;base64,AAAA" width="8" height="6"/></svg>"#
|
||||
.to_vec(),
|
||||
),
|
||||
};
|
||||
assert_eq!(super::frame_metadata(&preview, hash), Some((8.3, 6.2)));
|
||||
assert!(super::validate(&preview, source).is_none(), "malformed image data is still rejected by actual display validation");
|
||||
assert!(super::frame_metadata(&preview, [0; 32]).is_none());
|
||||
for (width, height) in [("8pt", "6"), ("NaN", "6"), ("0", "6"), ("16384", "16384")] {
|
||||
preview.svg = std::sync::Arc::new(format!(r#"<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}"/>"#).into_bytes());
|
||||
assert!(super::frame_metadata(&preview, hash).is_none(), "unsupported frame {width} by {height}");
|
||||
}
|
||||
}
|
||||
use super::*;
|
||||
|
||||
const SOURCE: &[u8] = b"%PDF-1.7\nprivate native source";
|
||||
const SVG: &[u8] = br#"<svg xmlns="http://www.w3.org/2000/svg" width="8" height="6"><rect width="8" height="6" fill="red"/></svg>"#;
|
||||
|
||||
fn preview() -> VectorPreview {
|
||||
VectorPreview { source_hash: *blake3::hash(SOURCE).as_bytes(), svg: Arc::new(SVG.to_vec()) }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_roundtrip_binds_preview_to_exact_native_contents() {
|
||||
let preview = preview();
|
||||
let block = encode(&preview, SOURCE).unwrap();
|
||||
assert_eq!(decode(&block, SOURCE), Some(preview));
|
||||
assert!(decode(&block, b"%PDF-1.7\nchanged source").is_none());
|
||||
assert!(decode(&block, SVG).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_preview_blocks_are_ignored_without_losing_the_native_source() {
|
||||
let good = encode(&preview(), SOURCE).unwrap();
|
||||
for truncated in [0, 7, 39, 43, good.len() - 1] {
|
||||
assert!(decode(&good[..truncated], SOURCE).is_none());
|
||||
}
|
||||
let mut bad = good.clone();
|
||||
bad[7] = 2;
|
||||
assert!(decode(&bad, SOURCE).is_none());
|
||||
bad = good.clone();
|
||||
bad[40..44].copy_from_slice(&u32::MAX.to_be_bytes());
|
||||
assert!(decode(&bad, SOURCE).is_none());
|
||||
bad = good;
|
||||
bad.extend_from_slice(&[0; 4]);
|
||||
assert!(decode(&bad, SOURCE).is_none());
|
||||
let mut preview = preview();
|
||||
preview.svg = Arc::new(b"not SVG".to_vec());
|
||||
assert!(encode(&preview, SOURCE).is_none());
|
||||
preview.svg = Arc::new(br#"<svg xmlns="http://www.w3.org/2000/svg" width="16384" height="16384"/>"#.to_vec());
|
||||
assert!(encode(&preview, SOURCE).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_psd_instances_hash_the_native_payload_once_and_share_the_validated_preview() {
|
||||
use photocraft_color::{ColorMode, SampleType};
|
||||
use photocraft_doc::{Document, Layer, LayerContent, SmartObject, SmartSource};
|
||||
use photocraft_geom::Affine;
|
||||
use photocraft_raster::Surface;
|
||||
|
||||
let mut source = vec![b' '; MAX_BYTES];
|
||||
source[..SOURCE.len()].copy_from_slice(SOURCE);
|
||||
let preview = VectorPreview { source_hash: *blake3::hash(&source).as_bytes(), svg: Arc::new(SVG.to_vec()) };
|
||||
let mut doc = Document::new("shared preview", Size::new(8, 6), ColorMode::Rgb, SampleType::U8);
|
||||
let mut smart = SmartObject::new(
|
||||
SmartSource::Embedded { file_name: "Artwork.ai".into(), bytes: Arc::new(source.clone()) },
|
||||
Affine::IDENTITY,
|
||||
Some(Surface::new(doc.pixel_format())),
|
||||
);
|
||||
smart.vector_preview = Some(preview.clone());
|
||||
doc.layers.push(Layer::new("native", LayerContent::Smart(smart)));
|
||||
let out = crate::export(&doc, "psd", &Default::default()).unwrap();
|
||||
let mut file = photocraft_psd::PsdFile::from_bytes(&out.bytes).unwrap();
|
||||
let record = file.layers()[0].clone();
|
||||
*file.layers_mut() = vec![record; 100];
|
||||
let before = HASHED_SOURCE_BYTES.with(std::cell::Cell::get);
|
||||
let (back, _) = crate::psd_to_document(&file);
|
||||
let hashed = HASHED_SOURCE_BYTES.with(std::cell::Cell::get) - before;
|
||||
assert_eq!(hashed, source.len(), "a shared 16 MiB payload must not be hashed per layer");
|
||||
let previews: Vec<_> = back
|
||||
.layers
|
||||
.iter()
|
||||
.map(|layer| {
|
||||
let LayerContent::Smart(smart) = &layer.content else { panic!("smart object lost") };
|
||||
smart.vector_preview.as_ref().expect("preview lost")
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(previews.len(), 100);
|
||||
assert!(previews.iter().all(|actual| **actual == preview));
|
||||
assert!(previews.iter().all(|actual| Arc::ptr_eq(&actual.svg, &previews[0].svg)), "shared layers retain one SVG allocation");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn psd_export_hashes_and_renders_a_shared_native_preview_once_for_all_instances() {
|
||||
use photocraft_color::{ColorMode, SampleType};
|
||||
use photocraft_doc::{Document, Layer, LayerContent, SmartObject, SmartSource};
|
||||
use photocraft_geom::Affine;
|
||||
use photocraft_raster::Surface;
|
||||
|
||||
let counters = || {
|
||||
(
|
||||
HASHED_SOURCE_BYTES.with(std::cell::Cell::get),
|
||||
RENDERED_PREVIEWS.with(std::cell::Cell::get),
|
||||
crate::psd_export::EMBED_HASHED_SOURCE_BYTES.with(std::cell::Cell::get),
|
||||
)
|
||||
};
|
||||
let mut source = vec![b' '; 256 * 1024];
|
||||
source[..SOURCE.len()].copy_from_slice(SOURCE);
|
||||
let preview = VectorPreview { source_hash: *blake3::hash(&source).as_bytes(), svg: Arc::new(SVG.to_vec()) };
|
||||
let mut doc = Document::new("shared preview", Size::new(8, 6), ColorMode::Rgb, SampleType::U8);
|
||||
let mut smart = SmartObject::new(
|
||||
SmartSource::Embedded { file_name: "Artwork.ai".into(), bytes: Arc::new(source.clone()) },
|
||||
Affine::IDENTITY,
|
||||
Some(Surface::new(doc.pixel_format())),
|
||||
);
|
||||
smart.vector_preview = Some(preview.clone());
|
||||
for _ in 0..100 {
|
||||
doc.layers.push(Layer::new("native", LayerContent::Smart(smart.clone())));
|
||||
}
|
||||
let check = |bytes: &[u8]| {
|
||||
let back = crate::import("shared.psd", bytes).unwrap().document;
|
||||
assert_eq!(back.layers.len(), 100);
|
||||
for layer in &back.layers {
|
||||
let LayerContent::Smart(sm) = &layer.content else { panic!("smart object lost") };
|
||||
assert_eq!(sm.vector_preview.as_ref(), Some(&preview), "valid preview lost");
|
||||
let SmartSource::Linked { path } = &sm.source else { panic!("expected the PSD embedded-file table") };
|
||||
assert_eq!(crate::linked::embedded_payload(&back.metadata, path).unwrap().1, source.as_slice(), "original native bytes changed");
|
||||
}
|
||||
back
|
||||
};
|
||||
|
||||
// Embedded instances share one source allocation.
|
||||
let before = counters();
|
||||
let out = crate::export(&doc, "psd", &Default::default()).unwrap();
|
||||
let after = counters();
|
||||
assert_eq!(after.0 - before.0, source.len(), "the shared native source must not be hashed per layer or per use");
|
||||
assert_eq!(after.1 - before.1, 1, "the shared preview must not be rendered per layer or per use");
|
||||
assert_eq!(after.2 - before.2, source.len(), "the conversion cache must not re-hash the shared Embedded owner per layer");
|
||||
let file = photocraft_psd::PsdFile::from_bytes(&out.bytes).unwrap();
|
||||
assert!(file.layers().iter().all(|layer| layer.block(&PSD_KEY).is_some()));
|
||||
let back = check(&out.bytes);
|
||||
|
||||
// Reopened instances link to one preserved embedded file of the document.
|
||||
let before = counters();
|
||||
let again = crate::export(&back, "psd", &Default::default()).unwrap();
|
||||
let after = counters();
|
||||
assert_eq!(after.0 - before.0, source.len(), "the preserved linked payload must not be hashed per layer or per use");
|
||||
assert_eq!(after.1 - before.1, 1, "the reopened shared preview must not be rendered per layer or per use");
|
||||
assert_eq!(after.2 - before.2, 0, "preserved linked-table sources need no Embedded conversion hash");
|
||||
check(&again.bytes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_cache_checks_actual_source_bytes_and_bounds_distinct_previews() {
|
||||
let owner = Arc::new(SOURCE.to_vec());
|
||||
let red = preview();
|
||||
let mut blue = red.clone();
|
||||
blue.svg = Arc::new(String::from_utf8(SVG.to_vec()).unwrap().replace("red", "blue").into_bytes());
|
||||
// A claimed hash of other bytes is never trusted, even after the real source was cached.
|
||||
let mut stale = red.clone();
|
||||
stale.source_hash = *blake3::hash(b"%PDF-1.7\nother").as_bytes();
|
||||
let changed = Arc::new(b"%PDF-1.7\nprivate native sourcf".to_vec());
|
||||
let mut cache = ExportCache::new(95);
|
||||
assert!(cache.get(&stale, &owner, owner.as_slice()).is_none());
|
||||
let first = cache.get(&red, &owner, owner.as_slice()).unwrap();
|
||||
assert_eq!(first.block, encode(&red, SOURCE).unwrap());
|
||||
assert_eq!(first.frame, Some((8.0, 6.0)));
|
||||
assert!(cache.get(&red, &changed, changed.as_slice()).is_none(), "equal-length changed bytes must not reuse the cached hash");
|
||||
assert!(cache.get(&stale, &owner, owner.as_slice()).is_none());
|
||||
// A range outside the owner cannot be keyed by its address.
|
||||
assert!(cache.get(&red, &owner, SOURCE).is_none());
|
||||
let remaining = cache.remaining_pixels;
|
||||
assert_eq!(remaining, 47);
|
||||
for _ in 0..100 {
|
||||
assert!(Arc::ptr_eq(&cache.get(&red, &owner, owner.as_slice()).unwrap(), &first));
|
||||
}
|
||||
assert_eq!(cache.remaining_pixels, remaining, "cached instances are free");
|
||||
// Two 8×6 previews take 96 pixels; a 95-pixel export may validate only one.
|
||||
assert!(cache.get(&blue, &owner, owner.as_slice()).is_none());
|
||||
assert_eq!(cache.remaining_pixels, 0);
|
||||
let other = Arc::new(SOURCE.to_vec());
|
||||
assert!(cache.get(&red, &other, other.as_slice()).is_none(), "an exhausted export hashes no further sources");
|
||||
assert!(cache.get(&red, &owner, owner.as_slice()).is_some(), "a valid cache hit spends no work");
|
||||
}
|
||||
|
||||
/// Distinct tiny previews are cheap; a tiny canvas cannot disguise a large raster decode.
|
||||
#[test]
|
||||
fn preview_caches_charge_decode_work_and_retain_many_normal_tiny_previews() {
|
||||
let owner = Arc::new(SOURCE.to_vec());
|
||||
let mut imports = ImportCache::new(10_000);
|
||||
let mut exports = ExportCache::new(10_000);
|
||||
let mut tiny = Vec::new();
|
||||
for n in 0..12 {
|
||||
let mut drawing = preview();
|
||||
drawing.svg = Arc::new(String::from_utf8(SVG.to_vec()).unwrap().replace("red", &format!("#{n:06x}")).into_bytes());
|
||||
let block = block_bytes(&drawing).unwrap();
|
||||
assert!(imports.decode("native", &block, || Some(SOURCE)).is_some());
|
||||
assert!(exports.get(&drawing, &owner, owner.as_slice()).is_some());
|
||||
tiny.push((drawing, block));
|
||||
}
|
||||
assert_eq!(imports.remaining_pixels, 10_000 - 12 * 48);
|
||||
assert_eq!(exports.remaining_pixels, imports.remaining_pixels);
|
||||
let image = photocraft_codecs::Image::from_u8(64, 64, photocraft_codecs::ChannelLayout::Rgb, vec![255; 64 * 64 * 3]).unwrap();
|
||||
let png = photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).unwrap();
|
||||
let data = png.iter().map(|byte| format!("%{byte:02X}")).collect::<String>();
|
||||
let mut drawing = preview();
|
||||
drawing.svg = Arc::new(
|
||||
format!(r#"<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"><image width="1" height="1" href="data:image/png,{data}"/></svg>"#)
|
||||
.into_bytes(),
|
||||
);
|
||||
let block = block_bytes(&drawing).unwrap();
|
||||
let before = imports.remaining_pixels;
|
||||
assert!(imports.decode("native", &block, || None).is_some());
|
||||
assert!(exports.get(&drawing, &owner, owner.as_slice()).is_some());
|
||||
assert!(before - imports.remaining_pixels >= 4097);
|
||||
assert_eq!(exports.remaining_pixels, imports.remaining_pixels);
|
||||
// An invalid new preview may already spend work; its attempted allowance is charged.
|
||||
let mut invalid = preview();
|
||||
invalid.svg = Arc::new(br#"<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"><image href="data:image/png,invalid"/></svg>"#.to_vec());
|
||||
assert!(imports.decode("native", &block_bytes(&invalid).unwrap(), || None).is_none());
|
||||
assert!(exports.get(&invalid, &owner, owner.as_slice()).is_none());
|
||||
assert_eq!(imports.remaining_pixels, 0);
|
||||
assert_eq!(exports.remaining_pixels, 0);
|
||||
assert!(imports.decode("native", &tiny[0].1, || None).is_some(), "valid import cache hits remain available");
|
||||
assert!(exports.get(&tiny[0].0, &owner, owner.as_slice()).is_some(), "valid export cache hits remain available");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distinct_import_previews_share_a_work_budget_and_cached_instances_are_free() {
|
||||
let red = preview();
|
||||
let mut blue = red.clone();
|
||||
blue.svg = Arc::new(String::from_utf8(SVG.to_vec()).unwrap().replace("red", "blue").into_bytes());
|
||||
let red_block = encode(&red, SOURCE).unwrap();
|
||||
let blue_block = encode(&blue, SOURCE).unwrap();
|
||||
// Two 8×6 previews would take 96 pixels; a 95-pixel import may validate only one.
|
||||
// This small budget exercises the production 96 MP policy without allocating 96 MP.
|
||||
let mut cache = ImportCache::new(95);
|
||||
assert!(cache.decode("shared", &red_block, || Some(SOURCE)).is_some());
|
||||
assert_eq!(cache.remaining_pixels, 47);
|
||||
for _ in 0..100 {
|
||||
assert!(cache.decode("shared", &red_block, || panic!("cached source was read again")).is_some());
|
||||
}
|
||||
assert_eq!(cache.remaining_pixels, 47);
|
||||
assert!(cache.decode("shared", &blue_block, || panic!("cached source was read again")).is_none());
|
||||
assert_eq!(cache.remaining_pixels, 0);
|
||||
assert!(cache.decode("new source", &blue_block, || panic!("exhausted import read another source")).is_none());
|
||||
assert!(cache.decode("shared", &red_block, || panic!("valid cache hit spent work")).is_some());
|
||||
let mut cache = ImportCache::new(96);
|
||||
assert!(cache.decode("shared", &red_block, || Some(SOURCE)).is_some());
|
||||
assert!(cache.decode("shared", &blue_block, || None).is_some());
|
||||
assert_eq!(cache.remaining_pixels, 0);
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,7 @@ mod layer_labels;
|
||||
mod layer_xmp;
|
||||
mod modes;
|
||||
mod multichannel;
|
||||
mod native_vector_preview;
|
||||
mod orientation;
|
||||
mod pattern_fill_psd;
|
||||
mod pcraft_manifest_name;
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
//! Native embedded files stay native while PhotoCraft retains a source-bound display preview.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use photocraft_color::{ColorMode, SampleType};
|
||||
use photocraft_doc::{Document, Layer, LayerContent, SmartObject, SmartSource, VectorPreview};
|
||||
use photocraft_geom::{Affine, Size};
|
||||
use photocraft_io::{ExportOptions, export, import, linked, smart_map, vector_preview};
|
||||
use photocraft_psd::PsdFile;
|
||||
use photocraft_raster::Surface;
|
||||
|
||||
const PDF: &[u8] = b"%PDF-1.7\nopaque native source bytes\n%%EOF\n";
|
||||
const EPS: &[u8] = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 8 6\n1 0 0 setrgbcolor 0 0 8 6 rectfill\nshowpage\n%%EOF\n";
|
||||
const SVG: &[u8] = br#"<svg xmlns="http://www.w3.org/2000/svg" width="8" height="6"><rect width="8" height="6" fill="red"/></svg>"#;
|
||||
|
||||
fn fixture(name: &str, source: &[u8]) -> Document {
|
||||
let mut doc = Document::new("native", Size::new(20, 20), ColorMode::Rgb, SampleType::U8);
|
||||
let mut cache = Surface::new(doc.pixel_format());
|
||||
cache.fill_rect(photocraft_geom::Rect::new(2, 3, 18, 15), &[1.0, 0.0, 0.0, 1.0]);
|
||||
let mut sm = SmartObject::new(
|
||||
SmartSource::Embedded { file_name: name.into(), bytes: Arc::new(source.to_vec()) },
|
||||
Affine { m: [2.0, 0.0, 0.0, 2.0, 2.0, 3.0] },
|
||||
Some(cache),
|
||||
);
|
||||
sm.vector_preview = Some(VectorPreview { source_hash: *blake3::hash(source).as_bytes(), svg: Arc::new(SVG.to_vec()) });
|
||||
doc.layers.push(Layer::new("native", LayerContent::Smart(sm)));
|
||||
doc
|
||||
}
|
||||
|
||||
fn smart(doc: &Document) -> &SmartObject {
|
||||
let LayerContent::Smart(sm) = &doc.layers[0].content else { panic!("native vector was rasterized") };
|
||||
sm
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ai_pdf_eps_psd_and_psb_roundtrips_keep_source_bytes_preview_and_pixel_frame() {
|
||||
for (name, bytes) in [("Artwork.ai", PDF), ("Artwork.pdf", PDF), ("Artwork.eps", EPS)] {
|
||||
for extension in ["psd", "psb"] {
|
||||
let original = fixture(name, bytes);
|
||||
let output = export(&original, extension, &ExportOptions::default()).unwrap();
|
||||
assert!(!output.warnings.iter().any(|w| w.contains("smart object written as pixels")), "{:?}", output.warnings);
|
||||
let file = PsdFile::from_bytes(&output.bytes).unwrap();
|
||||
let placed = smart_map::parse_sold(&file.layers()[0].block(b"SoLd").unwrap().data).unwrap();
|
||||
assert_eq!(smart_map::stored_size(&placed.descriptor), Some((8.0, 6.0)));
|
||||
assert!(file.layers()[0].block(&vector_preview::PSD_KEY).is_some());
|
||||
let back = import(extension, &output.bytes).unwrap().document;
|
||||
let sm = smart(&back);
|
||||
let SmartSource::Linked { path } = &sm.source else { panic!("expected PSD embedded-table source") };
|
||||
let (actual_name, actual_bytes) = linked::embedded_payload(&back.metadata, path).unwrap();
|
||||
assert_eq!(actual_name, name);
|
||||
assert_eq!(actual_bytes, bytes);
|
||||
assert_eq!(sm.vector_preview, smart(&original).vector_preview);
|
||||
assert_eq!(sm.transform, smart(&original).transform);
|
||||
let again = export(&back, extension, &Default::default()).unwrap();
|
||||
let again = import(extension, &again.bytes).unwrap().document;
|
||||
assert_eq!(smart(&again).vector_preview, sm.vector_preview);
|
||||
assert_eq!(smart(&again).transform, sm.transform);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_or_corrupt_psd_preview_is_discarded_and_native_source_survives() {
|
||||
let output = export(&fixture("Artwork.ai", PDF), "psd", &Default::default()).unwrap();
|
||||
for hash_offset in [8, 44] {
|
||||
let mut file = PsdFile::from_bytes(&output.bytes).unwrap();
|
||||
let block = file.layers_mut()[0].blocks.iter_mut().find(|b| b.key == vector_preview::PSD_KEY).unwrap();
|
||||
block.data[hash_offset] ^= 0xff;
|
||||
let back = import("test.psd", &file.to_bytes().unwrap()).unwrap().document;
|
||||
let sm = smart(&back);
|
||||
assert!(sm.vector_preview.is_none());
|
||||
let SmartSource::Linked { path } = &sm.source else { panic!("source disappeared") };
|
||||
assert_eq!(linked::embedded_payload(&back.metadata, path).unwrap().1, PDF);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacing_native_source_cannot_export_a_stale_preview() {
|
||||
let mut doc = fixture("Artwork.ai", PDF);
|
||||
let LayerContent::Smart(sm) = &mut doc.layers[0].content else { unreachable!() };
|
||||
sm.source = SmartSource::Embedded { file_name: "Artwork.ai".into(), bytes: Arc::new(b"%PDF-1.7\nother".to_vec()) };
|
||||
let output = export(&doc, "psd", &Default::default()).unwrap();
|
||||
let file = PsdFile::from_bytes(&output.bytes).unwrap();
|
||||
assert!(file.layers()[0].block(&vector_preview::PSD_KEY).is_none());
|
||||
assert!(smart(&import("test.psd", &output.bytes).unwrap().document).vector_preview.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_preview_supplies_unfiltered_pixels_for_psd_smart_filter_masks() {
|
||||
let mut doc = fixture("Artwork.ai", PDF);
|
||||
let LayerContent::Smart(sm) = &mut doc.layers[0].content else { unreachable!() };
|
||||
sm.smart_filters.push(photocraft_doc::SmartFilter {
|
||||
command: "filter.blur.gaussianBlur".into(),
|
||||
params: serde_json::json!({"radius": 1.0}),
|
||||
blend: photocraft_color::BlendMode::Normal,
|
||||
opacity: 1.0,
|
||||
visible: true,
|
||||
});
|
||||
let mut mask = photocraft_doc::LayerMask::reveal_all();
|
||||
// All-white masks intentionally reopen as None. Use an actual masked region to prove
|
||||
// native-preview source pixels keep a nontrivial mask through PSD serialization.
|
||||
mask.surface.fill_rect(photocraft_geom::Rect::new(4, 4, 8, 8), &[0.0]);
|
||||
sm.filter_mask = Some(mask);
|
||||
let out = export(&doc, "psd", &Default::default()).unwrap();
|
||||
assert!(!out.warnings.iter().any(|warning| warning.contains("smart filter mask was not written")), "{:?}", out.warnings);
|
||||
let file = PsdFile::from_bytes(&out.bytes).unwrap();
|
||||
assert!(file.global_blocks.iter().any(|block| &block.key == b"FEid"));
|
||||
let back = import("test.psd", &out.bytes).unwrap().document;
|
||||
let mask = smart(&back).filter_mask.as_ref().expect("nontrivial filter mask was lost");
|
||||
assert_eq!(mask.value(5, 5), 0.0);
|
||||
assert_eq!(mask.value(0, 0), 1.0);
|
||||
assert!(smart(&back).vector_preview.is_some());
|
||||
assert_eq!(smart(&back).smart_filters.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_hundred_shared_native_instances_keep_source_bytes_and_preview_through_psd_resaves() {
|
||||
let mut doc = fixture("Artwork.ai", PDF);
|
||||
let layer = doc.layers[0].clone();
|
||||
for _ in 1..100 {
|
||||
doc.layers.push(Layer::new("native", layer.content.clone()));
|
||||
}
|
||||
let check = |bytes: &[u8]| {
|
||||
let back = import("shared.psd", bytes).unwrap().document;
|
||||
assert_eq!(back.layers.len(), 100);
|
||||
let file = PsdFile::from_bytes(bytes).unwrap();
|
||||
assert!(file.layers().iter().all(|layer| layer.block(&vector_preview::PSD_KEY).is_some()));
|
||||
for layer in &back.layers {
|
||||
let LayerContent::Smart(sm) = &layer.content else { panic!("native vector was rasterized") };
|
||||
assert_eq!(sm.vector_preview, smart(&doc).vector_preview);
|
||||
assert_eq!(sm.transform, smart(&doc).transform);
|
||||
let SmartSource::Linked { path } = &sm.source else { panic!("expected PSD embedded-table source") };
|
||||
assert_eq!(linked::embedded_payload(&back.metadata, path).unwrap(), ("Artwork.ai".to_string(), PDF));
|
||||
}
|
||||
back
|
||||
};
|
||||
let back = check(&export(&doc, "psd", &Default::default()).unwrap().bytes);
|
||||
check(&export(&back, "psd", &Default::default()).unwrap().bytes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_preview_preserves_fractional_source_pixel_frame_in_psd() {
|
||||
let mut doc = fixture("Artwork.ai", PDF);
|
||||
let LayerContent::Smart(sm) = &mut doc.layers[0].content else { unreachable!() };
|
||||
let svg = br#"<svg xmlns="http://www.w3.org/2000/svg" width="8.3" height="6.2"><rect width="8" height="6" fill="red"/></svg>"#;
|
||||
sm.vector_preview.as_mut().unwrap().svg = Arc::new(svg.to_vec());
|
||||
assert_eq!(vector_preview::frame(sm.vector_preview.as_ref().unwrap(), PDF), Some((8.3, 6.2)));
|
||||
let out = export(&doc, "psd", &Default::default()).unwrap();
|
||||
let file = PsdFile::from_bytes(&out.bytes).unwrap();
|
||||
let placed = smart_map::parse_sold(&file.layers()[0].block(b"SoLd").unwrap().data).unwrap();
|
||||
assert_eq!(smart_map::stored_size(&placed.descriptor), Some((8.3, 6.2)));
|
||||
let back = import("test.psd", &out.bytes).unwrap().document;
|
||||
assert_eq!(smart(&back).transform, smart(&doc).transform);
|
||||
let again = export(&back, "psd", &Default::default()).unwrap();
|
||||
let file = PsdFile::from_bytes(&again.bytes).unwrap();
|
||||
let placed = smart_map::parse_sold(&file.layers()[0].block(b"SoLd").unwrap().data).unwrap();
|
||||
assert_eq!(smart_map::stored_size(&placed.descriptor), Some((8.3, 6.2)));
|
||||
}
|
||||
@@ -116,6 +116,7 @@ fn text_shape_smart_raw_blocks_survive() {
|
||||
"Smart",
|
||||
LayerContent::Smart(SmartObject {
|
||||
contents_id: SmartContentsId::fresh(),
|
||||
vector_preview: None,
|
||||
source: SmartSource::Linked { path: String::new() },
|
||||
transform: photocraft_geom::Affine::IDENTITY,
|
||||
smart_filters: vec![],
|
||||
|
||||
@@ -154,7 +154,7 @@ fn starting_at(path: &Subpath, s: f64) -> Subpath {
|
||||
return path.clone();
|
||||
}
|
||||
let s = if path.closed { s.rem_euclid(total) } else { s };
|
||||
if s <= SAME || (!path.closed && s >= total - SAME) || (path.closed && s >= total - SAME) {
|
||||
if s <= SAME || s >= total - SAME {
|
||||
return path.clone();
|
||||
}
|
||||
// The segment and parameter at arc length `s` (linear within a measured piece).
|
||||
|
||||
@@ -4,9 +4,9 @@ use super::{
|
||||
brush_picker, camera_raw_ui, canvas, dds_options_ui, delete_layer_prompt, dialogs, discard_ui, distort_ui, dock, field_wheel, file_open, gpu_canvas,
|
||||
gpu_status, gradient_ui, histogram_panel, i18n, jobs_ui, jpeg_options_ui, json, kys_import, last_brush, layers_panel_ui, mask_thumbs_ui, mask_trash_prompt,
|
||||
monitor_status, native_menu, notices, open_prompt, paint_mouse, palette, panel_docking, panels, prefs_ui, preset_files_ui, preset_panels, rasterize_prompt,
|
||||
screen_picker, shape_stroke_ui, shortcuts, single_line_drag, state, stroke_trace, stylus, targa_options_ui, text_field_menu, theme, tiff_options_ui,
|
||||
timeline_ui, titlebar, tool_cursor, tool_memory, transform_tool, type_panels_ui, type_tool, type_transform, updates_ui, vector_ui, view_cmds, wheel_nav,
|
||||
wide_angle_ui, widgets, work_area, workspace_ui,
|
||||
screen_picker, shape_stroke_ui, shortcuts, single_line_drag, smart_svg_ui, state, stroke_trace, stylus, targa_options_ui, text_field_menu, theme,
|
||||
tiff_options_ui, timeline_ui, titlebar, tool_cursor, tool_memory, transform_tool, type_panels_ui, type_tool, type_transform, updates_ui, vector_ui,
|
||||
view_cmds, wheel_nav, wide_angle_ui, widgets, work_area, workspace_ui,
|
||||
};
|
||||
|
||||
mod clipboard;
|
||||
@@ -143,6 +143,7 @@ impl PhotocraftApp {
|
||||
clip_unmirrored: true,
|
||||
clip_external: false,
|
||||
clip_read_for_paste: false,
|
||||
smart_svg: Default::default(),
|
||||
transform_preview: None,
|
||||
move_mods: Default::default(),
|
||||
nudge_bundle: Default::default(),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::{
|
||||
DocId, PhotocraftApp, Tool, Value, actions, canvas, delete_layer_prompt, dock, gpu_canvas, jobs_ui, json, mask_trash_prompt, notices, panel_docking, state,
|
||||
transform_tool, type_transform, vector_ui,
|
||||
DocId, PhotocraftApp, Tool, Value, actions, canvas, delete_layer_prompt, dock, gpu_canvas, jobs_ui, json, mask_trash_prompt, notices, panel_docking,
|
||||
smart_svg_ui, state, transform_tool, type_transform, vector_ui,
|
||||
};
|
||||
|
||||
impl PhotocraftApp {
|
||||
@@ -73,6 +73,9 @@ impl PhotocraftApp {
|
||||
if let Some(result) = panel_docking::command(self, id, ¶ms) {
|
||||
return result;
|
||||
}
|
||||
if let Some(result) = smart_svg_ui::intercept(self, id, ¶ms) {
|
||||
return result;
|
||||
}
|
||||
if let Some(r) = transform_tool::intercept(self, id) {
|
||||
return r;
|
||||
}
|
||||
|
||||
@@ -163,6 +163,9 @@ impl PhotocraftApp {
|
||||
/// (a flat file): confirming the document's own file is recorded as a `file.save` step,
|
||||
/// which writes whichever file the action is played on, not as a Save As to a fixed path.
|
||||
pub(crate) fn save_dialog_flow(&mut self, path: Option<String>, choices: FormatChoices, plain_save: bool) -> Result<Value, String> {
|
||||
if self.smart_svg.modal_open() {
|
||||
return Err("Answer Edit SVG Contents before starting a save".into());
|
||||
}
|
||||
if self.save_options_open() {
|
||||
return Err(SAVE_OPTIONS_OPEN.into());
|
||||
}
|
||||
@@ -223,6 +226,9 @@ impl PhotocraftApp {
|
||||
|
||||
/// [`Self::save_as_with`] once the path is known.
|
||||
pub(crate) fn save_to(&mut self, path: String, choices: FormatChoices) -> Result<Value, String> {
|
||||
if self.smart_svg.modal_open() {
|
||||
return Err("Answer Edit SVG Contents before starting a save".into());
|
||||
}
|
||||
// An RGB Targa asks for its bits per pixel, a DDS texture for its compression, and a JPEG
|
||||
// for its quality and format, unless the caller chose them; the save continues from the
|
||||
// prompt (still a copy when it would be one without the prompt), which records the step
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
use super::{
|
||||
PhotocraftApp, analysis_ui, brush_panel, brush_picker, camera_raw_ui, canvas, dds_options_ui, dialogs, discard_ui, distort_ui, field_wheel, file_open,
|
||||
gpu_canvas, gpu_status, gradient_ui, i18n, jobs_ui, jpeg_options_ui, kys_import, last_brush, layers_panel_ui, monitor_status, native_menu, notices,
|
||||
paint_mouse, palette, panels, prefs_ui, preset_panels, rasterize_prompt, screen_picker, shape_stroke_ui, shortcuts, targa_options_ui, text_field_menu,
|
||||
theme, tiff_options_ui, timeline_ui, titlebar, tool_cursor, tool_memory, transform_tool, type_panels_ui, type_tool, updates_ui, vector_ui, view_cmds,
|
||||
wide_angle_ui, work_area, workspace_ui,
|
||||
paint_mouse, palette, panels, prefs_ui, preset_panels, rasterize_prompt, screen_picker, shape_stroke_ui, shortcuts, smart_svg_ui, targa_options_ui,
|
||||
text_field_menu, theme, tiff_options_ui, timeline_ui, titlebar, tool_cursor, tool_memory, transform_tool, type_panels_ui, type_tool, updates_ui, vector_ui,
|
||||
view_cmds, wide_angle_ui, work_area, workspace_ui,
|
||||
};
|
||||
|
||||
impl PhotocraftApp {
|
||||
@@ -314,6 +314,8 @@ impl PhotocraftApp {
|
||||
targa_options_ui::show(self, &ctx);
|
||||
jpeg_options_ui::show(self, &ctx);
|
||||
dds_options_ui::show(self, &ctx);
|
||||
smart_svg_ui::poll(self, &ctx);
|
||||
smart_svg_ui::show(self, &ctx);
|
||||
distort_ui::show(self, &ctx);
|
||||
camera_raw_ui::show(self, &ctx);
|
||||
wide_angle_ui::show(self, &ctx);
|
||||
|
||||
@@ -100,7 +100,7 @@ fn field_and_slider_pick_on_press_and_follow_small_moves() {
|
||||
press(&mut h, o + vec2(FIELD + gap + 9.0, FIELD * 0.95));
|
||||
assert_ne!(color(&h), before, "the slider picks on press");
|
||||
let hue = rgb_to_hsb(parse_hex(&color(&h)).unwrap())[0];
|
||||
assert!(hue < 30.0 || hue > 330.0, "hue {hue}");
|
||||
assert!(hue.is_finite() && !(30.0..=330.0).contains(&hue), "hue {hue}");
|
||||
}
|
||||
|
||||
/// Only the primary button picks: a right or middle press (and its release) on the field or
|
||||
|
||||
@@ -986,7 +986,7 @@ fn parse_planes(v: &Value) -> Result<(Vec<VpPlane>, Vec<Option<Torn>>), String>
|
||||
};
|
||||
let angle = p.get("angle").and_then(Value::as_f64).unwrap_or(90.0);
|
||||
let depth = p.get("depth").and_then(Value::as_f64).unwrap_or(1.0);
|
||||
if !(0.0..=360.0).contains(&angle) || !(depth > 0.0 && depth <= 1000.0) {
|
||||
if !((0.0..=360.0).contains(&angle) && depth > 0.0 && depth <= 1000.0) {
|
||||
return Err(format!("plane {i}: angle 0..360, depth above 0 and at most 1000"));
|
||||
}
|
||||
planes.push(VpPlane { corners: [[0.0; 2]; 4] });
|
||||
|
||||
@@ -4176,6 +4176,7 @@ cameraRaw Dehaze Odstranění oparu
|
||||
|
||||
# Graphics Adapter preference (#1885)
|
||||
GPU adapter Adaptér GPU
|
||||
smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded source; format-specific features may change. Obsah AI/PDF/EPS se bude upravovat jako SVG. Uložení nahradí vložený zdroj; vlastnosti specifické pro formát se mohou změnit.
|
||||
|
||||
# Styles panel: Import / Export Styles (.asl)
|
||||
Import Styles… Importovat styly…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -3444,6 +3444,16 @@ curves Smooth Glätten
|
||||
|
||||
# Graphics Adapter preference (#1885)
|
||||
GPU adapter GPU-Adapter
|
||||
smartSvg Edit SVG Smart Object SVG-Smart-Objekt bearbeiten
|
||||
smartSvg Choose the application used to edit this SVG: Wählen Sie die Anwendung zum Bearbeiten dieses SVG:
|
||||
smartSvg Other application Andere Anwendung
|
||||
smartSvg PhotoCraft (internal editor) PhotoCraft (interner Editor)
|
||||
smartSvg Choose executable… Programm auswählen…
|
||||
smartSvg Save to the opened SVG file. Each saved change becomes one undo step in PhotoCraft. Speichern Sie die geöffnete SVG-Datei. Jede gespeicherte Änderung wird in PhotoCraft zu einem Rückgängig-Schritt.
|
||||
smartSvg Updates wait while another document or an editing dialog is active. Aktualisierungen warten, während ein anderes Dokument oder ein Bearbeitungsdialog aktiv ist.
|
||||
smartSvg Stop syncing this Smart Object Synchronisierung dieses Smart-Objekts beenden
|
||||
smartSvg SVG editor opened. Save the SVG to update this Smart Object. SVG-Editor geöffnet. Speichern Sie das SVG, um dieses Smart-Objekt zu aktualisieren.
|
||||
smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded source; format-specific features may change. AI/PDF/EPS-Inhalte werden als SVG bearbeitet. Speichern ersetzt die eingebettete Quelle; formatspezifische Eigenschaften können sich ändern.
|
||||
|
||||
# Styles panel: Import / Export Styles (.asl)
|
||||
Import Styles… Stile importieren…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -4131,6 +4131,7 @@ curves Smooth Εξομάλυνση
|
||||
|
||||
# Graphics Adapter preference (#1885)
|
||||
GPU adapter Προσαρμογέας GPU
|
||||
smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded source; format-specific features may change. Το περιεχόμενο AI/PDF/EPS θα υποστεί επεξεργασία ως SVG. Η αποθήκευση αντικαθιστά την ενσωματωμένη πηγή· οι ιδιότητες του αρχικού τύπου αρχείου μπορεί να αλλάξουν.
|
||||
|
||||
# Styles panel: Import / Export Styles (.asl)
|
||||
Import Styles… Εισαγωγή στιλ…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 1042 and column 18.
|
@@ -4393,6 +4393,7 @@ navigator Zoom Zoom
|
||||
|
||||
# Graphics Adapter preference (#1885)
|
||||
GPU adapter Adaptador de GPU
|
||||
smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded source; format-specific features may change. El contenido AI/PDF/EPS se editará como SVG. Al guardar se reemplaza el origen incrustado; las funciones específicas del formato pueden cambiar.
|
||||
|
||||
# Styles panel: Import / Export Styles (.asl)
|
||||
Import Styles… Importar estilos…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 4 and column 47.
|
@@ -3443,6 +3443,7 @@ curves Smooth Lisser
|
||||
|
||||
# Graphics Adapter preference (#1885)
|
||||
GPU adapter Adaptateur GPU
|
||||
smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded source; format-specific features may change. Le contenu AI/PDF/EPS sera modifié au format SVG. L’enregistrement remplace la source incorporée ; les propriétés propres au format peuvent changer.
|
||||
|
||||
# Styles panel: Import / Export Styles (.asl)
|
||||
Import Styles… Importer des styles…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -4347,6 +4347,16 @@ cameraRaw Yellow Sárga
|
||||
|
||||
# Graphics Adapter preference (#1885)
|
||||
GPU adapter GPU-adapter
|
||||
smartSvg Choose executable… Futtatható fájl kiválasztása…
|
||||
smartSvg Choose the application used to edit this SVG: Válassza ki az SVG szerkesztésére használt alkalmazást:
|
||||
smartSvg Edit SVG Smart Object SVG intelligens objektum szerkesztése
|
||||
smartSvg Other application Másik alkalmazás
|
||||
smartSvg PhotoCraft (internal editor) PhotoCraft (belső szerkesztő)
|
||||
smartSvg SVG editor opened. Save the SVG to update this Smart Object. Az SVG-szerkesztő megnyílt. Mentse az SVG-t az intelligens objektum frissítéséhez.
|
||||
smartSvg Save to the opened SVG file. Each saved change becomes one undo step in PhotoCraft. Mentse a megnyitott SVG-fájlba. Minden mentett módosítás külön visszavonási lépés lesz a PhotoCraftban.
|
||||
smartSvg Stop syncing this Smart Object Az intelligens objektum szinkronizálásának leállítása
|
||||
smartSvg Updates wait while another document or an editing dialog is active. A frissítések várakoznak, amíg másik dokumentum vagy szerkesztési párbeszédablak aktív.
|
||||
smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded source; format-specific features may change. Az AI/PDF/EPS tartalom SVG-ként szerkeszthető. A mentés lecseréli a beágyazott forrást; a formátumspecifikus tulajdonságok megváltozhatnak.
|
||||
|
||||
# Styles panel: Import / Export Styles (.asl)
|
||||
Import Styles… Stílusok importálása…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -3446,6 +3446,7 @@ curves Smooth Haluskan
|
||||
|
||||
# Graphics Adapter preference (#1885)
|
||||
GPU adapter Adaptor GPU
|
||||
smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded source; format-specific features may change. Konten AI/PDF/EPS akan diedit sebagai SVG. Menyimpan akan mengganti sumber tersemat; fitur khusus format mungkin berubah.
|
||||
|
||||
# Styles panel: Import / Export Styles (.asl)
|
||||
Import Styles… Impor Gaya…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 4 and column 47.
|
@@ -4548,6 +4548,7 @@ shape Appearance Aspetto
|
||||
|
||||
# Graphics Adapter preference (#1885)
|
||||
GPU adapter Adattatore GPU
|
||||
smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded source; format-specific features may change. Il contenuto AI/PDF/EPS verrà modificato come SVG. Il salvataggio sostituisce la sorgente incorporata; le caratteristiche specifiche del formato potrebbero cambiare.
|
||||
|
||||
# Styles panel: Import / Export Styles (.asl)
|
||||
Import Styles… Importa stili…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2408 and column 12.
|
@@ -4110,6 +4110,7 @@ curves Smooth スムーズ
|
||||
|
||||
# Graphics Adapter preference (#1885)
|
||||
GPU adapter GPU アダプター
|
||||
smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded source; format-specific features may change. AI/PDF/EPS の内容を SVG として編集します。保存すると埋め込みソースが置き換わります。形式固有の機能が変わる場合があります。
|
||||
|
||||
# Styles panel: Import / Export Styles (.asl)
|
||||
Import Styles… スタイルを読み込み…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -3642,6 +3642,7 @@ curves Smooth 매끄럽게
|
||||
|
||||
# Graphics Adapter preference (#1885)
|
||||
GPU adapter GPU 어댑터
|
||||
smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded source; format-specific features may change. AI/PDF/EPS 내용을 SVG로 편집합니다. 저장하면 포함된 원본이 교체되며 형식별 기능이 변경될 수 있습니다.
|
||||
|
||||
# Styles panel: Import / Export Styles (.asl)
|
||||
Import Styles… 스타일 가져오기…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2955 and column 12.
|
@@ -4191,6 +4191,7 @@ curves Smooth Vloeiend maken
|
||||
|
||||
# Graphics Adapter preference (#1885)
|
||||
GPU adapter GPU-adapter
|
||||
smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded source; format-specific features may change. AI/PDF/EPS-inhoud wordt als SVG bewerkt. Opslaan vervangt de ingesloten bron; formaatspecifieke eigenschappen kunnen veranderen.
|
||||
|
||||
# Styles panel: Import / Export Styles (.asl)
|
||||
Import Styles… Stijlen importeren…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 8 and column 34.
|
@@ -4048,6 +4048,7 @@ curves Smooth Wygładź
|
||||
|
||||
# Graphics Adapter preference (#1885)
|
||||
GPU adapter Karta GPU
|
||||
smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded source; format-specific features may change. Zawartość AI/PDF/EPS będzie edytowana jako SVG. Zapisanie zastąpi osadzone źródło; właściwości specyficzne dla formatu mogą się zmienić.
|
||||
|
||||
# Styles panel: Import / Export Styles (.asl)
|
||||
Import Styles… Importuj style…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -4221,6 +4221,7 @@ layerLabel No Color Sem cor
|
||||
|
||||
# Graphics Adapter preference (#1885)
|
||||
GPU adapter Adaptador de GPU
|
||||
smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded source; format-specific features may change. O conteúdo AI/PDF/EPS será editado como SVG. Salvar substitui a origem incorporada; recursos específicos do formato podem mudar.
|
||||
|
||||
# Styles panel: Import / Export Styles (.asl)
|
||||
Import Styles… Importar estilos…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -3912,6 +3912,7 @@ curves Smooth Сгладить
|
||||
|
||||
# Graphics Adapter preference (#1885)
|
||||
GPU adapter Адаптер GPU
|
||||
smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded source; format-specific features may change. Содержимое AI/PDF/EPS будет редактироваться как SVG. Сохранение заменит встроенный источник; особенности исходного формата могут измениться.
|
||||
|
||||
# Styles panel: Import / Export Styles (.asl)
|
||||
Import Styles… Импортировать стили…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -4490,6 +4490,16 @@ shape Appearance Izgled
|
||||
|
||||
# Graphics Adapter preference (#1885)
|
||||
GPU adapter GPU adapter
|
||||
smartSvg Choose executable… Izaberi izvršnu datoteku…
|
||||
smartSvg Choose the application used to edit this SVG: Izaberite aplikaciju za uređivanje ovog SVG-a:
|
||||
smartSvg Edit SVG Smart Object Uredi SVG pametni objekat
|
||||
smartSvg Other application Druga aplikacija
|
||||
smartSvg PhotoCraft (internal editor) PhotoCraft (interni uređivač)
|
||||
smartSvg SVG editor opened. Save the SVG to update this Smart Object. SVG uređivač je otvoren. Sačuvajte SVG da biste ažurirali ovaj pametni objekat.
|
||||
smartSvg Save to the opened SVG file. Each saved change becomes one undo step in PhotoCraft. Sačuvajte u otvorenu SVG datoteku. Svaka sačuvana izmena postaje jedan korak opoziva u PhotoCraft-u.
|
||||
smartSvg Stop syncing this Smart Object Zaustavi sinhronizaciju ovog pametnog objekta
|
||||
smartSvg Updates wait while another document or an editing dialog is active. Ažuriranja čekaju dok je aktivan drugi dokument ili dijalog za uređivanje.
|
||||
smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded source; format-specific features may change. AI/PDF/EPS садржај ће се уређивати као SVG. Чување замењује уграђени извор; својства специфична за формат могу се променити.
|
||||
|
||||
# Styles panel: Import / Export Styles (.asl)
|
||||
Import Styles… Uvezi stilove…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -4159,6 +4159,16 @@ cameraData Lens Lens
|
||||
|
||||
# Graphics Adapter preference (#1885)
|
||||
GPU adapter GPU bağdaştırıcısı
|
||||
smartSvg Choose executable… Çalıştırılabilir dosya seç…
|
||||
smartSvg Choose the application used to edit this SVG: Bu SVG'yi düzenlemek için kullanılacak uygulamayı seçin:
|
||||
smartSvg Edit SVG Smart Object SVG Akıllı Nesnesini Düzenle
|
||||
smartSvg Other application Diğer uygulama
|
||||
smartSvg PhotoCraft (internal editor) PhotoCraft (dahili düzenleyici)
|
||||
smartSvg SVG editor opened. Save the SVG to update this Smart Object. SVG düzenleyicisi açıldı. Bu Akıllı Nesneyi güncellemek için SVG'yi kaydedin.
|
||||
smartSvg Save to the opened SVG file. Each saved change becomes one undo step in PhotoCraft. Açılan SVG dosyasına kaydedin. Kaydedilen her değişiklik PhotoCraft'ta bir geri alma adımı olur.
|
||||
smartSvg Stop syncing this Smart Object Bu Akıllı Nesnenin eşitlemesini durdur
|
||||
smartSvg Updates wait while another document or an editing dialog is active. Başka bir belge veya düzenleme iletişim kutusu etkin olduğunda güncellemeler bekler.
|
||||
smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded source; format-specific features may change. AI/PDF/EPS içeriği SVG olarak düzenlenecek. Kaydetmek gömülü kaynağı değiştirir; biçime özgü özellikler değişebilir.
|
||||
|
||||
# Styles panel: Import / Export Styles (.asl)
|
||||
@plural Imported {n} style from {file}|Imported {n} styles from {file} {file} dosyasından {n} stil içe aktarıldı
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 8 and column 34.
|
@@ -4141,6 +4141,7 @@ curves Smooth Згладити
|
||||
|
||||
# Graphics Adapter preference (#1885)
|
||||
GPU adapter Адаптер GPU
|
||||
smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded source; format-specific features may change. Вміст AI/PDF/EPS редагуватиметься як SVG. Збереження замінить вбудоване джерело; властивості початкового формату можуть змінитися.
|
||||
|
||||
# Styles panel: Import / Export Styles (.asl)
|
||||
Import Styles… Імпортувати стилі…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 1410 and column 18.
|
@@ -3924,6 +3924,16 @@ curves Smooth Làm mượt
|
||||
|
||||
# Graphics Adapter preference (#1885)
|
||||
GPU adapter Bộ điều hợp GPU
|
||||
smartSvg Choose executable… Chọn tệp thực thi…
|
||||
smartSvg Choose the application used to edit this SVG: Chọn ứng dụng dùng để chỉnh sửa SVG này:
|
||||
smartSvg Edit SVG Smart Object Chỉnh sửa Đối tượng thông minh SVG
|
||||
smartSvg Other application Ứng dụng khác
|
||||
smartSvg PhotoCraft (internal editor) PhotoCraft (trình chỉnh sửa nội bộ)
|
||||
smartSvg SVG editor opened. Save the SVG to update this Smart Object. Đã mở trình chỉnh sửa SVG. Lưu SVG để cập nhật Đối tượng thông minh này.
|
||||
smartSvg Save to the opened SVG file. Each saved change becomes one undo step in PhotoCraft. Lưu vào tệp SVG đã mở. Mỗi lần lưu thay đổi tạo một bước hoàn tác trong PhotoCraft.
|
||||
smartSvg Stop syncing this Smart Object Dừng đồng bộ Đối tượng thông minh này
|
||||
smartSvg Updates wait while another document or an editing dialog is active. Các cập nhật sẽ chờ khi tài liệu khác hoặc hộp thoại chỉnh sửa đang hoạt động.
|
||||
smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded source; format-specific features may change. Nội dung AI/PDF/EPS sẽ được chỉnh sửa dưới dạng SVG. Lưu sẽ thay thế nguồn nhúng; các tính năng riêng của định dạng có thể thay đổi.
|
||||
|
||||
# Styles panel: Import / Export Styles (.asl)
|
||||
Import Styles… Nhập kiểu…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -3093,6 +3093,7 @@ curves Smooth 平滑
|
||||
|
||||
# Graphics Adapter preference (#1885)
|
||||
GPU adapter GPU 适配器
|
||||
smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded source; format-specific features may change. AI/PDF/EPS 内容将以 SVG 格式编辑。保存会替换嵌入的源文件;格式特有的功能可能会发生变化。
|
||||
|
||||
# Styles panel: Import / Export Styles (.asl)
|
||||
Import Styles… 导入样式…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2383 and column 12.
|
@@ -3057,6 +3057,7 @@ curves Smooth 平滑
|
||||
|
||||
# Graphics Adapter preference (#1885)
|
||||
GPU adapter GPU 介面卡
|
||||
smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded source; format-specific features may change. AI/PDF/EPS 內容將以 SVG 格式編輯。儲存會取代嵌入的來源檔案;格式特有的功能可能會改變。
|
||||
|
||||
# Styles panel: Import / Export Styles (.asl)
|
||||
Import Styles… 匯入樣式…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -209,9 +209,17 @@ impl Focus {
|
||||
|
||||
/// What decides which keys [`crate::shortcuts::handle`] gives the shortcuts: an open menu, a
|
||||
/// dialog, the unsaved-changes prompt, a warp or the Filter Gallery, inline type.
|
||||
fn key_owner(app: &PhotocraftApp, ctx: &egui::Context) -> (bool, usize, bool, bool, bool) {
|
||||
fn key_owner(app: &PhotocraftApp, ctx: &egui::Context) -> (bool, usize, bool, bool, bool, bool, bool) {
|
||||
let distort = app.distort.active() || app.distort.gallery.is_some();
|
||||
(crate::menu_nav::is_open(ctx), app.ui.dialogs.len(), app.discard.is_some(), distort, app.ui.text_edit.is_some())
|
||||
(
|
||||
crate::menu_nav::is_open(ctx),
|
||||
app.ui.dialogs.len(),
|
||||
app.discard.is_some(),
|
||||
distort,
|
||||
app.ui.text_edit.is_some(),
|
||||
app.save_options_open(),
|
||||
app.smart_svg.modal_open(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Keys whose shortcuts follow the key's position, as in Photoshop: ⇧⌘. is ⇧⌘> on a US layout,
|
||||
|
||||
@@ -14,6 +14,56 @@ use crate::PhotocraftApp;
|
||||
use crate::shortcuts::parse;
|
||||
use crate::state::Tool;
|
||||
|
||||
#[test]
|
||||
fn svg_editor_prompt_owns_later_shortcuts_in_same_frame() {
|
||||
let launched = std::rc::Rc::new(std::cell::Cell::new(false));
|
||||
let launch_flag = launched.clone();
|
||||
let services = crate::Services {
|
||||
open_external_svg: Some(Box::new(move |_, _| {
|
||||
launch_flag.set(true);
|
||||
Err("The editor must not launch before a choice is confirmed".into())
|
||||
})),
|
||||
..Default::default()
|
||||
};
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), services);
|
||||
app.session.execute("file.new", json!({"width": 32, "height": 32})).unwrap();
|
||||
photocraft_engine::file_cmds::place_bytes(
|
||||
&mut app.session,
|
||||
"Artwork.svg",
|
||||
br#"<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8"><rect width="8" height="8"/></svg>"#.to_vec(),
|
||||
None,
|
||||
&json!({"smartObject":true}),
|
||||
)
|
||||
.unwrap();
|
||||
app.run("edit.keyboardShortcuts", json!({"set": {"layer.smartObjects.editContents": "F6"}})).unwrap();
|
||||
app.sync_views();
|
||||
let before = app.session.active().unwrap().doc.clone();
|
||||
let revision = app.session.active().unwrap().revision;
|
||||
let history = app.session.active().unwrap().history.past_len();
|
||||
let modifiers = platform(Modifiers::COMMAND);
|
||||
let input = egui::RawInput {
|
||||
events: vec![
|
||||
egui::Event::Key { key: egui::Key::F6, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE },
|
||||
egui::Event::ModifiersChanged(modifiers),
|
||||
egui::Event::Key { key: egui::Key::J, physical_key: None, pressed: true, repeat: false, modifiers },
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
let ctx = egui::Context::default();
|
||||
let mut output = ctx.run_ui(input, |ui| {
|
||||
assert!(super::dispatch_pressed(&mut app, ui.ctx(), Focus::None, false));
|
||||
assert!(ui.ctx().input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Key { key: egui::Key::J, pressed: true, .. }))));
|
||||
});
|
||||
output.textures_delta.clear();
|
||||
assert!(app.smart_svg.modal_open());
|
||||
assert!(!launched.get());
|
||||
let st = app.session.active().unwrap();
|
||||
assert_eq!(st.doc, before);
|
||||
assert_eq!(st.revision, revision);
|
||||
assert_eq!(st.history.past_len(), history);
|
||||
assert_eq!(take_log(&ctx), vec![("layer.smartObjects.editContents".into(), Outcome::Ran)]);
|
||||
}
|
||||
|
||||
/// Background, a filled pixel layer "paint" (active, with a selection), a type layer and a group
|
||||
/// holding a layer: what a real layout PSD looks like.
|
||||
fn realistic() -> photocraft_engine::Session {
|
||||
|
||||
@@ -564,7 +564,7 @@ fn handle_modal_canvas_keys(app: &mut PhotocraftApp, ctx: &egui::Context, focus:
|
||||
// Dialogs (and Liquify's panel while one of its controls has focus) keep canvas zoom; a
|
||||
// focused text field keeps its keys.
|
||||
let liquify_focus = (app.distort.liquify.is_some() || app.distort.caf.is_some() || app.distort.select_mask.is_some()) && focus != Focus::None;
|
||||
if liquify_focus || !app.ui.dialogs.is_empty() || app.discard.is_some() {
|
||||
if liquify_focus || !app.ui.dialogs.is_empty() || app.discard.is_some() || app.smart_svg.modal_open() || app.save_options_open() {
|
||||
if focus != Focus::Text {
|
||||
nav_keys(app, ctx);
|
||||
}
|
||||
@@ -834,6 +834,7 @@ fn handle_escape_and_view_keys(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
|| app.ui.tool == Tool::RotateView
|
||||
|| app.wide_angle.is_some()
|
||||
|| app.save_options_open()
|
||||
|| app.smart_svg.modal_open()
|
||||
|| app.distort.gallery.is_some()
|
||||
|| ctx.any_popup_open()
|
||||
|| ctx.data(|d| d.get_temp::<(egui::Id, egui::Rect)>(egui::Id::unique("tool-flyout")).is_some());
|
||||
|
||||
@@ -61,6 +61,11 @@ mod app;
|
||||
mod input;
|
||||
mod prefs;
|
||||
mod services;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod smart_svg_ui;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
#[path = "smart_svg_ui/browser.rs"]
|
||||
mod smart_svg_ui;
|
||||
mod text;
|
||||
mod tools;
|
||||
|
||||
@@ -231,9 +236,10 @@ pub(crate) use services::SAVE_OPTIONS_OPEN;
|
||||
pub(crate) use services::saved_revision_after_write;
|
||||
pub use services::{
|
||||
AdoptAutosaveFn, AppendTextFn, AutomationCommandFn, AutomationOpenPcraftFn, AutomationReadFn, AutomationWriteFn, AutosaveFn, AutosaveResultsFn, CapsLockFn,
|
||||
CaptionStyleFn, ClipboardGetFn, ClipboardSetFn, ClipboardUnreadableFn, CursorPosFn, DiscardAutosaveFn, EncodePngFn, ExportFn, ExportSettings, FileStamp,
|
||||
FileStampFn, FormatChoices, ImportFn, Inbox, LoadTextFn, OpenUrlFn, OsEventsFn, PhotoshopShortcutsFn, QuitFn, RecoverFn, Recoverable, RightButtonDownFn,
|
||||
SameFileFn, SaveFileFn, SaveTextFn, Services, SystemThemeFn, WindowFrameFn, WriteFn,
|
||||
CaptionStyleFn, ClipboardGetFn, ClipboardSetFn, ClipboardUnreadableFn, CursorPosFn, DiscardAutosaveFn, EncodePngFn, ExportFn, ExportSettings,
|
||||
ExternalSvgEditor, ExternalSvgSession, ExternalVectorSave, ExternalVectorSource, FileStamp, FileStampFn, FormatChoices, ImportFn, Inbox, LoadTextFn,
|
||||
OpenExternalSvgFn, OpenUrlFn, OsEventsFn, PhotoshopShortcutsFn, QuitFn, RecoverFn, Recoverable, RightButtonDownFn, SameFileFn, SaveFileFn, SaveTextFn,
|
||||
Services, SystemThemeFn, WindowFrameFn, WriteFn,
|
||||
};
|
||||
pub use text::bidi_text;
|
||||
pub use text::cjk_fonts;
|
||||
@@ -568,6 +574,7 @@ pub struct PhotocraftApp {
|
||||
/// The OS clipboard was already read for the paste in flight (Edit › Paste positions it first),
|
||||
/// so `run` doesn't read it a second time.
|
||||
pub(crate) clip_read_for_paste: bool,
|
||||
pub(crate) smart_svg: smart_svg_ui::State,
|
||||
/// Pointer position over the canvas (document px), for the Info panel and status bar.
|
||||
pub(crate) hover_doc: Option<[f64; 2]>,
|
||||
/// Layer hovered in the Layers panel, outlined on the canvas.
|
||||
|
||||
@@ -40,11 +40,11 @@ use state::{checked, translated_menu_label};
|
||||
|
||||
/// Shared gate for native menu clicks and control-channel `ui.menu.invoke`.
|
||||
/// Dialogs and unsaved-changes prompts block edits, but allow the four view-navigation
|
||||
/// commands also usable through shortcuts. Camera Raw and the full-window workspaces
|
||||
/// commands also usable through shortcuts. Camera Raw, the SVG editor choice and full-window workspaces
|
||||
/// (`distort_ui::modal_workspace`) block menu commands entirely, and a pending crop blocks the
|
||||
/// commands Photoshop greys during one (`crop_ui::blocks`).
|
||||
pub(crate) fn modal_allows(app: &PhotocraftApp, id: &str) -> bool {
|
||||
if app.camera_raw.is_some() || crate::distort_ui::modal_workspace(app).is_some() || crate::crop_ui::blocks(app, id) {
|
||||
if app.camera_raw.is_some() || app.smart_svg.modal_open() || crate::distort_ui::modal_workspace(app).is_some() || crate::crop_ui::blocks(app, id) {
|
||||
return false;
|
||||
}
|
||||
(app.ui.dialogs.is_empty() && app.ui.layers_panel_dialog.is_none() && app.discard.is_none()) || crate::shortcuts::NAV_COMMANDS.contains(&id)
|
||||
|
||||
@@ -290,6 +290,9 @@ fn invoke_file_commands(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str,
|
||||
}
|
||||
|
||||
pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: Value) -> Result<Value, String> {
|
||||
if let Some(result) = crate::smart_svg_ui::intercept(app, id, ¶ms) {
|
||||
return result;
|
||||
}
|
||||
if let Some(result) = invoke_early_commands(app, ctx, id, ¶ms) {
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ pub(crate) fn damage(app: &PhotocraftApp, doc: DocId, revision: u64, seen: u64,
|
||||
/// Limit an active preview to the view. Returning to the document restores the whole region.
|
||||
pub(crate) fn clip_damage(app: &mut PhotocraftApp, doc: DocId, revision: u64, key: u64, region: Rect, visible: Rect) -> Rect {
|
||||
let Some(p) = app.blend_preview.as_mut().filter(|p| p.doc == doc && p.revision == revision) else { return region };
|
||||
if !p.shown.as_ref().is_some_and(|(shown, _)| *shown == key) {
|
||||
if p.shown.as_ref().is_none_or(|(shown, _)| *shown != key) {
|
||||
p.covered = None;
|
||||
return region;
|
||||
}
|
||||
|
||||
@@ -250,6 +250,53 @@ pub type ClipboardGetFn = Box<dyn FnMut() -> Result<Option<(u32, u32, Vec<u8>)>,
|
||||
/// Whether the last clipboard read found no way to reach the OS clipboard at all.
|
||||
pub type ClipboardUnreadableFn = Box<dyn Fn() -> bool>;
|
||||
|
||||
/// An editor selected by the user, never an executable supplied by document metadata.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum ExternalSvgEditor {
|
||||
/// A known VectorCraft installation, found by the desktop shell.
|
||||
VectorCraft,
|
||||
/// A known Adobe Illustrator installation (offered where the shell can discover one).
|
||||
Illustrator,
|
||||
/// The absolute path the user chose with "Other application": an executable, or on macOS
|
||||
/// an application bundle. The shell validates it before starting anything.
|
||||
Program(String),
|
||||
}
|
||||
|
||||
/// An immutable saved source and optional display-only preview of those exact bytes.
|
||||
#[derive(Debug)]
|
||||
pub struct ExternalVectorSave {
|
||||
/// The saved file in its original format (SVG, AI, PDF or EPS).
|
||||
pub bytes: Vec<u8>,
|
||||
/// For AI/PDF/EPS, an SVG rendering of `bytes` used only for display; `None` for SVG.
|
||||
pub preview_svg: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// A native editing session supplies stable saved source snapshots without blocking the UI.
|
||||
pub trait ExternalSvgSession {
|
||||
/// Take the next settled save (its bytes) or error, if one is ready; never blocks.
|
||||
fn poll(&mut self) -> Option<Result<Vec<u8>, String>>;
|
||||
/// SVG-only services remain compatible; native services return source and preview together.
|
||||
fn poll_vector(&mut self) -> Option<Result<ExternalVectorSave, String>> {
|
||||
self.poll().map(|result| result.map(|bytes| ExternalVectorSave { bytes, preview_svg: None }))
|
||||
}
|
||||
/// No more snapshots can arrive, for example after the desktop chooser was cancelled.
|
||||
fn finished(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// An immutable embedded source. The shell keeps the original format in its owned working directory.
|
||||
#[derive(Clone)]
|
||||
pub struct ExternalVectorSource {
|
||||
/// The embedded file, shared rather than copied.
|
||||
pub bytes: std::sync::Arc<Vec<u8>>,
|
||||
/// Its whitelisted format: `svg`, `ai`, `pdf` or `eps`.
|
||||
pub extension: String,
|
||||
/// The placed object's source-pixel frame, which a converted preview is fitted to.
|
||||
pub size: Option<(f64, f64)>,
|
||||
}
|
||||
|
||||
pub type OpenExternalSvgFn = Box<dyn FnMut(ExternalSvgEditor, ExternalVectorSource) -> Result<Box<dyn ExternalSvgSession>, String>>;
|
||||
/// Shared queue of (file name, bytes) delivered asynchronously.
|
||||
pub type Inbox = std::sync::Arc<std::sync::Mutex<Vec<(String, Vec<u8>)>>>;
|
||||
|
||||
@@ -323,6 +370,8 @@ pub type RightButtonDownFn = Box<dyn Fn() -> bool + Send + Sync>;
|
||||
/// I/O dependencies.
|
||||
#[derive(Default)]
|
||||
pub struct Services {
|
||||
/// Explicit interactive SVG smart-object editing; unavailable in browser and automation.
|
||||
pub open_external_svg: Option<OpenExternalSvgFn>,
|
||||
/// User-initiated desktop/browser pixel sampling.
|
||||
pub screen_pick: Option<screen_picker::Service>,
|
||||
/// Decode a file's bytes into a document (PSD, PNG, JPEG, …).
|
||||
|
||||
@@ -0,0 +1,502 @@
|
||||
//! Explicit desktop SVG editor selection and bounded, undoable Smart Object save-back.
|
||||
|
||||
use base64::Engine as _;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use photocraft_doc::{DocId, LayerContent, LayerId, SmartSource};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{ExternalSvgEditor, ExternalSvgSession, PhotocraftApp};
|
||||
|
||||
const MAX_SESSIONS: usize = 8;
|
||||
const MAX_SVG_BYTES: usize = 16 * 1024 * 1024;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Editor {
|
||||
VectorCraft,
|
||||
Illustrator,
|
||||
Program,
|
||||
Internal,
|
||||
}
|
||||
|
||||
struct Target {
|
||||
doc: DocId,
|
||||
layer: LayerId,
|
||||
contents_id: Value,
|
||||
fingerprint: Value,
|
||||
svg: Arc<Vec<u8>>,
|
||||
extension: String,
|
||||
size: Option<(f64, f64)>,
|
||||
}
|
||||
|
||||
struct Pending {
|
||||
target: Target,
|
||||
editor: Editor,
|
||||
program: Option<String>,
|
||||
}
|
||||
|
||||
struct Editing {
|
||||
target: Target,
|
||||
session: Box<dyn ExternalSvgSession>,
|
||||
saved: Option<crate::ExternalVectorSave>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct State {
|
||||
pending: Option<Pending>,
|
||||
editing: Vec<Editing>,
|
||||
internal_once: bool,
|
||||
}
|
||||
|
||||
impl State {
|
||||
/// True while the editor-choice dialog awaits OK or Cancel.
|
||||
pub(crate) fn modal_open(&self) -> bool {
|
||||
self.pending.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// Translate dialog and status text in the `smartSvg` context.
|
||||
fn tr(source: &str) -> &str {
|
||||
crate::i18n::tr_ctx(crate::i18n::current(), "smartSvg", source)
|
||||
}
|
||||
|
||||
/// Surface a non-fatal editing or sync failure in the status bar; the document is unchanged.
|
||||
fn report(app: &mut PhotocraftApp, error: String) {
|
||||
app.ui.status = error;
|
||||
app.ui.status_error = true;
|
||||
}
|
||||
|
||||
/// Automation retains the engine's internal Edit Contents path; it never launches a program.
|
||||
pub(crate) fn intercept(app: &mut PhotocraftApp, id: &str, params: &Value) -> Option<Result<Value, String>> {
|
||||
if id != "layer.smartObjects.editContents" {
|
||||
return None;
|
||||
}
|
||||
if std::mem::take(&mut app.smart_svg.internal_once) || app.automation_input || app.services.open_external_svg.is_none() {
|
||||
return None;
|
||||
}
|
||||
if !(params.is_null() || params.as_object().is_some_and(|p| p.keys().all(|key| key == "layer"))) {
|
||||
return None;
|
||||
}
|
||||
let st = app.session.active()?;
|
||||
let layer = match params.get("layer") {
|
||||
Some(v) => LayerId(v.as_u64()?),
|
||||
None => st.active_layer?,
|
||||
};
|
||||
let LayerContent::Smart(smart) = &st.doc.layer(layer)?.content else { return None };
|
||||
let bytes = match &smart.source {
|
||||
SmartSource::Embedded { bytes, .. } => bytes.as_slice(),
|
||||
SmartSource::Linked { path } => photocraft_io::linked::embedded_payload(&st.doc.metadata, path)?.1,
|
||||
};
|
||||
if !(photocraft_io::svg::is_svg(bytes) || bytes.starts_with(b"%PDF-") || bytes.starts_with(b"%!PS-Adobe-") || bytes.starts_with(&[0xc5, 0xd0, 0xd3, 0xc6]))
|
||||
{
|
||||
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}));
|
||||
}
|
||||
if !safe_to_apply(app) {
|
||||
return Err("Finish the current edit or dialog before editing this SVG Smart Object".into());
|
||||
}
|
||||
let exported = app.run("layer.smartObjects.exportSvg", json!({"layer": layer.0}))?;
|
||||
let extension = exported.get("extension").and_then(Value::as_str).unwrap_or("svg").to_string();
|
||||
let svg = if let Some(svg) = exported.get("svg").and_then(Value::as_str) {
|
||||
svg.as_bytes().to_vec()
|
||||
} else {
|
||||
let encoded = exported.get("sourceBase64").and_then(Value::as_str).ok_or("The Smart Object did not export a vector")?;
|
||||
if encoded.len() > MAX_SVG_BYTES.div_ceil(3) * 4 {
|
||||
return Err("The vector exceeds the 16 MiB editor limit".into());
|
||||
}
|
||||
base64::engine::general_purpose::STANDARD.decode(encoded).map_err(|_| "Invalid vector source encoding")?
|
||||
};
|
||||
let size: Option<(f64, f64)> =
|
||||
serde_json::from_value(exported.get("sourceSize").cloned().unwrap_or(Value::Null)).map_err(|_| "Invalid vector source size")?;
|
||||
if svg.len() > MAX_SVG_BYTES {
|
||||
return Err("The SVG exceeds the 16 MiB editor limit".into());
|
||||
}
|
||||
let fingerprint = exported.get("fingerprint").filter(|v| !v.is_null()).cloned().ok_or("The SVG fingerprint is missing")?;
|
||||
let contents_id = exported.get("contentsId").filter(|v| !v.is_null()).cloned().ok_or("The Smart Object identity is missing")?;
|
||||
app.smart_svg.pending = Some(Pending {
|
||||
target: Target { doc, layer, contents_id, fingerprint, svg: Arc::new(svg), extension, size },
|
||||
editor: Editor::VectorCraft,
|
||||
program: None,
|
||||
});
|
||||
Ok(json!({"deferred": true}))
|
||||
})();
|
||||
if let Err(error) = &result {
|
||||
report(app, error.clone());
|
||||
}
|
||||
Some(result)
|
||||
}
|
||||
|
||||
/// Saves apply only while the target's document is active, never by switching documents.
|
||||
fn same_target(app: &PhotocraftApp, target: &Target) -> bool {
|
||||
app.session.active().is_some_and(|st| st.doc.id == target.doc)
|
||||
}
|
||||
|
||||
/// The update command must be enabled and no layer sharing these contents (every placed
|
||||
/// instance changes together) may be fully or pixel locked.
|
||||
fn writable_target(app: &PhotocraftApp, target: &Target) -> bool {
|
||||
let Some(st) = app.session.active().filter(|st| st.doc.id == target.doc) else { return false };
|
||||
if !app.session.is_enabled_with("layer.smartObjects.updateSvg", &json!({"document":target.doc.0,"layer":target.layer.0})) {
|
||||
return false;
|
||||
}
|
||||
st.doc
|
||||
.walk()
|
||||
.into_iter()
|
||||
.filter(|(_, _, layer)| matches!(&layer.content, LayerContent::Smart(smart) if json!(smart.contents_id.0) == target.contents_id))
|
||||
.all(|(_, _, layer)| {
|
||||
let locks = st.doc.effective_locks(layer.id);
|
||||
!locks.all && !locks.pixels
|
||||
})
|
||||
}
|
||||
|
||||
/// Act on the dialog's choice after re-exporting to confirm the fingerprint and contents id
|
||||
/// still match: run the internal editor, or start (or replace) one external session per
|
||||
/// shared contents, at most [`MAX_SESSIONS`]. Errors leave the document unchanged.
|
||||
fn confirm(app: &mut PhotocraftApp) -> Result<Value, String> {
|
||||
let Some(pending) = app.smart_svg.pending.take() else { return Ok(json!({"cancelled": true})) };
|
||||
if !safe_to_apply(app) {
|
||||
return Err("Finish the current edit or dialog before editing this SVG Smart Object".into());
|
||||
}
|
||||
if !same_target(app, &pending.target) {
|
||||
return Err("SVG editing cancelled: the active document changed".into());
|
||||
}
|
||||
let exported = app.run("layer.smartObjects.exportSvg", json!({"layer": pending.target.layer.0}))?;
|
||||
if exported.get("fingerprint") != Some(&pending.target.fingerprint) || exported.get("contentsId") != Some(&pending.target.contents_id) {
|
||||
return Err("SVG editing cancelled: the Smart Object contents changed".into());
|
||||
}
|
||||
if pending.editor == Editor::Internal {
|
||||
// Re-enter through the normal shell dispatch, retaining its authorization and bookkeeping.
|
||||
app.smart_svg.internal_once = true;
|
||||
let result = app.run("layer.smartObjects.editContents", json!({"layer": pending.target.layer.0}));
|
||||
app.smart_svg.internal_once = false;
|
||||
if result.is_ok() {
|
||||
app.smart_svg.editing.retain(|edit| edit.target.doc != pending.target.doc || edit.target.contents_id != pending.target.contents_id);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
let existing = app.smart_svg.editing.iter().position(|edit| edit.target.doc == pending.target.doc && edit.target.contents_id == pending.target.contents_id);
|
||||
if existing.is_none() && app.smart_svg.editing.len() >= MAX_SESSIONS {
|
||||
return Err("Close a document or stop an SVG editor session before starting another (limit: 8)".into());
|
||||
}
|
||||
let editor = match pending.editor {
|
||||
Editor::VectorCraft => ExternalSvgEditor::VectorCraft,
|
||||
Editor::Illustrator => ExternalSvgEditor::Illustrator,
|
||||
Editor::Program => ExternalSvgEditor::Program(pending.program.ok_or("Choose an editor executable first")?),
|
||||
Editor::Internal => return Err("Invalid SVG editor choice".into()),
|
||||
};
|
||||
let open = app.services.open_external_svg.as_mut().ok_or("External SVG editing is unavailable")?;
|
||||
let session = open(
|
||||
editor,
|
||||
crate::ExternalVectorSource { bytes: pending.target.svg.clone(), extension: pending.target.extension.clone(), size: pending.target.size },
|
||||
)?;
|
||||
if let Some(index) = existing {
|
||||
app.smart_svg.editing.remove(index);
|
||||
}
|
||||
let mut target = pending.target;
|
||||
target.svg = Arc::default();
|
||||
let target_is_native = target.extension != "svg";
|
||||
app.smart_svg.editing.push(Editing { target, session, saved: None });
|
||||
app.ui.status = tr(if target_is_native {
|
||||
"Vector editor opened. Save the original-format file to update this Smart Object."
|
||||
} else {
|
||||
"SVG editor opened. Save the SVG to update this Smart Object."
|
||||
})
|
||||
.into();
|
||||
app.ui.status_error = false;
|
||||
Ok(json!({"externalEditor": true}))
|
||||
}
|
||||
|
||||
/// Ask the user for one local executable; the answer applies only if the same pending target
|
||||
/// is still open. Executable validation happens when the session starts.
|
||||
fn choose_program(app: &mut PhotocraftApp) -> Result<Value, String> {
|
||||
let pending = app.smart_svg.pending.as_ref().ok_or("The SVG editor choice was cancelled")?;
|
||||
let doc = pending.target.doc;
|
||||
let layer = pending.target.layer;
|
||||
let contents_id = pending.target.contents_id.clone();
|
||||
app.ask_file(
|
||||
crate::file_dialog::FileDialogRequest::Open {
|
||||
multiple: false,
|
||||
initial_dir: None,
|
||||
extensions: if cfg!(windows) { Some(vec!["exe".into()]) } else { None },
|
||||
},
|
||||
move |app, answer| {
|
||||
let crate::file_dialog::FileDialogAnswer::Paths(paths) = answer else { return Err("Choose a local editor executable".into()) };
|
||||
if paths.len() != 1 {
|
||||
return Err("Choose one editor executable".into());
|
||||
}
|
||||
let path = paths.into_iter().next().ok_or("No editor executable selected")?;
|
||||
if let Some(pending) = app
|
||||
.smart_svg
|
||||
.pending
|
||||
.as_mut()
|
||||
.filter(|pending| pending.target.doc == doc && pending.target.layer == layer && pending.target.contents_id == contents_id)
|
||||
{
|
||||
pending.program = Some(path);
|
||||
pending.editor = Editor::Program;
|
||||
} else {
|
||||
return Err("The SVG editor choice was cancelled".into());
|
||||
}
|
||||
Ok(json!({"editorSelected": true}))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Draw the editor-choice dialog while a choice is pending. The internal editor is offered
|
||||
/// only for SVG; "Stop syncing" ends a running session for the same contents.
|
||||
pub(crate) fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
let file_dialog_open = app.file_dialog_open();
|
||||
let can_pick = app.services.file_dialog.is_some() && !file_dialog_open;
|
||||
let Some(pending) = app.smart_svg.pending.as_mut() else { return };
|
||||
let mut ok = false;
|
||||
let mut cancel = false;
|
||||
let mut choose = false;
|
||||
let mut stop = false;
|
||||
let running = app.smart_svg.editing.iter().any(|edit| edit.target.doc == pending.target.doc && edit.target.contents_id == pending.target.contents_id);
|
||||
let modal = egui::Modal::new(egui::Id::unique("smart-svg-editor")).show(ctx, |ui| {
|
||||
let tokens = crate::theme::Tokens::get(ctx);
|
||||
ui.set_max_width(470.0);
|
||||
ui.label(
|
||||
egui::RichText::new(tr(if pending.target.extension == "svg" { "Edit SVG Smart Object" } else { "Edit Vector Smart Object" }))
|
||||
.font(crate::theme::semibold(15.0)),
|
||||
);
|
||||
ui.add_space(4.0);
|
||||
crate::widgets::hairline(ui);
|
||||
ui.add_space(8.0);
|
||||
ui.label(tr(if pending.target.extension == "svg" {
|
||||
"Choose the application used to edit this SVG:"
|
||||
} else {
|
||||
"Choose the application used to edit this vector:"
|
||||
}));
|
||||
if pending.target.extension != "svg" {
|
||||
ui.label(tr(
|
||||
"AI/PDF/EPS files keep their original format. Unsupported features may change when saved. Original contents remain available through Undo.",
|
||||
));
|
||||
}
|
||||
for (choice, label) in [
|
||||
(Editor::VectorCraft, "VectorCraft"),
|
||||
(Editor::Illustrator, "Adobe Illustrator"),
|
||||
(Editor::Program, "Other application"),
|
||||
(Editor::Internal, "PhotoCraft (internal editor)"),
|
||||
] {
|
||||
// The desktop shell discovers Illustrator installations only on Windows and macOS.
|
||||
if choice == Editor::Illustrator && !cfg!(any(windows, target_os = "macos")) {
|
||||
continue;
|
||||
}
|
||||
let available = pending.target.extension == "svg" || choice != Editor::Internal;
|
||||
ui.add_enabled_ui(available, |ui| ui.radio_value(&mut pending.editor, choice, tr(label)));
|
||||
}
|
||||
if pending.editor == Editor::Program {
|
||||
if let Some(program) = &pending.program {
|
||||
let name = std::path::Path::new(program).file_name().and_then(|s| s.to_str()).unwrap_or(program);
|
||||
ui.label(name);
|
||||
}
|
||||
choose = ui.add_enabled(can_pick, egui::Button::new(tr("Choose executable…"))).clicked();
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
ui.label(
|
||||
egui::RichText::new(tr(if pending.target.extension == "svg" {
|
||||
"Save to the opened SVG file. Each saved change becomes one undo step in PhotoCraft."
|
||||
} else {
|
||||
"Save to the opened original-format file. Each saved change becomes one undo step in PhotoCraft."
|
||||
}))
|
||||
.small()
|
||||
.color(tokens.text_dim),
|
||||
);
|
||||
ui.label(egui::RichText::new(tr("Updates wait while another document or an editing dialog is active.")).small().color(tokens.text_dim));
|
||||
if running {
|
||||
stop = ui.button(tr("Stop syncing this Smart Object")).clicked();
|
||||
}
|
||||
ui.add_space(12.0);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let enabled = !file_dialog_open && (pending.editor != Editor::Program || pending.program.is_some());
|
||||
ok = ui.add_enabled_ui(enabled, |ui| crate::widgets::primary_button(ui, "OK", 84.0)).inner.clicked();
|
||||
cancel = crate::widgets::secondary_button(ui, "Cancel", 84.0).clicked();
|
||||
});
|
||||
});
|
||||
let owns_keys = modal.is_top_modal || ctx.memory(|memory| memory.top_modal_layer().is_none());
|
||||
cancel |=
|
||||
modal.should_close() || (owns_keys && !modal.any_popup_open && ctx.input_mut(|input| input.consume_key(egui::Modifiers::NONE, egui::Key::Escape)));
|
||||
if stop || cancel {
|
||||
if stop && let Some(pending) = app.smart_svg.pending.as_ref() {
|
||||
app.smart_svg.editing.retain(|edit| edit.target.doc != pending.target.doc || edit.target.contents_id != pending.target.contents_id);
|
||||
}
|
||||
app.smart_svg.pending = None;
|
||||
return;
|
||||
}
|
||||
if choose {
|
||||
if let Err(error) = choose_program(app) {
|
||||
report(app, error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let enter = owns_keys && !modal.any_popup_open && ctx.input_mut(|input| input.consume_key(egui::Modifiers::NONE, egui::Key::Enter));
|
||||
let ready = !app.file_dialog_open() && app.smart_svg.pending.as_ref().is_some_and(|pending| pending.editor != Editor::Program || pending.program.is_some());
|
||||
if ready
|
||||
&& (ok || enter)
|
||||
&& let Err(error) = confirm(app)
|
||||
{
|
||||
report(app, error);
|
||||
}
|
||||
}
|
||||
|
||||
/// True only when no save, dialog, mask/channel target, transform, preview or drag is in
|
||||
/// progress, so an external save cannot land in the middle of another edit.
|
||||
fn safe_to_apply(app: &PhotocraftApp) -> bool {
|
||||
!app.automation_input
|
||||
&& !app.saving()
|
||||
&& !app.save_options_open()
|
||||
&& !app.file_dialog_open()
|
||||
&& !app.ui.mask_target
|
||||
&& !app.layer_mask_targeted()
|
||||
&& !app.ui.vector_mask_target
|
||||
&& !app.session.tools.mask_targeted
|
||||
&& app.session.active().is_some_and(|st| {
|
||||
st.doc.quick_mask.is_none()
|
||||
&& st.channel_view.layer_mask.is_none()
|
||||
&& st.channel_view.target == photocraft_engine::channel_cmds::ChannelTarget::Composite
|
||||
&& app.session.job_on(st.doc.id).is_none()
|
||||
})
|
||||
&& app.ui.dialogs.is_empty()
|
||||
&& app.ui.layers_panel_dialog.is_none()
|
||||
&& app.discard.is_none()
|
||||
&& app.camera_raw.is_none()
|
||||
&& !app.smart_svg.modal_open()
|
||||
&& app.ui.transform.is_none()
|
||||
&& app.ui.cam_transform.is_none()
|
||||
&& app.ui.text_edit.is_none()
|
||||
&& app.ui.type_transform.is_none()
|
||||
&& app.ui.symmetry_transform.is_none()
|
||||
&& app.ui.stroke_editor.is_none()
|
||||
&& app.ui.direct_selection.drag.is_none()
|
||||
&& app.ui.corner_radius.is_none()
|
||||
&& !app.distort.active()
|
||||
&& crate::distort_ui::modal_workspace(app).is_none()
|
||||
&& app.wide_angle.is_none()
|
||||
&& app.color_range.is_none()
|
||||
&& app.text_style_preview.is_none()
|
||||
&& !crate::crop_ui::pending(app)
|
||||
&& !crate::gradient_ui::editing(app)
|
||||
&& app.move_preview.is_none()
|
||||
&& app.patch_preview.is_none()
|
||||
&& app.filter_preview.is_none()
|
||||
&& app.adjust_preview.is_none()
|
||||
&& app.live_adjust.is_none()
|
||||
&& app.transform_preview.is_none()
|
||||
&& app.shape_fill_preview.is_none()
|
||||
&& app.solid_fill_preview.is_none()
|
||||
&& app.live_stroke.is_none()
|
||||
&& app.drag.is_none()
|
||||
&& !app.press_stroke
|
||||
}
|
||||
|
||||
/// Poll platform snapshots without changing the active document or selection.
|
||||
pub(crate) fn poll(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
if app
|
||||
.smart_svg
|
||||
.pending
|
||||
.as_ref()
|
||||
.is_some_and(|pending| !app.session.documents().iter().any(|st| st.doc.id == pending.target.doc && st.doc.layer(pending.target.layer).is_some()))
|
||||
{
|
||||
app.smart_svg.pending = None;
|
||||
}
|
||||
if app.smart_svg.editing.is_empty() {
|
||||
return;
|
||||
}
|
||||
let safe = safe_to_apply(app) && ctx.memory(|memory| memory.top_modal_layer().is_none()) && !ctx.input(|input| input.pointer.any_down());
|
||||
let mut editing = std::mem::take(&mut app.smart_svg.editing);
|
||||
for mut edit in editing.drain(..) {
|
||||
let exists = app
|
||||
.session
|
||||
.documents()
|
||||
.iter()
|
||||
.find(|st| st.doc.id == edit.target.doc)
|
||||
.and_then(|st| st.doc.layer(edit.target.layer))
|
||||
.is_some_and(|layer| matches!(&layer.content, LayerContent::Smart(smart) if json!(smart.contents_id.0) == edit.target.contents_id));
|
||||
if !exists {
|
||||
continue;
|
||||
}
|
||||
if let Some(result) = edit.session.poll_vector() {
|
||||
match result {
|
||||
Ok(saved) if saved.bytes.len() <= MAX_SVG_BYTES && saved.preview_svg.as_ref().is_none_or(|svg| svg.len() <= MAX_SVG_BYTES) => {
|
||||
edit.saved = Some(saved)
|
||||
}
|
||||
Ok(_) => report(app, "The SVG exceeds the 16 MiB editor limit; save a smaller SVG to retry".into()),
|
||||
Err(error) => report(app, error),
|
||||
}
|
||||
}
|
||||
// Release admission capacity after cancellation/startup failure. Retain a final
|
||||
// pending save until its target is safe to update.
|
||||
if edit.saved.is_none() && edit.session.finished() {
|
||||
continue;
|
||||
}
|
||||
let internal_edit = app.session.smart_links.iter().any(|link| link.parent == edit.target.doc && json!(link.contents_id.0) == edit.target.contents_id);
|
||||
if safe
|
||||
&& !internal_edit
|
||||
&& same_target(app, &edit.target)
|
||||
&& edit.saved.is_some()
|
||||
&& writable_target(app, &edit.target)
|
||||
&& let Some(bytes) = edit.saved.take()
|
||||
{
|
||||
let native = edit.target.extension != "svg";
|
||||
let preview = if native { bytes.preview_svg } else { Some(bytes.bytes.clone()) };
|
||||
let svg = match preview.and_then(|svg| String::from_utf8(svg).ok()) {
|
||||
Some(svg) => svg,
|
||||
None => {
|
||||
report(app, "The saved vector has no valid display preview; save again to retry".into());
|
||||
app.smart_svg.editing.push(edit);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let mut params = json!({"document": edit.target.doc.0, "layer": edit.target.layer.0, "fingerprint": edit.target.fingerprint, "contentsId": edit.target.contents_id});
|
||||
let command = if native {
|
||||
params["previewSvg"] = json!(svg);
|
||||
params["sourceBase64"] = json!(base64::engine::general_purpose::STANDARD.encode(&bytes.bytes));
|
||||
params["extension"] = json!(edit.target.extension);
|
||||
"layer.smartObjects.updateVectorSource"
|
||||
} else {
|
||||
params["svg"] = json!(svg);
|
||||
"layer.smartObjects.updateSvg"
|
||||
};
|
||||
match app.run(command, params) {
|
||||
Ok(result) => {
|
||||
if let Some(fingerprint) = result.get("fingerprint").filter(|v| !v.is_null()) {
|
||||
edit.target.fingerprint = fingerprint.clone();
|
||||
} else {
|
||||
report(app, "The SVG update did not return its fingerprint; syncing stopped".into());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
let stale = error.contains("contents changed") || error.contains("conflicting contents");
|
||||
let kind = if native { "Vector" } else { "SVG" };
|
||||
report(
|
||||
app,
|
||||
if stale {
|
||||
format!("{kind} syncing stopped: {error}")
|
||||
} else {
|
||||
format!("{kind} update failed; change the working file and save to retry: {error}")
|
||||
},
|
||||
);
|
||||
if stale {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
app.smart_svg.editing.push(edit);
|
||||
}
|
||||
if !app.smart_svg.editing.is_empty() {
|
||||
ctx.request_repaint_after(Duration::from_millis(500));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,27 @@
|
||||
//! Browser builds retain internal Edit Contents without desktop editor sessions.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::PhotocraftApp;
|
||||
|
||||
/// No external editor sessions exist in the browser.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct State {}
|
||||
|
||||
impl State {
|
||||
/// The editor-choice dialog is never shown in the browser.
|
||||
pub(crate) fn modal_open(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Leave every command to normal dispatch, so Edit Contents uses the internal editor.
|
||||
pub(crate) fn intercept(_app: &mut PhotocraftApp, _id: &str, _params: &Value) -> Option<Result<Value, String>> {
|
||||
None
|
||||
}
|
||||
|
||||
/// No sessions to poll.
|
||||
pub(crate) fn poll(_app: &mut PhotocraftApp, _ctx: &egui::Context) {}
|
||||
|
||||
/// No dialog to draw.
|
||||
pub(crate) fn show(_app: &mut PhotocraftApp, _ctx: &egui::Context) {}
|
||||
@@ -0,0 +1,769 @@
|
||||
use std::cell::RefCell;
|
||||
use std::collections::VecDeque;
|
||||
use std::rc::Rc;
|
||||
|
||||
use photocraft_engine::Session;
|
||||
|
||||
use super::*;
|
||||
use crate::Services;
|
||||
|
||||
const SVG: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="12" height="8"><rect width="12" height="8" fill="red"/></svg>"#;
|
||||
|
||||
type Saves = Rc<RefCell<VecDeque<Result<Vec<u8>, String>>>>;
|
||||
type Calls = Rc<RefCell<Vec<ExternalSvgEditor>>>;
|
||||
|
||||
struct TestSession(Saves);
|
||||
|
||||
impl ExternalSvgSession for TestSession {
|
||||
fn poll(&mut self) -> Option<Result<Vec<u8>, String>> {
|
||||
self.0.borrow_mut().pop_front()
|
||||
}
|
||||
}
|
||||
|
||||
struct NativeTestSession(Saves);
|
||||
impl ExternalSvgSession for NativeTestSession {
|
||||
fn poll(&mut self) -> Option<Result<Vec<u8>, String>> {
|
||||
self.0.borrow_mut().pop_front()
|
||||
}
|
||||
fn poll_vector(&mut self) -> Option<Result<crate::ExternalVectorSave, String>> {
|
||||
self.poll().map(|r| r.map(|bytes| crate::ExternalVectorSave { bytes, preview_svg: Some(SVG.as_bytes().to_vec()) }))
|
||||
}
|
||||
}
|
||||
|
||||
fn fixture() -> (PhotocraftApp, Saves, Calls) {
|
||||
let saves = Saves::default();
|
||||
let calls = Calls::default();
|
||||
let (queue, launched) = (saves.clone(), calls.clone());
|
||||
let services = Services {
|
||||
open_external_svg: Some(Box::new(move |editor, bytes| {
|
||||
assert_eq!(&*bytes.bytes, SVG.as_bytes());
|
||||
launched.borrow_mut().push(editor);
|
||||
Ok(Box::new(TestSession(queue.clone())))
|
||||
})),
|
||||
..Default::default()
|
||||
};
|
||||
let mut app = PhotocraftApp::new(Session::new(), services);
|
||||
app.session.execute("file.new", json!({"width":64,"height":64})).unwrap();
|
||||
photocraft_engine::file_cmds::place_bytes(&mut app.session, "Artwork.svg", SVG.as_bytes().to_vec(), None, &json!({"smartObject":true})).unwrap();
|
||||
app.sync_views();
|
||||
(app, saves, calls)
|
||||
}
|
||||
|
||||
fn open(app: &mut PhotocraftApp) {
|
||||
assert!(intercept(app, "layer.smartObjects.editContents", &json!({})).unwrap().unwrap()["deferred"].as_bool().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_menu_invoke_preserves_svg_editor_choice_and_rejects_other_dialogs() {
|
||||
let (mut app, saves, calls) = fixture();
|
||||
open(&mut app);
|
||||
let pending = app.smart_svg.pending.as_mut().unwrap();
|
||||
pending.editor = Editor::Program;
|
||||
pending.program = Some("synthetic-editor".into());
|
||||
let before = app.session.active().unwrap().doc.clone();
|
||||
let revision = app.session.active().unwrap().revision;
|
||||
let history = app.session.active().unwrap().history.past_len();
|
||||
let journal = app.session.journal.clone();
|
||||
let view = (app.ui.views[0].center, app.ui.views[0].zoom);
|
||||
let pending = app.smart_svg.pending.as_ref().unwrap();
|
||||
let target = (pending.target.doc, pending.target.layer, pending.target.contents_id.clone(), pending.target.fingerprint.clone());
|
||||
let svg = pending.target.svg.clone();
|
||||
let program = pending.program.clone();
|
||||
let internal_once = app.smart_svg.internal_once;
|
||||
let authorizations = Rc::new(std::cell::Cell::new(0));
|
||||
let authorized = authorizations.clone();
|
||||
app.services.automation_command = Some(Box::new(move |_, _| {
|
||||
authorized.set(authorized.get() + 1);
|
||||
Ok(())
|
||||
}));
|
||||
let clipboard_reads = Rc::new(std::cell::Cell::new(0));
|
||||
let reads = clipboard_reads.clone();
|
||||
app.services.clipboard_get_image = Some(Box::new(move || {
|
||||
reads.set(reads.get() + 1);
|
||||
Ok(None)
|
||||
}));
|
||||
let ctx = egui::Context::default();
|
||||
for id in ["layer.duplicate", "image.imageSize", "edit.paste", "view.zoomIn"] {
|
||||
let (request, _) = crate::control::ControlRequest::new("ui.menu.invoke", json!({"id": id}));
|
||||
let crate::control::Outcome::Done(result) = crate::control::handle(&mut app, &ctx, &request) else {
|
||||
panic!("blocked menu call must return immediately")
|
||||
};
|
||||
assert_eq!(result["ok"], false, "{id}: {result}");
|
||||
assert!(result.to_string().contains("modal dialog"));
|
||||
assert_eq!(authorizations.get(), 0);
|
||||
assert_eq!(clipboard_reads.get(), 0);
|
||||
assert!(calls.borrow().is_empty());
|
||||
assert!(saves.borrow().is_empty());
|
||||
assert!(app.smart_svg.editing.is_empty());
|
||||
assert_eq!(app.smart_svg.internal_once, internal_once);
|
||||
assert!(app.ui.dialogs.is_empty());
|
||||
let state = app.session.active().unwrap();
|
||||
assert_eq!(state.doc, before);
|
||||
assert_eq!(state.revision, revision);
|
||||
assert_eq!(state.history.past_len(), history);
|
||||
assert_eq!(app.session.journal, journal);
|
||||
assert_eq!((app.ui.views[0].center, app.ui.views[0].zoom), view);
|
||||
let pending = app.smart_svg.pending.as_ref().unwrap();
|
||||
assert_eq!((pending.target.doc, pending.target.layer, pending.target.contents_id.clone(), pending.target.fingerprint.clone()), target);
|
||||
assert!(Arc::ptr_eq(&pending.target.svg, &svg));
|
||||
assert_eq!(&**pending.target.svg, SVG.as_bytes());
|
||||
assert!(pending.editor == Editor::Program);
|
||||
assert_eq!(pending.program, program);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn routing_keeps_automation_non_svg_and_unrelated_commands_internal() {
|
||||
let (mut app, _, calls) = fixture();
|
||||
assert!(intercept(&mut app, "edit.copy", &json!({})).is_none());
|
||||
assert!(intercept(&mut app, "layer.smartObjects.editContents", &json!({"other":true})).is_none());
|
||||
app.automation_input = true;
|
||||
assert!(intercept(&mut app, "layer.smartObjects.editContents", &json!({})).is_none());
|
||||
app.automation_input = false;
|
||||
let st = app.session.active_mut().unwrap();
|
||||
let layer = st.active_layer.unwrap();
|
||||
let LayerContent::Smart(smart) = &mut Arc::make_mut(&mut st.doc).layer_mut(layer).unwrap().content else { panic!("smart") };
|
||||
smart.source = SmartSource::Embedded { file_name: "Embedded.pcraft".into(), bytes: Arc::new(vec![]) };
|
||||
assert!(intercept(&mut app, "layer.smartObjects.editContents", &json!({})).is_none());
|
||||
assert!(calls.borrow().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn svg_source_without_svg_filename_still_opens_editor_choice() {
|
||||
let (mut app, _, calls) = fixture();
|
||||
let st = app.session.active_mut().unwrap();
|
||||
let layer = st.active_layer.unwrap();
|
||||
let LayerContent::Smart(smart) = &mut Arc::make_mut(&mut st.doc).layer_mut(layer).unwrap().content else { panic!("smart") };
|
||||
let SmartSource::Embedded { file_name, .. } = &mut smart.source else { panic!("embedded") };
|
||||
*file_name = "Unknown clipboard object".into();
|
||||
open(&mut app);
|
||||
assert!(calls.borrow().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_and_escape_never_launch_or_change_document() {
|
||||
let (mut app, _, calls) = fixture();
|
||||
let before = app.session.active().unwrap().doc.clone();
|
||||
open(&mut app);
|
||||
open(&mut app);
|
||||
let ctx = egui::Context::default();
|
||||
PhotocraftApp::setup_context(&ctx, Default::default());
|
||||
let mut input = egui::RawInput::default();
|
||||
input.events.push(egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE });
|
||||
let mut output = ctx.run_ui(input, |ui| show(&mut app, ui.ctx()));
|
||||
output.textures_delta.clear();
|
||||
assert!(!app.smart_svg.modal_open());
|
||||
assert!(calls.borrow().is_empty());
|
||||
assert_eq!(app.session.active().unwrap().doc, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_internal_choice_uses_existing_child_document_flow() {
|
||||
let (mut app, _, calls) = fixture();
|
||||
let parent = app.session.active().unwrap().doc.id;
|
||||
open(&mut app);
|
||||
app.smart_svg.pending.as_mut().unwrap().editor = Editor::Internal;
|
||||
confirm(&mut app).unwrap();
|
||||
assert_ne!(app.session.active().unwrap().doc.id, parent);
|
||||
assert_eq!(app.session.smart_links.len(), 1);
|
||||
assert!(calls.borrow().is_empty());
|
||||
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();
|
||||
app.services.open_external_svg = None;
|
||||
let parent_index = app.session.active_index().unwrap();
|
||||
let st = app.session.active().unwrap();
|
||||
let (parent, layer, before, history) = (st.doc.id, st.active_layer.unwrap(), st.doc.clone(), st.history.past_len());
|
||||
let opened = app.run("layer.smartObjects.editContents", json!({})).unwrap();
|
||||
assert_eq!(opened["document"].as_u64(), app.session.active_index().map(|index| index as u64));
|
||||
assert_ne!(app.session.active().unwrap().doc.id, parent);
|
||||
assert_eq!(app.session.smart_links.len(), 1);
|
||||
let link = &app.session.smart_links[0];
|
||||
assert_eq!((link.parent, link.layer, link.child), (parent, layer, app.session.active().unwrap().doc.id));
|
||||
assert!(!app.smart_svg.modal_open());
|
||||
assert!(app.smart_svg.editing.is_empty());
|
||||
assert!(calls.borrow().is_empty());
|
||||
assert!(saves.borrow().is_empty());
|
||||
|
||||
app.run("layer.new.layer", json!({})).unwrap();
|
||||
app.session
|
||||
.edit("paint child", |doc, active| {
|
||||
let bounds = doc.bounds();
|
||||
doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(bounds, &[0.0, 1.0, 0.0, 1.0]);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
app.run("layer.smartObjects.saveContents", json!({})).unwrap();
|
||||
assert!(!app.session.active().unwrap().is_dirty());
|
||||
app.session.set_active(parent_index);
|
||||
let st = app.session.active().unwrap();
|
||||
let LayerContent::Smart(smart) = &st.doc.layer(layer).unwrap().content else { panic!("smart") };
|
||||
let SmartSource::Embedded { file_name, bytes } = &smart.source else { panic!("embedded") };
|
||||
assert!(file_name.ends_with(".pcraft"));
|
||||
assert_ne!(&**bytes, SVG.as_bytes());
|
||||
assert_eq!(smart.cache.as_ref().unwrap().rgba(32, 32), [0.0, 1.0, 0.0, 1.0]);
|
||||
assert_eq!(st.history.past_len(), history + 1);
|
||||
assert!(!app.smart_svg.modal_open());
|
||||
assert!(calls.borrow().is_empty());
|
||||
assert!(saves.borrow().is_empty());
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(app.session.active().unwrap().doc, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn choosing_internal_editor_ends_existing_external_session() {
|
||||
let (mut app, _, _) = fixture();
|
||||
open(&mut app);
|
||||
confirm(&mut app).unwrap();
|
||||
assert_eq!(app.smart_svg.editing.len(), 1);
|
||||
open(&mut app);
|
||||
app.smart_svg.pending.as_mut().unwrap().editor = Editor::Internal;
|
||||
confirm(&mut app).unwrap();
|
||||
assert!(app.smart_svg.editing.is_empty());
|
||||
assert_eq!(app.session.smart_links.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_launch_is_visible_and_keeps_source_unchanged() {
|
||||
let (mut app, _, _) = fixture();
|
||||
app.services.open_external_svg = Some(Box::new(|_, _| Err("Editor not installed".into())));
|
||||
let before = app.session.active().unwrap().doc.clone();
|
||||
open(&mut app);
|
||||
assert_eq!(confirm(&mut app).unwrap_err(), "Editor not installed");
|
||||
assert!(app.smart_svg.editing.is_empty());
|
||||
assert_eq!(app.session.active().unwrap().doc, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_editor_and_repeated_saves_update_with_undo_and_preserve_selection() {
|
||||
let (mut app, saves, calls) = fixture();
|
||||
let before = app.session.active().unwrap().doc.clone();
|
||||
let target = app.session.active().unwrap().active_layer;
|
||||
open(&mut app);
|
||||
app.smart_svg.pending.as_mut().unwrap().editor = Editor::Illustrator;
|
||||
confirm(&mut app).unwrap();
|
||||
assert!(matches!(calls.borrow().first(), Some(ExternalSvgEditor::Illustrator)));
|
||||
app.session.execute("layer.new.layer", json!({})).unwrap();
|
||||
let selection = app.session.active().unwrap().active_layer;
|
||||
let ctx = egui::Context::default();
|
||||
saves.borrow_mut().push_back(Ok(SVG.replace("red", "blue").into_bytes()));
|
||||
poll(&mut app, &ctx);
|
||||
let blue = app.session.active().unwrap().doc.clone();
|
||||
assert_ne!(blue, before);
|
||||
assert_ne!(selection, target);
|
||||
assert_eq!(app.session.active().unwrap().active_layer, selection);
|
||||
saves.borrow_mut().push_back(Ok(SVG.replace("red", "green").into_bytes()));
|
||||
poll(&mut app, &ctx);
|
||||
assert_ne!(app.session.active().unwrap().doc, blue);
|
||||
assert_eq!(app.smart_svg.editing.len(), 1);
|
||||
app.session.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(app.session.active().unwrap().doc, blue);
|
||||
app.session.execute("edit.undo", json!({})).unwrap();
|
||||
app.session.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(app.session.active().unwrap().doc, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_changes_wait_on_document_switch_and_active_preview() {
|
||||
let (mut app, saves, _) = fixture();
|
||||
let parent = app.session.active().unwrap().doc.id;
|
||||
open(&mut app);
|
||||
confirm(&mut app).unwrap();
|
||||
app.session.execute("file.new", json!({"width":32,"height":32})).unwrap();
|
||||
let other = app.session.active().unwrap().doc.clone();
|
||||
saves.borrow_mut().push_back(Ok(SVG.replace("red", "blue").into_bytes()));
|
||||
let ctx = egui::Context::default();
|
||||
poll(&mut app, &ctx);
|
||||
assert_eq!(app.session.active().unwrap().doc, other);
|
||||
assert!(app.smart_svg.editing.first().unwrap().saved.is_some());
|
||||
let index = app.session.documents().iter().position(|st| st.doc.id == parent).unwrap();
|
||||
app.session.set_active(index);
|
||||
app.ui.open_dialog(crate::state::DialogKind::Command, serde_json::Map::new());
|
||||
poll(&mut app, &ctx);
|
||||
assert!(app.smart_svg.editing.first().unwrap().saved.is_some());
|
||||
app.ui.dialogs.clear();
|
||||
poll(&mut app, &ctx);
|
||||
assert!(app.smart_svg.editing.first().unwrap().saved.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changed_source_stops_syncing_instead_of_overwriting_user_edit() {
|
||||
let (mut app, saves, _) = fixture();
|
||||
open(&mut app);
|
||||
confirm(&mut app).unwrap();
|
||||
let target = &app.smart_svg.editing.first().unwrap().target;
|
||||
app.session.execute("layer.smartObjects.updateSvg", json!({"document": target.doc.0,"layer": target.layer.0,"contentsId":target.contents_id,"fingerprint":target.fingerprint,"svg":SVG.replace("red","yellow")})).unwrap();
|
||||
let before = app.session.active().unwrap().doc.clone();
|
||||
saves.borrow_mut().push_back(Ok(SVG.replace("red", "blue").into_bytes()));
|
||||
poll(&mut app, &egui::Context::default());
|
||||
assert!(app.smart_svg.editing.is_empty());
|
||||
assert!(app.ui.status_error);
|
||||
assert_eq!(app.session.active().unwrap().doc, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_prompt_never_launches_into_another_document_or_changed_source() {
|
||||
for switched in [false, true] {
|
||||
let (mut app, _, calls) = fixture();
|
||||
open(&mut app);
|
||||
if switched {
|
||||
app.session.execute("file.new", json!({"width":32,"height":32})).unwrap();
|
||||
} else {
|
||||
let target = &app.smart_svg.pending.as_ref().unwrap().target;
|
||||
app.session.execute("layer.smartObjects.updateSvg", json!({"document":target.doc.0,"layer":target.layer.0,"contentsId":target.contents_id,"fingerprint":target.fingerprint,"svg":SVG.replace("red","blue")})).unwrap();
|
||||
}
|
||||
assert!(confirm(&mut app).is_err());
|
||||
assert!(calls.borrow().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_and_transient_saves_can_recover_and_deleted_layer_stops_session() {
|
||||
let (mut app, saves, _) = fixture();
|
||||
open(&mut app);
|
||||
confirm(&mut app).unwrap();
|
||||
let before = app.session.active().unwrap().doc.clone();
|
||||
saves.borrow_mut().push_back(Ok(vec![255]));
|
||||
poll(&mut app, &egui::Context::default());
|
||||
assert_eq!(app.smart_svg.editing.len(), 1);
|
||||
assert_eq!(app.session.active().unwrap().doc, before);
|
||||
saves.borrow_mut().push_back(Err("Editor is replacing its SVG file".into()));
|
||||
poll(&mut app, &egui::Context::default());
|
||||
assert_eq!(app.smart_svg.editing.len(), 1);
|
||||
saves.borrow_mut().push_back(Ok(b"<svg><".to_vec()));
|
||||
poll(&mut app, &egui::Context::default());
|
||||
assert_eq!(app.smart_svg.editing.len(), 1);
|
||||
assert_eq!(app.session.active().unwrap().doc, before);
|
||||
saves.borrow_mut().push_back(Ok(SVG.replace("red", "blue").into_bytes()));
|
||||
poll(&mut app, &egui::Context::default());
|
||||
assert_ne!(app.session.active().unwrap().doc, before);
|
||||
let st = app.session.active_mut().unwrap();
|
||||
Arc::make_mut(&mut st.doc).layers.clear();
|
||||
poll(&mut app, &egui::Context::default());
|
||||
assert!(app.smart_svg.editing.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_executable_requires_explicit_selection() {
|
||||
let (mut app, _, calls) = fixture();
|
||||
open(&mut app);
|
||||
app.smart_svg.pending.as_mut().unwrap().editor = Editor::Program;
|
||||
assert!(confirm(&mut app).is_err());
|
||||
assert!(calls.borrow().is_empty());
|
||||
open(&mut app);
|
||||
let pending = app.smart_svg.pending.as_mut().unwrap();
|
||||
pending.editor = Editor::Program;
|
||||
pending.program = Some("C:/synthetic/editor.exe".into());
|
||||
confirm(&mut app).unwrap();
|
||||
assert!(matches!(calls.borrow().first(), Some(ExternalSvgEditor::Program(path)) if path == "C:/synthetic/editor.exe"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_gradient_tool_does_not_block_svg_editing() {
|
||||
let (mut app, _, calls) = fixture();
|
||||
app.ui.tool = crate::state::Tool::Gradient;
|
||||
open(&mut app);
|
||||
confirm(&mut app).unwrap();
|
||||
assert_eq!(calls.borrow().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_changes_wait_on_idle_cam_box_then_resume_after_cancel() {
|
||||
let (mut app, saves, calls) = fixture();
|
||||
let smart_layer = app.session.active().unwrap().active_layer.unwrap();
|
||||
open(&mut app);
|
||||
confirm(&mut app).unwrap();
|
||||
app.session.execute("layer.new.layer", json!({})).unwrap();
|
||||
app.session.execute("select.rect", json!({"x":8,"y":8,"width":12,"height":8})).unwrap();
|
||||
app.ui.tool = crate::state::Tool::ContentAwareMove;
|
||||
assert!(crate::cam_transform::begin(&mut app, [8, 4]));
|
||||
assert!(app.drag.is_none() && app.patch_preview.is_none() && app.ui.transform.is_none());
|
||||
let before = app.session.active().unwrap().doc.clone();
|
||||
let revision = app.session.active().unwrap().revision;
|
||||
let history = app.session.active().unwrap().history.past_len();
|
||||
let raster_layer = app.session.active().unwrap().active_layer.unwrap();
|
||||
let cam = app.ui.cam_transform.clone();
|
||||
let blue = SVG.replace("red", "blue");
|
||||
saves.borrow_mut().push_back(Ok(blue.as_bytes().to_vec()));
|
||||
let ctx = egui::Context::default();
|
||||
|
||||
poll(&mut app, &ctx);
|
||||
assert_eq!(app.session.active().unwrap().doc, before);
|
||||
assert_eq!(app.session.active().unwrap().revision, revision);
|
||||
assert_eq!(app.session.active().unwrap().history.past_len(), history);
|
||||
assert_eq!(app.ui.cam_transform, cam);
|
||||
assert!(app.smart_svg.editing.first().unwrap().saved.is_some());
|
||||
assert!(saves.borrow().is_empty());
|
||||
|
||||
crate::cam_transform::cancel(&mut app);
|
||||
poll(&mut app, &ctx);
|
||||
let st = app.session.active().unwrap();
|
||||
let LayerContent::Smart(smart) = &st.doc.layer(smart_layer).unwrap().content else { panic!("smart") };
|
||||
let SmartSource::Embedded { bytes, .. } = &smart.source else { panic!("embedded") };
|
||||
assert_eq!(&**bytes, blue.as_bytes());
|
||||
assert_eq!(st.doc.layer(raster_layer), before.layer(raster_layer));
|
||||
assert_eq!(st.doc.selection, before.selection);
|
||||
assert_eq!(st.history.past_len(), history + 1);
|
||||
assert!(app.ui.cam_transform.is_none());
|
||||
assert!(app.smart_svg.editing.first().unwrap().saved.is_none());
|
||||
assert_eq!(app.smart_svg.editing.len(), 1);
|
||||
assert_eq!(calls.borrow().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_changes_wait_on_mask_target_then_apply_without_another_save() {
|
||||
let (mut app, saves, _) = fixture();
|
||||
open(&mut app);
|
||||
confirm(&mut app).unwrap();
|
||||
let before = app.session.active().unwrap().doc.clone();
|
||||
app.ui.mask_target = true;
|
||||
saves.borrow_mut().push_back(Ok(SVG.replace("red", "blue").into_bytes()));
|
||||
let ctx = egui::Context::default();
|
||||
poll(&mut app, &ctx);
|
||||
assert_eq!(app.session.active().unwrap().doc, before);
|
||||
assert!(app.smart_svg.editing.first().unwrap().saved.is_some());
|
||||
app.ui.mask_target = false;
|
||||
poll(&mut app, &ctx);
|
||||
assert_ne!(app.session.active().unwrap().doc, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_changes_wait_on_implicit_mask_then_resume_without_another_save() {
|
||||
let (mut app, saves, calls) = fixture();
|
||||
open(&mut app);
|
||||
confirm(&mut app).unwrap();
|
||||
let st = app.session.active_mut().unwrap();
|
||||
let layer = st.active_layer.unwrap();
|
||||
Arc::make_mut(&mut st.doc).layer_mut(layer).unwrap().mask = Some(photocraft_doc::LayerMask::reveal_all());
|
||||
st.channel_view.color_hidden = vec![true; photocraft_engine::channel_cmds::color_count(&st.doc)];
|
||||
st.channel_view.alpha_visible.clear();
|
||||
assert_eq!(st.channel_view.target, photocraft_engine::channel_cmds::ChannelTarget::Composite);
|
||||
assert!(st.channel_view.layer_mask.is_none());
|
||||
let before = st.doc.clone();
|
||||
let revision = st.revision;
|
||||
assert!(!app.ui.mask_target);
|
||||
assert!(!app.session.tools.mask_targeted);
|
||||
assert!(app.layer_mask_targeted());
|
||||
let blue = SVG.replace("red", "blue");
|
||||
saves.borrow_mut().push_back(Ok(blue.as_bytes().to_vec()));
|
||||
let ctx = egui::Context::default();
|
||||
|
||||
poll(&mut app, &ctx);
|
||||
assert_eq!(app.session.active().unwrap().doc, before);
|
||||
assert_eq!(app.session.active().unwrap().revision, revision);
|
||||
assert!(app.smart_svg.editing.first().unwrap().saved.is_some());
|
||||
assert!(saves.borrow().is_empty());
|
||||
|
||||
app.session.active_mut().unwrap().channel_view.color_hidden.clear();
|
||||
assert!(!app.layer_mask_targeted());
|
||||
poll(&mut app, &ctx);
|
||||
let st = app.session.active().unwrap();
|
||||
assert_ne!(st.doc, before);
|
||||
assert_eq!(st.doc.layer(layer).unwrap().mask, before.layer(layer).unwrap().mask);
|
||||
let LayerContent::Smart(smart) = &st.doc.layer(layer).unwrap().content else { panic!("smart") };
|
||||
let SmartSource::Embedded { bytes, .. } = &smart.source else { panic!("embedded") };
|
||||
assert_eq!(&**bytes, blue.as_bytes());
|
||||
assert!(app.smart_svg.editing.first().unwrap().saved.is_none());
|
||||
assert_eq!(calls.borrow().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_mask_channel_roundtrip_resumes_queued_svg_save_with_masks_preserved() {
|
||||
for channel in ["mask", "vectorMask"] {
|
||||
let (mut app, saves, calls) = fixture();
|
||||
open(&mut app);
|
||||
confirm(&mut app).unwrap();
|
||||
app.session.execute("layer.layerMask.revealAll", json!({})).unwrap();
|
||||
app.session.execute("layer.vectorMask.revealAll", json!({})).unwrap();
|
||||
let before = app.session.active().unwrap().doc.clone();
|
||||
let layer = app.session.active().unwrap().active_layer.unwrap();
|
||||
|
||||
app.run("channel.target", json!({"channel":channel})).unwrap();
|
||||
assert_eq!(app.ui.mask_target, channel == "mask");
|
||||
assert_eq!(app.ui.vector_mask_target, channel == "vectorMask");
|
||||
let blue = SVG.replace("red", "blue");
|
||||
saves.borrow_mut().push_back(Ok(blue.as_bytes().to_vec()));
|
||||
let ctx = egui::Context::default();
|
||||
poll(&mut app, &ctx);
|
||||
assert_eq!(app.session.active().unwrap().doc, before);
|
||||
assert!(app.smart_svg.editing.first().unwrap().saved.is_some());
|
||||
assert!(saves.borrow().is_empty());
|
||||
|
||||
app.run("channel.target", json!({"channel":"composite"})).unwrap();
|
||||
assert!(!app.ui.mask_target);
|
||||
assert!(!app.ui.vector_mask_target);
|
||||
assert!(!app.session.tools.mask_targeted);
|
||||
poll(&mut app, &ctx);
|
||||
let st = app.session.active().unwrap();
|
||||
let updated = st.doc.layer(layer).unwrap();
|
||||
assert_eq!(updated.mask, before.layer(layer).unwrap().mask);
|
||||
assert_eq!(updated.vector_mask, before.layer(layer).unwrap().vector_mask);
|
||||
let LayerContent::Smart(smart) = &updated.content else { panic!("smart") };
|
||||
let SmartSource::Embedded { bytes, .. } = &smart.source else { panic!("embedded") };
|
||||
assert_eq!(&**bytes, blue.as_bytes());
|
||||
assert!(app.smart_svg.editing.first().unwrap().saved.is_none());
|
||||
assert_eq!(app.smart_svg.editing.len(), 1);
|
||||
assert_eq!(calls.borrow().len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_gradient_preview_defers_save_until_gesture_ends() {
|
||||
let (mut app, saves, _) = fixture();
|
||||
open(&mut app);
|
||||
confirm(&mut app).unwrap();
|
||||
let before = app.session.active().unwrap().doc.clone();
|
||||
app.ui.tool = crate::state::Tool::Gradient;
|
||||
app.ui.tool_options.gradient_classic = false;
|
||||
assert!(crate::gradient_ui::pointer(&mut app, crate::canvas::ToolEvent::Down { x: 0.0, y: 0.0, pressure: 1.0 }, egui::Modifiers::NONE));
|
||||
assert!(crate::gradient_ui::editing(&app));
|
||||
saves.borrow_mut().push_back(Ok(SVG.replace("red", "blue").into_bytes()));
|
||||
let ctx = egui::Context::default();
|
||||
poll(&mut app, &ctx);
|
||||
assert_eq!(app.session.active().unwrap().doc, before);
|
||||
app.gradient = Default::default();
|
||||
poll(&mut app, &ctx);
|
||||
assert_ne!(app.session.active().unwrap().doc, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounded_sessions_require_reuse_or_closing_a_document() {
|
||||
let (mut app, _, calls) = fixture();
|
||||
for _ in 0..MAX_SESSIONS {
|
||||
open(&mut app);
|
||||
confirm(&mut app).unwrap();
|
||||
photocraft_engine::file_cmds::place_bytes(&mut app.session, "Artwork.svg", SVG.as_bytes().to_vec(), None, &json!({"smartObject":true})).unwrap();
|
||||
}
|
||||
open(&mut app);
|
||||
assert!(confirm(&mut app).unwrap_err().contains("limit: 8"));
|
||||
assert_eq!(calls.borrow().len(), MAX_SESSIONS);
|
||||
assert_eq!(app.smart_svg.editing.len(), MAX_SESSIONS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capture_smart_svg_visual_fixtures() {
|
||||
let Some(directory) = std::env::var_os("CRAFT_SMART_SVG_FIXTURES").map(std::path::PathBuf::from) else { return };
|
||||
std::fs::create_dir_all(&directory).unwrap();
|
||||
for theme in [crate::theme::ThemeKind::Pro, crate::theme::ThemeKind::StudioLight] {
|
||||
let (mut app, _, _) = fixture();
|
||||
open(&mut app);
|
||||
let mut ready = false;
|
||||
let lang = crate::i18n::lang_from_tag("en").unwrap();
|
||||
let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(900.0, 620.0)).wgpu().build_ui_state(
|
||||
move |ui, app: &mut PhotocraftApp| {
|
||||
let _language = crate::i18n::language_scope(lang);
|
||||
if !ready {
|
||||
PhotocraftApp::setup_context(ui.ctx(), theme);
|
||||
ready = true;
|
||||
return;
|
||||
}
|
||||
show(app, ui.ctx());
|
||||
},
|
||||
app,
|
||||
);
|
||||
harness.ctx.set_os(egui::os::OperatingSystem::Windows);
|
||||
harness.run_steps(5);
|
||||
harness.render().unwrap().save(directory.join(format!("photocraft-smart-svg-editor-{}-en.png", theme.id()))).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// An AI payload in the PSD link table must reach explicit editor selection without becoming
|
||||
/// a filesystem path. A saved SVG replaces it through the normal optimistic transaction.
|
||||
#[test]
|
||||
fn embedded_ai_link_table_handoff_preserves_source_until_saved_and_undo_restores_it() {
|
||||
let (mut app, saves, calls) = fixture();
|
||||
let id = app.session.active().unwrap().active_layer.unwrap();
|
||||
let original = b"%PDF-1.6\nsynthetic vector".to_vec();
|
||||
{
|
||||
let doc = Arc::make_mut(&mut app.session.active_mut().unwrap().doc);
|
||||
let payload = photocraft_io::linked::encode_linked_file(&photocraft_io::linked::LinkedFile {
|
||||
uuid: "embedded-ai".into(),
|
||||
file_name: "Logo.ai".into(),
|
||||
bytes: original.clone(),
|
||||
});
|
||||
doc.metadata.psd_global_blocks.push((*b"8BIM", *b"lnk2", Arc::new(payload)));
|
||||
let LayerContent::Smart(sm) = &mut doc.layer_mut(id).unwrap().content else { panic!("smart") };
|
||||
sm.source = SmartSource::Linked { path: "embedded-ai".into() };
|
||||
}
|
||||
let before = app.session.active().unwrap().doc.clone();
|
||||
let queue = saves.clone();
|
||||
app.services.open_external_svg = Some(Box::new(move |editor, source| {
|
||||
assert_eq!(editor, ExternalSvgEditor::VectorCraft);
|
||||
assert_eq!(*source.bytes, original);
|
||||
assert_eq!(source.extension, "ai");
|
||||
Ok(Box::new(NativeTestSession(queue.clone())))
|
||||
}));
|
||||
open(&mut app);
|
||||
assert!(calls.borrow().is_empty());
|
||||
confirm(&mut app).unwrap();
|
||||
assert_eq!(app.session.active().unwrap().doc, before);
|
||||
saves.borrow_mut().push_back(Ok(b"%PDF-1.6\nedited native AI".to_vec()));
|
||||
poll(&mut app, &egui::Context::default());
|
||||
let layer = app.session.active().unwrap().doc.layer(id).unwrap();
|
||||
assert!(
|
||||
matches!(&layer.content,LayerContent::Smart(sm) if matches!(&sm.source,SmartSource::Embedded {file_name,bytes} if file_name=="Logo.ai" && bytes.as_slice()==b"%PDF-1.6\nedited native AI"))
|
||||
);
|
||||
assert!(app.session.undo());
|
||||
assert_eq!(app.session.active().unwrap().doc, before);
|
||||
}
|
||||
|
||||
/// Optional screenshot uses authored bytes and mock services, never private artwork or an editor.
|
||||
#[test]
|
||||
fn capture_embedded_vector_conversion_prompt() {
|
||||
let Some(directory) = std::env::var_os("CRAFT_SMART_SVG_FIXTURES").map(std::path::PathBuf::from) else { return };
|
||||
std::fs::create_dir_all(&directory).unwrap();
|
||||
let (mut app, _, _) = fixture();
|
||||
let state = app.session.active_mut().unwrap();
|
||||
let id = state.active_layer.unwrap();
|
||||
let LayerContent::Smart(sm) = &mut Arc::make_mut(&mut state.doc).layer_mut(id).unwrap().content else { panic!("smart") };
|
||||
sm.source = SmartSource::Embedded { file_name: "Authored.ai".into(), bytes: Arc::new(b"%PDF-1.6\nsynthetic vector prompt".to_vec()) };
|
||||
open(&mut app);
|
||||
let mut ready = false;
|
||||
let lang = crate::i18n::lang_from_tag("en").unwrap();
|
||||
let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(900.0, 620.0)).wgpu().build_ui_state(
|
||||
move |ui, app: &mut PhotocraftApp| {
|
||||
let _language = crate::i18n::language_scope(lang);
|
||||
if !ready {
|
||||
PhotocraftApp::setup_context(ui.ctx(), crate::theme::ThemeKind::Pro);
|
||||
ready = true;
|
||||
return;
|
||||
}
|
||||
show(app, ui.ctx());
|
||||
},
|
||||
app,
|
||||
);
|
||||
harness.run_steps(5);
|
||||
harness.render().unwrap().save(directory.join("photocraft-embedded-vector-editor-en.png")).unwrap();
|
||||
}
|
||||
|
||||
/// Filter Gallery owns a document preview even though `distort.active()` excludes it.
|
||||
#[test]
|
||||
fn saved_changes_wait_for_filter_gallery_and_resume_after_cancel() {
|
||||
let (mut app, saves, _) = fixture();
|
||||
open(&mut app);
|
||||
confirm(&mut app).unwrap();
|
||||
crate::gallery_ui::open(&mut app).unwrap();
|
||||
assert!(app.distort.gallery.is_some());
|
||||
assert!(!app.distort.active());
|
||||
let before = app.session.active().unwrap().doc.clone();
|
||||
let changed = SVG.replace("red", "blue");
|
||||
saves.borrow_mut().push_back(Ok(changed.as_bytes().to_vec()));
|
||||
let ctx = egui::Context::default();
|
||||
poll(&mut app, &ctx);
|
||||
assert_eq!(app.session.active().unwrap().doc, before);
|
||||
assert!(app.smart_svg.editing.first().unwrap().saved.is_some());
|
||||
crate::gallery_ui::control(&mut app, &json!({"cancel":true})).unwrap();
|
||||
poll(&mut app, &ctx);
|
||||
assert!(app.smart_svg.editing.first().unwrap().saved.is_none());
|
||||
let state = app.session.active().unwrap();
|
||||
let LayerContent::Smart(smart) = &state.doc.layer(state.active_layer.unwrap()).unwrap().content else { panic!("smart object") };
|
||||
let photocraft_doc::SmartSource::Embedded { bytes, .. } = &smart.source else { panic!("embedded") };
|
||||
assert_eq!(bytes.as_slice(), changed.as_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_changes_wait_through_quick_mask_and_resume_without_another_save() {
|
||||
let (mut app, saves, _) = fixture();
|
||||
open(&mut app);
|
||||
confirm(&mut app).unwrap();
|
||||
app.run("select.editInQuickMaskMode", json!({"on":true})).unwrap();
|
||||
let before = app.session.active().unwrap().doc.clone();
|
||||
saves.borrow_mut().push_back(Ok(SVG.replace("red", "blue").into_bytes()));
|
||||
let ctx = egui::Context::default();
|
||||
poll(&mut app, &ctx);
|
||||
assert_eq!(app.session.active().unwrap().doc, before);
|
||||
assert!(app.smart_svg.editing.first().unwrap().saved.is_some());
|
||||
app.run("select.editInQuickMaskMode", json!({"on":false})).unwrap();
|
||||
poll(&mut app, &ctx);
|
||||
assert!(app.smart_svg.editing.first().unwrap().saved.is_none());
|
||||
let st = app.session.active().unwrap();
|
||||
let photocraft_doc::LayerContent::Smart(sm) = &st.doc.layer(st.active_layer.unwrap()).unwrap().content else { panic!("smart object") };
|
||||
let photocraft_doc::SmartSource::Embedded { bytes, .. } = &sm.source else { panic!("embedded") };
|
||||
assert_eq!(bytes.as_slice(), SVG.replace("red", "blue").as_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_changes_wait_for_layers_panel_dialog_before_first_modal_frame() {
|
||||
let (mut app, saves, _) = fixture();
|
||||
open(&mut app);
|
||||
confirm(&mut app).unwrap();
|
||||
let before = app.session.active().unwrap().doc.clone();
|
||||
app.ui.layers_panel_dialog = Some(crate::state::LayersPanelDialog::NewGroup("New group".into()));
|
||||
saves.borrow_mut().push_back(Ok(SVG.replace("red", "blue").into_bytes()));
|
||||
let ctx = egui::Context::default();
|
||||
assert!(ctx.memory(|memory| memory.top_modal_layer().is_none()));
|
||||
poll(&mut app, &ctx);
|
||||
assert_eq!(app.session.active().unwrap().doc, before);
|
||||
assert!(app.smart_svg.editing.first().unwrap().saved.is_some());
|
||||
app.ui.layers_panel_dialog = None;
|
||||
poll(&mut app, &ctx);
|
||||
assert_ne!(app.session.active().unwrap().doc, before);
|
||||
assert!(app.smart_svg.editing.first().unwrap().saved.is_none());
|
||||
}
|
||||
|
||||
struct FinishedSession(Option<Result<Vec<u8>, String>>);
|
||||
impl ExternalSvgSession for FinishedSession {
|
||||
fn poll(&mut self) -> Option<Result<Vec<u8>, String>> {
|
||||
self.0.take()
|
||||
}
|
||||
fn finished(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelled_desktop_choosers_release_session_capacity_without_changing_content() {
|
||||
let (mut app, _, _) = fixture();
|
||||
app.services.open_external_svg = Some(Box::new(|_, _| Ok(Box::new(FinishedSession(Some(Err("Editor selection cancelled".into())))))));
|
||||
let before = app.session.active().unwrap().doc.clone();
|
||||
let ctx = egui::Context::default();
|
||||
for _ in 0..MAX_SESSIONS + 1 {
|
||||
open(&mut app);
|
||||
confirm(&mut app).unwrap();
|
||||
poll(&mut app, &ctx);
|
||||
assert!(app.smart_svg.editing.is_empty());
|
||||
assert_eq!(app.session.active().unwrap().doc, before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finished_session_retains_final_save_until_target_is_safe() {
|
||||
let (mut app, _, _) = fixture();
|
||||
app.services.open_external_svg = Some(Box::new(|_, _| Ok(Box::new(FinishedSession(Some(Ok(SVG.replace("red", "blue").into_bytes())))))));
|
||||
open(&mut app);
|
||||
confirm(&mut app).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
app.run("select.editInQuickMaskMode", json!({"on":true})).unwrap();
|
||||
let before = app.session.active().unwrap().doc.clone();
|
||||
poll(&mut app, &ctx);
|
||||
assert_eq!(app.smart_svg.editing.len(), 1);
|
||||
assert!(app.smart_svg.editing[0].saved.is_some());
|
||||
assert_eq!(app.session.active().unwrap().doc, before);
|
||||
app.run("select.editInQuickMaskMode", json!({"on":false})).unwrap();
|
||||
poll(&mut app, &ctx);
|
||||
assert_ne!(app.session.active().unwrap().doc, before);
|
||||
poll(&mut app, &ctx);
|
||||
assert!(app.smart_svg.editing.is_empty());
|
||||
}
|
||||
@@ -77,6 +77,11 @@ pub struct LiveGradient {
|
||||
preview: Option<(u64, Arc<Document>)>,
|
||||
}
|
||||
|
||||
/// Whether an uncommitted gradient gesture must finish before applying a saved vector.
|
||||
pub(crate) fn editing(app: &PhotocraftApp) -> bool {
|
||||
app.gradient.drag.is_some() || app.gradient.panel.is_some() || app.gradient.preset_stop.is_some()
|
||||
}
|
||||
|
||||
/// Whether the Gradient tool is in its live (Gradient Fill layer) mode.
|
||||
/// Painting into a layer mask, an alpha channel or the Quick Mask stays classic (pixels), except
|
||||
/// that a selected gradient fill layer is always edited live (selecting it targets its mask).
|
||||
|
||||
@@ -26,7 +26,8 @@ pub fn cap_text_events(events: &mut [egui::Event]) {
|
||||
}
|
||||
// The highlighted range indexes the old text; past the cut it would point nowhere.
|
||||
egui::Event::Ime(egui::ImeEvent::Preedit { text, active_range_chars }) => {
|
||||
if cut(text) && active_range_chars.as_ref().is_some_and(|r| r.end > INPUT_EVENT_LIMIT) {
|
||||
let truncated = cut(text);
|
||||
if truncated && active_range_chars.as_ref().is_some_and(|r| r.end > INPUT_EVENT_LIMIT) {
|
||||
*active_range_chars = None;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
# Embedded vector smart-object editing
|
||||
|
||||
Layer > Smart Objects > Edit Contents offers VectorCraft editing for embedded SVG,
|
||||
PDF-compatible AI, PDF and EPS, including payloads retained in PSD linked-layer tables.
|
||||
Choose VectorCraft or a local executable. PhotoCraft creates a private working copy in the original format;
|
||||
saving that file updates the original smart object when its parent is active and ready.
|
||||
Each accepted save is one undo step, including all shared instances. Placement, masks,
|
||||
filters and Undo/Redo remain in the existing smart-object transaction.
|
||||
|
||||
For a vector inside a nested PSB smart object, save the edited PSB contents back to its
|
||||
parent as well. Raster/PSD/PSB internal editing retains its existing behavior.
|
||||
|
||||
## Format and platform limits
|
||||
|
||||
AI, PDF and EPS save-back retains the native file name and saved native bytes. A separate,
|
||||
source-hash-bound SVG is a display preview, never a replacement for the embedded native file.
|
||||
It is retained in `.pcraft` and a private PSD `pcVP` layer block; other PSD readers may ignore
|
||||
that block and use the layer's cached pixels. Undo restores both the original source and preview.
|
||||
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. `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,
|
||||
restricted to a fresh private temporary read/write root per snapshot, separate from the
|
||||
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. 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.
|
||||
|
||||
The PSD source-pixel frame is retained when PDF points differ from stored dimensions. The
|
||||
native file retains its own geometry; the display preview fits the original placed frame.
|
||||
Unsupported or oversized frames fail without changing the smart object.
|
||||
|
||||
Desktop integration supports an explicitly selected executable. Linux discovery checks
|
||||
standard native locations and a user Flatpak deployment. On Linux, choosing VectorCraft
|
||||
uses the desktop's Open With chooser to launch the regular application;
|
||||
select VectorCraft there. The writable document portal shares only the private working
|
||||
file. Working copies live in PhotoCraft's private cache because its isolated /tmp cannot
|
||||
be exported. Ordinary VectorCraft launches retain preferences and existing-window
|
||||
handoff instead of running a second payload inside PhotoCraft's sandbox. Reusing tabs
|
||||
requires the installed VectorCraft build's Linux single-instance support.
|
||||
|
||||
Native AI/PDF/EPS previews still need vectorcraft-cli alongside the discovered
|
||||
executable. The local Flatpak runtime test granted read-only access to the test
|
||||
VectorCraft build for that invocation. A sandboxed distribution must arrange access to
|
||||
its compatible converter; this source change adds no broad filesystem or host-process
|
||||
permissions and does not establish default Flatpak packaging acceptance. SVG editing
|
||||
through the portal does not require access to the VectorCraft deployment. Explicit
|
||||
Other application executables retain their direct-launch behavior.
|
||||
|
||||
On macOS, VectorCraft discovery also checks `/Applications/VectorCraft.app` and
|
||||
`~/Applications/VectorCraft.app` and runs the bundle's executable directly. Other application
|
||||
accepts an application bundle (`.app`): its Info.plist `CFBundleExecutable` must name an
|
||||
executable inside the bundle's `Contents/MacOS`. Bundles with the application identifier
|
||||
`ai.storyteller.vectorcraft` run directly with the working file as an argument, matching
|
||||
automatic discovery; other bundles use `/usr/bin/open -a` for open-document events. Adobe Illustrator is
|
||||
offered when an `Adobe Illustrator*.app` is found in `/Applications` (at most one folder deep).
|
||||
AI/PDF/EPS previews look for `vectorcraft-cli` in the same bundle's `Contents/MacOS`, then in
|
||||
the other known locations; current VectorCraft macOS packages ship the CLI as a separate
|
||||
download, so it must be placed there or installed beside a discovered executable. These paths
|
||||
are unit-tested on Linux with synthetic bundles. Windows and macOS runtime acceptance has not
|
||||
been performed. Browser and automation paths do not launch
|
||||
editors; browser internal editing remains available.
|
||||
|
||||
This PR does not add clipboard discovery or Paste As. It stands alone on main, without
|
||||
requiring the clipboard or Shape Tools work in #3409/#3446. Its source-watching and
|
||||
bounded SVG rendering build on credited open-source work from #3409; the embedded
|
||||
AI/PDF/EPS extension was first submitted in Skyfuller1303/photocraft#1.
|
||||
|
||||
## Safety
|
||||
|
||||
Artwork never supplies a program, command line or output pathname. Editor selection is
|
||||
explicit, without a shell. The interactive editor runs with its normal desktop permissions;
|
||||
the restricted MCP roots apply to preview generation, not to that editor process.
|
||||
Working copies reject links and nonregular files; Unix opens
|
||||
are no-follow and nonblocking. Unix save settling includes device/inode identity, including equal-time atomic replacement.
|
||||
Reads remain byte-bounded even if another process grows the file. A save that changes while it
|
||||
is being read is not reported as an error; the watcher waits for it to settle again and reads it.
|
||||
|
||||
Source input/output is limited to 16 MiB. Bounded SVG parsing permits one size-checked gzip
|
||||
layer; nested gzip is rejected both at the top level and inside embedded SVG data URLs.
|
||||
PSD preview validation caches shared sources and
|
||||
uses a 96 million pixel-work budget across distinct previews; previews beyond that budget fall
|
||||
back to the PSD cached appearance without changing the native source. The charge includes
|
||||
raster decoding, eager layer caches and embedded-SVG intermediate surfaces. Raster-bearing
|
||||
repeatable paint/effect resources reserve conservative repaint work; ordinary vector paths,
|
||||
artboard clips and blur without image references do not multiply unrelated image decoding.
|
||||
Decoded images and each canvas/cache component keep their independent 24 million pixel
|
||||
limits, with separate bounds on SVG nesting. A new saved preview also has a 96 million
|
||||
pixel-work validation limit. External file and URL images are not fetched. Missing system generic fonts fall back to installed families;
|
||||
matching typography still requires the authored fonts.
|
||||
|
||||
Memory-only layer.smartObjects.exportSvg, layer.smartObjects.updateSvg and
|
||||
layer.smartObjects.updateVectorSource validate
|
||||
parent document, layer, shared-content identity and source fingerprint. They never
|
||||
follow a linked disk pathname or launch processes. The native update command is unavailable
|
||||
to untrusted automation because a caller-supplied preview is not evidence of native appearance.
|
||||
Automation authorization also checks
|
||||
visible stored smart filters on every shared instance before replay, so a desktop LUT
|
||||
file parameter cannot inherit filesystem access. Malformed saves, locked targets and
|
||||
conflicting edits retain existing content. Saved updates wait through masks, gradients,
|
||||
other active previews and dialogs instead of interrupting an edit.
|
||||
|
||||
Frame recovery does not rasterize old previews or decode their embedded images just to
|
||||
read their dimensions. It checks the actual native-source digest, memoizes shared immutable
|
||||
preview identities and caps distinct old-preview XML at 64 MiB per update. Exhaustion
|
||||
rejects the whole update without changing any instance. PSD preview import/export keeps
|
||||
its separate 96 million pixel validation budget. Native and SVG source updates do not
|
||||
append full artwork to the replay journal; document Undo/Redo remains available.
|
||||
|
||||
## Validation evidence
|
||||
|
||||
Eight contributor-owned logo PSDs were tested through their visible nested AI vectors.
|
||||
A real VectorCraft edit/save reached PhotoCraft's native watcher and engine; child and
|
||||
parent Undo/Redo and PSD export/reopen preserved the changed vector. Original SHA256
|
||||
hashes were unchanged. This combines runtime editor/engine acceptance and separate UI
|
||||
wiring tests; it is not a claim of manual full PhotoCraft GUI acceptance. Private artwork,
|
||||
and private screenshots are excluded from this repository. The opt-in acceptance harness
|
||||
accepts local paths through environment variables; it contains no private artwork.
|
||||
|
||||

|
||||
|
||||
Earlier SVG-only Linux Flatpak GUI acceptance verified an editor save through the writable document
|
||||
portal reached PhotoCraft's watcher, with an ordinary document opening as a second tab
|
||||
in the same VectorCraft window. Both launch orders were checked.
|
||||
|
||||

|
||||
|
||||
Separate isolated VectorCraft GUI tests saved AI, PDF and EPS back to the same paths
|
||||
and opened a second document as another tab. The native-format watcher/engine test
|
||||
also passed inside PhotoCraft's Flatpak runtime. These tests do not establish lossless
|
||||
Adobe compatibility or Windows/macOS acceptance.
|
||||
|
||||
D-Bus tests cover
|
||||
chooser success/cancellation/failure and reject symlinks and FIFOs before sharing.
|
||||
|
||||
Offscreen dialog capture with authored bytes and mock services; no private artwork.
|
||||
@@ -1,6 +1,6 @@
|
||||
# File-format parity
|
||||
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-24 · **Change:** minor (PSD Filter Gallery: Halftone Pattern dot/circle/line settings survive PSD/PSB saves (#8940); Save for Web writes WebP, lossy or lossless, #6391; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency, #8877; layered TIFF writes the TySh text version and descriptor in PSD big-endian order while the header, matrix, warp and bounds keep container order, #9098; layered TIFF writes Photoshop's 14-byte global layer mask, so Photoshop opens the layers instead of only the composite, #9095; legacy PSD point text placed from the layout ink box after font resolution, #6094; `.atn`: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; `.asl` layer style libraries read and written, #1051; JPEG Options on File › Save, Save As and Save a Copy, FILE-215-19 partial (no Matte, progressive Scans or size preview); 32-bit Hue/Saturation unclipped and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; JPEG Options on Save As and Save a Copy, FILE-215-19; TIFF compression for scripted saves, `tiffCompression`, #5301; the Nikon D4 white level follows the sample depth (12 and 14 bit), #5978; Apply Layer Comp action mapping, #7101; Cineon write: 10-bit gray/RGB; animated WebP frames load into video layers, #1198; Photoshop mixed-order TIFF type geometry, #4758; DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 53 filters (39 more: Pinch, Polar Coordinates, Ripple, Spherize, Twirl, Wave, ZigZag, Invert, Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint, Clouds, Difference Clouds, Fibers, Lens Flare and others) plus Hue/Saturation and Perspective Warp mapped both ways, #6296, #6298, #6300, #6302, #6304, #6314, #6316, #6267, #6268, FILE-215-2; large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: smart objects keep Photoshop's Cylinder warp through re-renders and saves, #4112; PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding; Amiga IFF write: 24-plane ILBM, Save As `.iff`; PSD stores spot channels as Photoshop does, ink 0 = solid, and numbers them in 1053, #7415 split; TIFF writes and reads alpha and spot channels as extra samples with Photoshop's channel resources, PSD output unchanged, FILE-215-7, FILE-215-17's TIFF part, #7415; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency) · **Target:** Adobe Photoshop 2026
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-26 · **Change:** minor (embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; PSD Filter Gallery: Halftone Pattern dot/circle/line settings survive PSD/PSB saves (#8940); Save for Web writes WebP, lossy or lossless, #6391; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency, #8877; layered TIFF writes the TySh text version and descriptor in PSD big-endian order while the header, matrix, warp and bounds keep container order, #9098; layered TIFF writes Photoshop's 14-byte global layer mask, so Photoshop opens the layers instead of only the composite, #9095; legacy PSD point text placed from the layout ink box after font resolution, #6094; `.atn`: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; `.asl` layer style libraries read and written, #1051; JPEG Options on File › Save, Save As and Save a Copy, FILE-215-19 partial (no Matte, progressive Scans or size preview); 32-bit Hue/Saturation unclipped and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; JPEG Options on Save As and Save a Copy, FILE-215-19; TIFF compression for scripted saves, `tiffCompression`, #5301; the Nikon D4 white level follows the sample depth (12 and 14 bit), #5978; Apply Layer Comp action mapping, #7101; Cineon write: 10-bit gray/RGB; animated WebP frames load into video layers, #1198; Photoshop mixed-order TIFF type geometry, #4758; DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 53 filters (39 more: Pinch, Polar Coordinates, Ripple, Spherize, Twirl, Wave, ZigZag, Invert, Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint, Clouds, Difference Clouds, Fibers, Lens Flare and others) plus Hue/Saturation and Perspective Warp mapped both ways, #6296, #6298, #6300, #6302, #6304, #6314, #6316, #6267, #6268, FILE-215-2; large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: smart objects keep Photoshop's Cylinder warp through re-renders and saves, #4112; PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding; Amiga IFF write: 24-plane ILBM, Save As `.iff`; PSD stores spot channels as Photoshop does, ink 0 = solid, and numbers them in 1053, #7415 split; TIFF writes and reads alpha and spot channels as extra samples with Photoshop's channel resources, PSD output unchanged, FILE-215-7, FILE-215-17's TIFF part, #7415; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency) · **Target:** Adobe Photoshop 2026
|
||||
|
||||
**PSD Filter Gallery (#8940, 2026-10-23):** Halftone Pattern maps its size, contrast and dot/circle/line pattern as editable settings on import and export, with the reference application's descriptor keys (`HlSz`, `Cntr`, `ScrT`: `ScrD`/`ScrC`/`ScrL`). PSD/PSB round trips pass at 8/16/32 bits. This does not claim pixel equivalence for the existing renderer. Unknown or malformed settings remain verbatim on import.
|
||||
|
||||
@@ -140,10 +140,16 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings.
|
||||
| Curves `.acv`, Levels `.alv`, other adjustment presets | R/W | not audited | |
|
||||
| C2PA Content Credentials | R/W | no | #1050 |
|
||||
|
||||
## Embedded vectors in PSD smart objects (2026-10-22)
|
||||
|
||||
Embedded SVG and PDF-compatible AI/PDF/EPS payloads can use an explicit VectorCraft editor choice, including nested PSB contents. AI/PDF/EPS working copies retain their format on save-back; a separate source-bound SVG display preview does not replace the native payload. Placement, shared instances, masks and Undo/Redo use existing smart-object transactions, with native bytes and previews persisted through .pcraft and PSD/PSB saves. Unsupported editor features may still change; standalone EPS/DCS import and lossless Adobe round-trip compatibility remain unverified. See [workflow, safety and validation limits](embedded-vector-editing.md). No corpus floor or parity percentage is changed.
|
||||
|
||||
## Revision history
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-26 | minor | Embedded AI/PDF/EPS save-back retains native format and a separate source-bound display preview; original sources remain undoable. Native Adobe interoperability and standalone EPS/DCS remain unverified; no corpus floor or percentage changed. |
|
||||
| 2026-10-22 | minor | Embedded PSD vectors gain explicit bounded SVG editor/save-back; standalone EPS/DCS and native-format fidelity remain open. |
|
||||
| 2026-10-24 | minor | Layered TIFF: the `LMsk` block in tag 37724 carries Photoshop's default global layer mask (14 bytes) instead of none; with an empty block Photoshop read only the composite (#9095). PSD output unchanged |
|
||||
| 2026-10-24 | minor | Layered TIFF writes the TySh text version and descriptor in PSD big-endian order while retaining container order for the block header, transform matrix, warp section and bounds (#9098); the mixed-order read path remains covered by #4758/#4771. |
|
||||
| 2026-10-23 | minor | PSD: spot channels are stored and read as Photoshop 27.11's PSDs store them (ink 0 = solid; all 7 of its spot PSDs store the 100/25/75/0 % columns as 0/191/64/255), and resource 1053 numbers the extra channels 3, 4, 5, … after 0 for the merged transparency. A PSD with spot channels whose 1057 is exactly what PhotoCraft 0.1.0–0.16.0 wrote, and with no 1053 or one kept from an opened Photoshop file that doesn't list every channel, is read as those versions stored it (straight past the channels that 1053 lists), so it keeps its ink. Multichannel is unchanged (#7415 split, part 4) |
|
||||
|
||||
+4
-1
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 112 KiB |
Reference in New Issue
Block a user