Support embedded vector editing through regular desktop editors

Offer explicit external editing for embedded SVG, PDF-compatible AI, PDF and
EPS smart objects, with bounded conversion/rendering and transactional save-back
that retains placement, shared instances, masks, filters and undo history.

On Linux, export the checked writable working file through the desktop portal
so the regular VectorCraft instance handles it. Keep Flatpak working copies in
the app-private cache and release completed editor sessions after cancellation.
Do not execute another Flatpak's interactive payload inside PhotoCraft.

Adapt the credited SVG editor foundation from Skyfuller1303's 897f2007 without
its clipboard or unrelated Shape Tools changes. Add current-main compatibility,
embedded native-vector support, regression tests and authored screenshots.

Co-authored-by: Skyfuller1303 <skyfullerdesign@gmail.com>
This commit is contained in:
Don Omondi
2026-10-22 17:23:55 +03:00
parent 1104b6f211
commit 39d270a5c3
57 changed files with 4047 additions and 34 deletions
+11
View File
@@ -85,6 +85,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 |
Artwork shown in the screenshots (all public domain, via Wikimedia Commons; details in
[`docs/images/SOURCES.md`](docs/images/SOURCES.md)):
@@ -122,3 +124,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
+4
View File
@@ -4670,6 +4670,8 @@ dependencies = [
"resvg",
"rfd",
"ron",
"roxmltree 0.20.0",
"rustix 1.1.5",
"serde_json",
"tempfile",
"winit",
@@ -4808,6 +4810,7 @@ dependencies = [
name = "photocraft-engine"
version = "0.12.0"
dependencies = [
"base64 0.22.1",
"blake3",
"craft-print",
"exr",
@@ -4905,6 +4908,7 @@ dependencies = [
"craft-docpath",
"crc32fast",
"flate2",
"imagesize",
"photocraft-affinity",
"photocraft-algo",
"photocraft-cms",
+5
View File
@@ -35,6 +35,8 @@ serde_json = { workspace = true }
log = { workspace = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
tempfile = "=3.27.0"
roxmltree = "0.20"
rfd = { workspace = true }
arboard = { version = "3.6", features = ["wayland-data-control"] }
open = "5"
@@ -63,6 +65,9 @@ muda = { version = "=0.21.1", default-features = false }
dispatch2 = { version = "=0.3.1", default-features = false, features = ["std"] }
# Drop position on X11 (`cursor.rs`): pure-Rust X client, already in Cargo.lock; pinned exactly.
[target.'cfg(unix)'.dependencies]
rustix = { version = "=1.1.5", features = ["fs"] }
[target.'cfg(target_os = "linux")'.dependencies]
photocraft-cms = { workspace = true }
# The print portal's PDF: a private (0600) temporary file, removed when the dialog is done.
+640
View File
@@ -0,0 +1,640 @@
//! Explicit desktop SVG editing. Clipboard/source data never supplies a command or program path.
//!
//! The owned temporary SVG is watched off the UI thread. Closing the PhotoCraft session stops
//! watching and removes its temporary directory; it does not terminate the external editor.
use photocraft_ui_egui::{ExternalSvgEditor, ExternalSvgSession};
/// Start an explicit native editor session; artwork cannot supply executable paths.
pub(crate) fn open(editor: ExternalSvgEditor, source: photocraft_ui_egui::ExternalVectorSource) -> Result<Box<dyn ExternalSvgSession>, String> {
native::open(editor, source)
}
mod native {
use super::*;
use std::fs::{Metadata, OpenOptions};
use std::io::{Read, Write};
use std::path::Path;
use std::path::PathBuf;
use std::process::Child;
use std::sync::mpsc::{self, Receiver, Sender, SyncSender, TrySendError};
use std::thread;
use std::time::{Duration, Instant, SystemTime};
use tempfile::TempDir;
const MAX_BYTES: usize = 16 * 1024 * 1024;
const POLL_INTERVAL: Duration = Duration::from_millis(200);
const SAVE_SETTLE: Duration = Duration::from_millis(600);
/// Resolve the chosen editor and move conversion, launch and watching off the UI thread.
pub(super) fn open(editor: ExternalSvgEditor, source: photocraft_ui_egui::ExternalVectorSource) -> Result<Box<dyn ExternalSvgSession>, String> {
// A Flatpak payload belongs to the caller's sandbox when executed directly. Let the
// desktop launch the actual editor and grant it just this writable working file.
#[cfg(target_os = "linux")]
let portal = matches!(&editor, ExternalSvgEditor::VectorCraft);
#[cfg(not(target_os = "linux"))]
let portal = false;
let executable = if portal && source.extension == "svg" { None } else { Some(resolve_editor(editor)?) };
if source.bytes.is_empty() || source.bytes.len() > MAX_BYTES || !matches!(source.extension.as_str(), "svg" | "pdf" | "ai" | "eps") {
return Err("Unsupported embedded vector source or source exceeds 16 MiB".into());
}
// Conversion and editor startup run off the UI thread. Dropping this session cancels
// conversion; it never kills the interactive editor or changes the parent document.
let (stop, stopped) = mpsc::channel();
let (send, updates) = mpsc::sync_channel(2);
thread::Builder::new()
.name("photocraft-vector-editor".into())
.spawn(move || {
let prepared = match executable.as_deref() {
Some(executable) => super::vector_conversion::prepare(executable, &source, &stopped),
None => OwnedSvg::create(&source.bytes).map(|owned| (owned, source.bytes.as_ref().clone())),
};
match prepared {
Ok((owned, initial)) => {
if stopped.try_recv().is_ok() {
return;
}
#[cfg(target_os = "linux")]
if portal {
match super::portal::open(&owned, &stopped) {
Ok(()) => watch(owned, initial, None, stopped, send, POLL_INTERVAL, SAVE_SETTLE),
Err(error) => {
let _ = send.try_send(Err(error));
}
}
return;
}
if let Some(executable) = executable {
match std::process::Command::new(executable).arg(&owned.file).spawn() {
Ok(child) => watch(owned, initial, Some(child), stopped, send, POLL_INTERVAL, SAVE_SETTLE),
Err(error) => {
let _ = send.try_send(Err(format!("Could not open the vector editor: {error}")));
}
}
}
}
Err(error) => {
let _ = send.try_send(Err(error));
}
}
})
.map_err(|e| format!("Cannot start vector editor session: {e}"))?;
Ok(Box::new(Session { stop, updates, finished: false }))
}
/// Discover only known installations or an explicitly chosen executable.
#[cfg(unix)]
fn resolve_editor(editor: ExternalSvgEditor) -> Result<PathBuf, String> {
match editor {
ExternalSvgEditor::Program(path) => validate_executable(Path::new(&path)),
ExternalSvgEditor::VectorCraft => {
let mut candidates = vec![PathBuf::from("/usr/local/bin/vectorcraft"), PathBuf::from("/usr/bin/vectorcraft")];
if let Some(home) = std::env::var_os("HOME") {
let home = PathBuf::from(home);
candidates.insert(0, home.join(".local/share/flatpak/app/ai.storyteller.vectorcraft/current/active/files/bin/vectorcraft"));
candidates.push(home.join(".local/bin/vectorcraft"));
}
candidates
.into_iter()
.find_map(|path| validate_executable(&path).ok())
.ok_or_else(|| "VectorCraft was not found. Choose its executable with Other application".into())
}
ExternalSvgEditor::Illustrator => Err("Choose a local executable with Other application".into()),
}
}
/// Canonicalize an absolute user-selected path and require an executable regular file.
#[cfg(unix)]
fn validate_executable(path: &Path) -> Result<PathBuf, String> {
use std::os::unix::fs::PermissionsExt;
if !path.is_absolute() {
return Err("Choose an absolute executable path".into());
}
let path = path.canonicalize().map_err(|e| format!("Cannot resolve the chosen editor: {e}"))?;
let metadata = std::fs::symlink_metadata(&path).map_err(|e| format!("Cannot read the chosen editor: {e}"))?;
ensure_regular(&metadata)?;
if metadata.permissions().mode() & 0o111 == 0 {
return Err("The chosen file is not executable".into());
}
Ok(path)
}
#[cfg(windows)]
fn resolve_editor(editor: ExternalSvgEditor) -> Result<PathBuf, String> {
match editor {
ExternalSvgEditor::Program(path) => validate_executable(Path::new(&path)),
ExternalSvgEditor::VectorCraft => {
let mut candidates = vec![];
for variable in ["ProgramFiles", "ProgramFiles(x86)"] {
if let Some(root) = std::env::var_os(variable) {
candidates.push(PathBuf::from(root).join("VectorCraft/vectorcraft.exe"));
}
}
if let Some(root) = std::env::var_os("LOCALAPPDATA") {
candidates.push(PathBuf::from(root).join("Programs/VectorCraft/vectorcraft.exe"));
}
candidates
.into_iter()
.find_map(|path| validate_executable(&path).ok())
.ok_or_else(|| "VectorCraft was not found. Choose its executable with Other editor".into())
}
ExternalSvgEditor::Illustrator => {
let mut candidates = vec![];
for variable in ["ProgramFiles", "ProgramFiles(x86)"] {
let Some(root) = std::env::var_os(variable) else { continue };
let adobe = PathBuf::from(root).join("Adobe");
// One known installation level only; never search the user's disks recursively.
let Ok(entries) = std::fs::read_dir(&adobe) else { continue };
for entry in entries.take(64).flatten() {
if !entry.file_name().to_string_lossy().starts_with("Adobe Illustrator") {
continue;
}
candidates.push(entry.path().join("Support Files/Contents/Windows/Illustrator.exe"));
}
}
candidates.sort();
candidates
.into_iter()
.rev()
.find_map(|path| validate_executable(&path).ok())
.ok_or_else(|| "Illustrator was not found. Choose its executable with Other editor".into())
}
}
}
#[cfg(windows)]
fn validate_executable(path: &Path) -> Result<PathBuf, String> {
if !path.is_absolute() || !path.extension().and_then(|ext| ext.to_str()).is_some_and(|ext| ext.eq_ignore_ascii_case("exe")) {
return Err("Choose an absolute Windows .exe path; scripts and command lines are not supported".into());
}
let metadata = std::fs::symlink_metadata(path).map_err(|e| format!("Cannot read the chosen editor: {e}"))?;
ensure_regular(&metadata)?;
path.canonicalize().map_err(|e| format!("Cannot resolve the chosen editor: {e}"))
}
pub(super) struct OwnedSvg {
directory: TempDir,
pub(super) root: PathBuf,
pub(super) file: PathBuf,
}
impl OwnedSvg {
/// Create a private working file from bounded source bytes.
pub(super) fn create(svg: &[u8]) -> Result<Self, String> {
if svg.is_empty() || svg.len() > MAX_BYTES {
return Err("The SVG must contain between 1 byte and 16 MiB".into());
}
let mut builder = tempfile::Builder::new();
builder.prefix("photocraft-svg-edit-");
// Flatpak's /tmp has no host pathname that the document portal can export.
// The app-private cache does; TempDir still creates a private random directory
// and removes it when syncing stops. Only Artwork.svg is shared with the editor.
#[cfg(target_os = "linux")]
let cache = if Path::new("/.flatpak-info").is_file() {
let cache = std::env::var_os("XDG_CACHE_HOME")
.map(PathBuf::from)
.filter(|path| path.is_absolute())
.ok_or("A sandbox-private cache directory is required for vector editing")?;
std::fs::create_dir_all(&cache).map_err(|e| format!("Cannot create vector editing cache: {e}"))?;
Some(cache)
} else {
None
};
#[cfg(not(target_os = "linux"))]
let cache: Option<PathBuf> = None;
let directory = match cache {
Some(cache) => builder.tempdir_in(cache),
None => builder.tempdir(),
}
.map_err(|e| format!("Cannot create the SVG editing directory: {e}"))?;
let root = directory.path().canonicalize().map_err(|e| format!("Cannot resolve the SVG editing directory: {e}"))?;
let file = root.join("Artwork.svg");
let mut output = OpenOptions::new().write(true).create_new(true).open(&file).map_err(|e| format!("Cannot create the SVG editing file: {e}"))?;
output.write_all(svg).and_then(|()| output.sync_all()).map_err(|e| format!("Cannot write the SVG editing file: {e}"))?;
Ok(Self { directory, root, file })
}
/// Validate the working directory and capture a regular file identity for save settling.
pub(super) fn stamp(&self) -> Result<Stamp, String> {
let directory_metadata = std::fs::symlink_metadata(&self.root).map_err(|e| format!("Cannot inspect the SVG editing directory: {e}"))?;
if !directory_metadata.is_dir() || is_redirect(&directory_metadata) || self.directory.path().canonicalize().ok().as_ref() != Some(&self.root) {
return Err("The SVG editing directory was replaced or redirected".into());
}
let metadata = std::fs::symlink_metadata(&self.file).map_err(|e| format!("Cannot inspect the edited SVG: {e}"))?;
ensure_regular(&metadata)?;
Stamp::from_metadata(&metadata)
}
/// Read one settled snapshot, retaining byte limits during concurrent file growth.
pub(super) fn read(&self, expected: &Stamp) -> Result<Vec<u8>, String> {
if &self.stamp()? != expected {
return Err("The SVG is still being saved".into());
}
let mut options = OpenOptions::new();
options.read(true);
no_follow(&mut options);
let file = options.open(&self.file).map_err(|e| format!("Cannot read the edited SVG: {e}"))?;
let metadata = file.metadata().map_err(|e| format!("Cannot inspect the edited SVG handle: {e}"))?;
ensure_regular(&metadata)?;
if &Stamp::from_metadata(&metadata)? != expected {
return Err("The SVG is still being saved".into());
}
let size = usize::try_from(metadata.len()).map_err(|_| "The edited SVG is too large")?;
let mut bytes = Vec::new();
bytes.try_reserve_exact(size).map_err(|_| "Not enough memory to read the edited SVG")?;
// Also bound reads if a concurrent writer grows the file after the metadata check.
file.take(MAX_BYTES as u64 + 1).read_to_end(&mut bytes).map_err(|e| format!("Cannot read the edited SVG: {e}"))?;
if bytes.is_empty() || bytes.len() > MAX_BYTES {
return Err("The edited SVG must contain between 1 byte and 16 MiB".into());
}
if bytes.len() != size || &self.stamp()? != expected {
return Err("The SVG is still being saved".into());
}
Ok(bytes)
}
/// Share a checked writable handle, never a sandbox-private pathname, with the desktop.
#[cfg(target_os = "linux")]
pub(super) fn share_handle(&self) -> Result<std::fs::File, String> {
let expected = self.stamp()?;
let mut options = OpenOptions::new();
options.read(true).write(true);
no_follow(&mut options);
let file = options.open(&self.file).map_err(|e| format!("Cannot share vector working file: {e}"))?;
let metadata = file.metadata().map_err(|e| e.to_string())?;
ensure_regular(&metadata)?;
if Stamp::from_metadata(&metadata)? != expected || self.stamp()? != expected {
return Err("The vector working file changed before sharing".into());
}
Ok(file)
}
/// Write normalized SVG through a checked, no-follow handle before editor launch.
pub(super) fn overwrite(&self, bytes: &[u8]) -> Result<(), String> {
if bytes.is_empty() || bytes.len() > MAX_BYTES {
return Err("Prepared SVG exceeds the supported byte limit".into());
}
self.stamp()?;
let mut options = OpenOptions::new();
options.write(true);
no_follow(&mut options);
let mut file = options.open(&self.file).map_err(|e| format!("Cannot open the prepared SVG: {e}"))?;
ensure_regular(&file.metadata().map_err(|e| format!("Cannot inspect the prepared SVG: {e}"))?)?;
file.set_len(0).and_then(|()| file.write_all(bytes)).and_then(|()| file.sync_all()).map_err(|e| format!("Cannot write the prepared SVG: {e}"))
}
}
/// Prevent redirected opens and avoid blocking on a substituted Unix FIFO.
fn no_follow(options: &mut OpenOptions) {
#[cfg(windows)]
{
use std::os::windows::fs::OpenOptionsExt;
// Open the reparse point itself, so replacing the file with a symlink cannot
// make this access follow it between the metadata check and opening the handle.
options.custom_flags(0x0020_0000); // FILE_FLAG_OPEN_REPARSE_POINT
}
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
// Do not follow a replaced symlink or block opening a substituted FIFO/device.
options.custom_flags((rustix::fs::OFlags::NOFOLLOW | rustix::fs::OFlags::NONBLOCK).bits() as i32);
}
}
fn ensure_regular(metadata: &Metadata) -> Result<(), String> {
if !metadata.is_file() || is_redirect(metadata) {
return Err("The SVG or editor must be a regular file, not a link or reparse point".into());
}
Ok(())
}
fn is_redirect(metadata: &Metadata) -> bool {
if metadata.file_type().is_symlink() {
return true;
}
#[cfg(windows)]
{
use std::os::windows::fs::MetadataExt;
metadata.file_attributes() & 0x0000_0400 != 0 // FILE_ATTRIBUTE_REPARSE_POINT
}
#[cfg(not(windows))]
{
false
}
}
#[derive(Clone, PartialEq, Eq)]
pub(super) struct Stamp {
modified: SystemTime,
length: u64,
#[cfg(unix)]
identity: (u64, u64),
}
impl Stamp {
/// Reject unsupported file sizes before capturing save time, length and native identity.
fn from_metadata(metadata: &Metadata) -> Result<Self, String> {
if metadata.len() == 0 || metadata.len() > MAX_BYTES as u64 {
return Err("The edited SVG must contain between 1 byte and 16 MiB".into());
}
#[cfg(unix)]
use std::os::unix::fs::MetadataExt;
Ok(Self {
modified: metadata.modified().map_err(|e| format!("Cannot inspect the SVG save time: {e}"))?,
length: metadata.len(),
#[cfg(unix)]
identity: (metadata.dev(), metadata.ino()),
})
}
}
struct Session {
stop: Sender<()>,
updates: Receiver<Result<Vec<u8>, String>>,
finished: bool,
}
impl ExternalSvgSession for Session {
fn poll(&mut self) -> Option<Result<Vec<u8>, String>> {
match self.updates.try_recv() {
Ok(update) => Some(update),
Err(mpsc::TryRecvError::Empty) => None,
Err(mpsc::TryRecvError::Disconnected) => {
self.finished = true;
None
}
}
}
fn finished(&self) -> bool {
self.finished
}
}
impl Drop for Session {
fn drop(&mut self) {
// No thread join or editor termination on the UI thread. The worker owns TempDir
// and drops it after this bounded wakeup, including when the update receiver closes.
let _ = self.stop.send(());
}
}
#[cfg(test)]
fn start(owned: OwnedSvg, initial: Vec<u8>, child: Option<Child>, interval: Duration, settle: Duration) -> Result<Session, String> {
let (stop, stopped) = mpsc::channel();
// At most two bounded updates await the UI, never an unbounded save queue.
let (send, updates) = mpsc::sync_channel(2);
thread::Builder::new()
.name("photocraft-svg-editor".into())
.spawn(move || watch(owned, initial, child, stopped, send, interval, settle))
.map_err(|e| format!("Cannot watch SVG editor saves: {e}"))?;
Ok(Session { stop, updates, finished: false })
}
fn watch(
owned: OwnedSvg,
mut last_bytes: Vec<u8>,
mut child: Option<Child>,
stopped: Receiver<()>,
send: SyncSender<Result<Vec<u8>, String>>,
interval: Duration,
settle: Duration,
) {
// The editor may save before the watcher starts. First settled read must compare bytes.
let mut last_stamp: Option<Stamp> = None;
let mut pending: Option<(Stamp, Instant)> = None;
let mut last_error: Option<String> = None;
while let Err(mpsc::RecvTimeoutError::Timeout) = stopped.recv_timeout(interval) {
if child.as_mut().is_some_and(|process| process.try_wait().ok().flatten().is_some()) {
child = None;
}
let candidate = match owned.stamp() {
Ok(stamp) => {
if last_stamp.as_ref() == Some(&stamp) {
continue;
}
match &pending {
Some((old, since)) if old == &stamp && since.elapsed() >= settle => owned.read(&stamp),
Some((old, _)) if old == &stamp => continue,
_ => {
pending = Some((stamp, Instant::now()));
continue;
}
}
}
Err(error) => {
pending = None;
Err(error)
}
};
match candidate {
Ok(bytes) => {
last_error = None;
if bytes == last_bytes {
last_stamp = pending.as_ref().map(|(stamp, _)| stamp.clone());
continue;
}
match send.try_send(Ok(bytes.clone())) {
Ok(()) => {
last_bytes = bytes;
last_stamp = pending.as_ref().map(|(stamp, _)| stamp.clone());
}
Err(TrySendError::Full(_)) => {}
Err(TrySendError::Disconnected(_)) => break,
}
}
Err(error) => {
last_stamp = None;
if last_error.as_ref() == Some(&error) {
continue;
}
match send.try_send(Err(error.clone())) {
Ok(()) => last_error = Some(error),
Err(TrySendError::Full(_)) => {}
Err(TrySendError::Disconnected(_)) => break,
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const EMPTY_SAVE: &str = "The edited SVG must contain between 1 byte and 16 MiB";
const CHANGING_SAVE: &str = "The SVG is still being saved";
fn update(session: &mut Session, deadline: Instant) -> Result<Vec<u8>, String> {
loop {
assert!(Instant::now() < deadline, "timed out waiting for an SVG save");
if let Some(update) = session.poll() {
return update;
}
thread::sleep(Duration::from_millis(10));
}
}
fn expect_saved(session: &mut Session, expected: &[u8], deadline: Instant) {
loop {
match update(session, deadline) {
Ok(bytes) => {
assert_eq!(bytes, expected, "unexpected SVG save contents");
return;
}
Err(error) => assert!(matches!(error.as_str(), EMPTY_SAVE | CHANGING_SAVE), "unexpected SVG watcher error: {error}"),
}
}
}
fn expect_empty_error(session: &mut Session, deadline: Instant) {
loop {
match update(session, deadline) {
Err(error) if error == EMPTY_SAVE => return,
Err(error) if error == CHANGING_SAVE => {}
other => panic!("expected the held-empty SVG error, got {other:?}"),
}
}
}
fn queued_session(updates: Vec<Result<Vec<u8>, String>>) -> Session {
let (stop, _stopped) = mpsc::channel();
let (send, receive) = mpsc::channel();
for update in updates {
send.send(update).unwrap();
}
Session { stop, updates: receive, finished: false }
}
#[test]
fn finished_session_reports_queued_error_before_releasing_capacity() {
let mut session = queued_session(vec![Err("Editor selection cancelled".into())]);
assert!(!session.finished());
assert!(session.poll().unwrap().is_err());
assert!(!session.finished());
assert!(session.poll().is_none());
assert!(session.finished());
}
#[test]
fn saves_debounce_repeat_and_recover_after_invalid_file() {
let initial = b"<svg/>".to_vec();
let owned = OwnedSvg::create(&initial).unwrap();
let file = owned.file.clone();
let root = owned.root.clone();
let settle = Duration::from_millis(100);
let deadline = Instant::now() + Duration::from_secs(3);
let mut session = start(owned, initial, None, Duration::from_millis(10), settle).unwrap();
let save_started = Instant::now();
let first = b"<svg>first complete</svg>";
std::fs::write(&file, first).unwrap();
expect_saved(&mut session, first, deadline);
assert!(save_started.elapsed() >= settle, "a changed save must settle before delivery");
assert!(session.poll().is_none());
std::fs::write(&file, b"").unwrap();
expect_empty_error(&mut session, deadline);
std::fs::write(&file, b"<svg>second complete</svg>").unwrap();
expect_saved(&mut session, b"<svg>second complete</svg>", deadline);
assert!(session.poll().is_none());
drop(session);
while root.exists() && Instant::now() < deadline {
thread::sleep(Duration::from_millis(10));
}
assert!(!root.exists(), "worker must clean its owned temporary SVG");
}
#[test]
fn exact_save_wait_skips_only_expected_queued_transient_errors() {
let bytes = b"<svg>complete</svg>";
let mut session = queued_session(vec![Err(EMPTY_SAVE.into()), Err(CHANGING_SAVE.into()), Ok(bytes.to_vec())]);
expect_saved(&mut session, bytes, Instant::now() + Duration::from_secs(3));
assert!(session.poll().is_none());
}
#[test]
#[should_panic(expected = "unexpected SVG watcher error")]
fn exact_save_wait_rejects_unexpected_errors() {
let mut session = queued_session(vec![Err("Cannot read the edited SVG: permission denied".into()), Ok(b"<svg/>".to_vec())]);
expect_saved(&mut session, b"<svg/>", Instant::now() + Duration::from_secs(3));
}
#[test]
fn oversize_file_is_rejected_before_allocating() {
let owned = OwnedSvg::create(b"<svg/>").unwrap();
std::fs::File::create(&owned.file).unwrap().set_len(MAX_BYTES as u64 + 1).unwrap();
assert!(owned.stamp().is_err());
assert!(OwnedSvg::create(&[]).is_err());
}
#[cfg(unix)]
#[test]
fn atomic_replacement_with_equal_size_and_mtime_is_a_new_save() {
let initial = b"<svg>one</svg>".to_vec();
let owned = OwnedSvg::create(&initial).unwrap();
let file = owned.file.clone();
let original_stamp = owned.stamp().unwrap();
let modified = std::fs::metadata(&file).unwrap().modified().unwrap();
let mut session = start(owned, initial, None, Duration::from_millis(10), Duration::from_millis(40)).unwrap();
let replacement = file.with_extension("replacement");
let saved = b"<svg>two</svg>";
std::fs::write(&replacement, saved).unwrap();
std::fs::File::options().write(true).open(&replacement).unwrap().set_modified(modified).unwrap();
std::fs::rename(&replacement, &file).unwrap();
let replaced_stamp = Stamp::from_metadata(&std::fs::metadata(&file).unwrap()).unwrap();
assert_eq!(original_stamp.modified, replaced_stamp.modified);
assert_eq!(original_stamp.length, replaced_stamp.length);
assert!(original_stamp != replaced_stamp, "file identity must distinguish equal-time atomic saves");
expect_saved(&mut session, saved, Instant::now() + Duration::from_secs(3));
}
#[cfg(unix)]
#[test]
fn unix_saved_file_rejects_symlinks_and_fifos_without_blocking() {
let owned = OwnedSvg::create(b"<svg/>").unwrap();
let stamp = owned.stamp().unwrap();
let target = owned.root.join("private.svg");
std::fs::write(&target, b"private contents").unwrap();
std::fs::remove_file(&owned.file).unwrap();
std::os::unix::fs::symlink(&target, &owned.file).unwrap();
assert!(owned.stamp().is_err());
assert!(owned.read(&stamp).is_err());
assert!(owned.overwrite(b"<svg/>").is_err());
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!(owned.stamp().is_err());
let began = Instant::now();
assert!(owned.read(&stamp).is_err());
assert!(began.elapsed() < Duration::from_secs(1));
assert_eq!(std::fs::read(&target).unwrap(), b"private contents");
}
#[cfg(windows)]
#[test]
fn script_and_command_line_are_not_editor_executables() {
assert!(validate_executable(Path::new("cmd.exe /c start")).is_err());
assert!(validate_executable(Path::new("C:/editor.cmd")).is_err());
assert!(validate_executable(Path::new("editor.exe")).is_err());
}
#[cfg(windows)]
#[test]
fn replacement_symlink_is_rejected_without_reading_target() {
let owned = OwnedSvg::create(b"<svg/>").unwrap();
let target = owned.root.join("other.svg");
std::fs::write(&target, b"private target").unwrap();
std::fs::remove_file(&owned.file).unwrap();
// Symlink creation requires Developer Mode or elevation. Other tests run everywhere.
if std::os::windows::fs::symlink_file(&target, &owned.file).is_err() {
return;
}
assert!(owned.stamp().is_err());
let _ = std::fs::remove_file(&owned.file);
}
}
}
#[path = "vector_conversion.rs"]
mod vector_conversion;
#[cfg(target_os = "linux")]
#[path = "external_svg_portal.rs"]
mod portal;
+188
View File
@@ -0,0 +1,188 @@
//! 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};
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)
}
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();
}
}
+2
View File
@@ -54,6 +54,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;
+1
View File
@@ -569,6 +569,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();
+220
View File
@@ -0,0 +1,220 @@
//! Bounded, private VectorCraft conversion before an embedded AI/PDF/EPS editing session.
//! 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};
/// Prepare a bounded SVG working copy, converting native vectors inside private MCP roots.
pub(super) fn prepare(editor: &Path, source: &ExternalVectorSource, stop: &Receiver<()>) -> Result<(OwnedSvg, Vec<u8>), String> {
if !matches!(source.extension.as_str(), "svg" | "pdf" | "ai" | "eps") {
return Err("Unsupported embedded vector source type".into());
}
let owned = OwnedSvg::create(&source.bytes)?;
if source.extension == "svg" {
return Ok((owned, source.bytes.as_ref().clone()));
}
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 = conversion_commands(&input, &owned.file)?;
// 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()?)?;
let svg = source_svg(&converted, source.size)?;
// Validate bounded rendering before the interactive editor sees the working copy.
photocraft_io::svg::import_svg_bounded("Artwork.svg", &svg, 24_000_000).map_err(|e| format!("Converted SVG is unsupported: {e}"))?;
owned.overwrite(&svg)?;
Ok(svg)
})();
if result.is_err() {
let _ = child.kill();
let _ = child.wait();
}
result.map(|svg| (owned, svg))
}
/// Build requests only from UTF-8 paths: serializing a non-UTF-8 `Path` inside `json!` panics.
fn conversion_commands(input: &Path, output: &Path) -> 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": "svg"}))])
}
/// 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("Artwork.svg", bytes, 24_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());
}
let dimension = |key| root.attribute(key).and_then(|v| v.trim_end_matches("pt").parse::<f64>().ok()).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");
}
#[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);
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 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, svg) = prepare(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).is_err());
let root = owned.root.clone();
drop(owned);
assert!(!root.exists());
}
}
+3
View File
@@ -189,5 +189,8 @@ sync_github_name = true
[people.fjamesprice]
sync_github_name = true
[people.mockydevs]
real_name = "Don Omondi"
[people.ha1des]
sync_github_name = true
+130 -3
View File
@@ -286,6 +286,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!(
@@ -335,9 +337,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.
@@ -901,6 +907,127 @@ mod tests {
assert!(unclassified.is_empty(), "filesystem path parameters in the command registry need an automation policy: {unclassified:?}");
}
#[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", &params).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.
+1
View File
@@ -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 }
+1
View File
@@ -1481,6 +1481,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());
+1
View File
@@ -111,6 +111,7 @@ pub mod shell_commands;
pub mod shortcut_sets;
pub mod slice_cmds;
pub mod smart_cmds;
pub mod smart_svg_cmds;
pub mod smartselect_cmds;
pub mod snap;
pub mod solid_fill_cmds;
+58 -7
View File
@@ -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_layers(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}")))
}
@@ -204,15 +209,52 @@ 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.
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)
}
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.
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() {
@@ -382,12 +424,13 @@ fn apply_smart_filter(f: &SmartFilter, cur: &Surface, canvas: Rect, extent: Rect
}
/// The source placed in document pixels, before any smart filter.
fn place(name: &str, bytes: &[u8], fmt: PixelFormat, sm: &SmartObject) -> Result<Surface> {
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;
@@ -402,6 +445,8 @@ fn place(name: &str, bytes: &[u8], fmt: PixelFormat, sm: &SmartObject) -> Result
});
}
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)?,
};
@@ -443,12 +488,18 @@ fn render_in(doc: &Document, sm: &SmartObject, ctx: &JobCtx) -> Result<Option<Su
}
let Some((name, bytes)) = source_bytes(&doc.metadata, &sm.source) else { return Ok(None) };
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);
@@ -660,7 +711,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.
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
View File
@@ -2,6 +2,7 @@ use super::*;
mod selection;
mod stack_cache;
mod svg_profile;
const DEPTHS: [u64; 3] = [8, 16, 32];
const W: i32 = 64;
@@ -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,74 @@
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"));
}
#[test]
fn svg_smart_contents_and_stack_reject_excessive_editable_caches() {
let svg = format!("<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"256\" height=\"256\">{}</svg>", "<rect width=\"256\" height=\"256\"/>".repeat(400));
assert!(decode_source("Clipboard.svg", svg.as_bytes()).unwrap_err().to_string().contains("cache budget"));
assert!(stack_image("Clipboard.svg", svg.as_bytes(), PixelFormat::RGBA32F, photocraft_doc::StackMode::Mean).is_err());
}
#[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");
}
}
+26
View File
@@ -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).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,23 @@ pub(super) fn convert_to_layers(s: &mut Session, p: &Value) -> Result<Value> {
})?;
Ok(json!({"layer": new_id.0, "group": group, "discardedSmartFilters": discarded}))
}
/// Preflight transformed SVG shape caches before an operation refreshes their raster caches.
fn check_destination_shapes(layers: &[Layer], transform: photocraft_geom::Affine, size: photocraft_geom::Size) -> std::result::Result<(), String> {
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 > 24_000_000 {
return Err("The unpacked shape caches exceed the smart-object cache budget".into());
}
}
Ok(())
}
+296
View File
@@ -0,0 +1,296 @@
//! 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";
pub const MAX_VECTOR_SOURCE_BYTES: usize = 16 << 20;
const MAX_PIXELS: u64 = 24_000_000;
fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
EngineError::BadParams { cmd: cmd.into(), msg: msg.into() }
}
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),
}
}
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)> {
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)
}
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(())
}
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";
}
if extension != "svg" && sm.psd_raw.is_some() && source_size(sm).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":source_size(sm)});
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> {
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("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)?;
if target.contents_id != contents || !blake3::hash(original).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)?)?;
if !blake3::hash(bytes).to_hex().as_str().eq_ignore_ascii_case(expected) {
return Err(bad(UPDATE, "shared smart-object instances have conflicting contents"));
}
}
if original == svg.as_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(&name, svg.as_bytes(), MAX_PIXELS)
.map_err(|error| bad(UPDATE, format!("can't import edited SVG contents: {error}")))?;
let size = imported.document.size;
if size.area() > MAX_PIXELS {
return Err(bad(UPDATE, "SVG contents exceed the 24 million pixel limit"));
}
let source = SmartSource::Embedded {
file_name: if photocraft_io::svg::is_svg(original) { name } else { "Artwork.svg".into() },
bytes: 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();
replacements.push(replacement);
}
// Finish every fallible render before committing any document state.
for replacement in &mut replacements {
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("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(svg.as_bytes()).to_hex().to_string(),"updated":true}))
}
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,
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: true,
},
]
}
#[cfg(test)]
mod tests;
+360
View File
@@ -0,0 +1,360 @@
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 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());
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 }));
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);
let params =
json!({"document":exported["document"],"layer":id.0,"svg":BLUE,"fingerprint":exported["fingerprint"],"contentsId":exported["contentsId"]});
assert_eq!(s.execute(UPDATE, params.clone()).unwrap()["updated"], true);
for id in [id, duplicate] {
assert_eq!(source(&s, id), BLUE.as_bytes());
}
let after = s.active().unwrap().doc.clone();
assert!(s.undo());
assert_eq!(s.active().unwrap().doc, before);
assert!(s.redo());
assert_eq!(s.active().unwrap().doc, after);
fails_without_edit(&mut s, params); // stale first-save fingerprint
let 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);
}
+2
View File
@@ -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.20"
# 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]
+56
View File
@@ -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,29 @@ 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])> {
if uuid.is_empty() {
return None;
}
for (_, key, data) in &meta.psd_global_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() {
@@ -574,3 +607,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());
}
}
+509 -15
View File
@@ -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};
@@ -65,11 +65,41 @@ fn fonts() -> Arc<usvg::fontdb::Database> {
}
#[cfg(not(target_arch = "wasm32"))]
db.load_system_fonts();
configure_generic_fonts(&mut db);
Arc::new(db)
})
.clone()
}
/// fontdb's defaults name common Windows fonts. If those families aren't installed,
/// usvg's missing-family fallback resolves to nothing and drops otherwise valid SVG text.
/// Keep existing mappings; fill only missing generics with a family actually in this database.
fn configure_generic_fonts(db: &mut usvg::fontdb::Database) {
use usvg::fontdb::{Family, Query};
for (generic, candidates) in [
(Family::Serif, &["Noto Serif", "DejaVu Serif", "Liberation Serif", "Inter"][..]),
(Family::SansSerif, &["Noto Sans", "DejaVu Sans", "Liberation Sans", "Inter"][..]),
(Family::Monospace, &["Noto Sans Mono", "DejaVu Sans Mono", "Liberation Mono", "JetBrains Mono"][..]),
] {
if db.query(&Query { families: &[generic], ..Default::default() }).is_some() {
continue;
}
let family = candidates
.iter()
.find(|name| db.query(&Query { families: &[Family::Name(name)], ..Default::default() }).is_some())
.map(|name| (*name).to_string())
.or_else(|| db.faces().find_map(|face| face.families.first().map(|(name, _)| name.clone())));
if let Some(family) = family {
match generic {
Family::Serif => db.set_serif_family(family),
Family::SansSerif => db.set_sans_serif_family(family),
Family::Monospace => db.set_monospace_family(family),
_ => {}
}
}
}
}
/// Parse options for an untrusted drawing. `<image href>` resolves only `data:` URLs: a file path
/// or relative href is never read, so opening an SVG cannot pull local files (`/etc/...`,
/// `~/.ssh/...`) into the document. Embedded SVG images get no `<image>` resolution at all
@@ -84,6 +114,7 @@ fn options(fontdb: Arc<usvg::fontdb::Database>) -> usvg::Options<'static> {
}
/// 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
@@ -114,18 +145,260 @@ 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,
fontless_text: bool,
error: Option<String>,
}
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());
}
}
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
}
}
}
// 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,
}
}
}
// 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(())
}
fn bounded_data_image(mime: &str, data: Arc<Vec<u8>>, options: &usvg::Options, budget: &Arc<Mutex<ImageBudget>>, depth: 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;
}
match parse_image_bounded(&data, budget.clone(), depth + 1) {
Ok(tree) => {
let mut counter = budget.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
counter.svg_images = counter.svg_images.saturating_add(1);
Some(usvg::ImageKind::SVG(tree))
}
Err(e) => {
image_error(budget, format!("can't parse embedded SVG image: {e}"));
None
}
}
} else {
let supported = matches!(
format,
Some(photocraft_codecs::Format::Png | photocraft_codecs::Format::Jpeg | photocraft_codecs::Format::Gif | photocraft_codecs::Format::WebP)
);
if !supported {
image_error(budget, "embedded SVG image has unsupported or invalid raster data");
return None;
}
let dimensions = match imagesize::blob_size(&data) {
Ok(size) => (size.width, size.height),
Err(e) => {
image_error(budget, format!("can't read embedded SVG image dimensions: {e}"));
return None;
}
};
let mut dimensions = if format == Some(photocraft_codecs::Format::Gif) {
let Some(frame) = gif_frame_size(&data) else {
image_error(budget, "can't read embedded GIF frame dimensions");
return None;
};
(dimensions.0.max(frame.0), dimensions.1.max(frame.1))
} else {
dimensions
};
if format == Some(photocraft_codecs::Format::WebP) && data.get(12..).and_then(|chunks| webp_frame_sizes(chunks, &mut dimensions, false)).is_none() {
image_error(budget, "can't read embedded WebP frame dimensions");
return None;
}
if !charge_image(budget, dimensions.0, dimensions.1) {
return None;
}
let image = (usvg::ImageHrefResolver::default_data_resolver())(mime, data, options);
if image.is_none() {
image_error(budget, "embedded SVG raster image has an unsupported MIME type");
}
image
}
}
fn parse_image_bounded(bytes: &[u8], budget: Arc<Mutex<ImageBudget>>, depth: usize) -> Result<usvg::Tree, IoError> {
parse_image_bounded_with(bytes, budget, depth, fonts)
}
fn parse_image_bounded_with(
bytes: &[u8],
budget: Arc<Mutex<ImageBudget>>,
depth: usize,
fonts: fn() -> Arc<usvg::fontdb::Database>,
) -> Result<usvg::Tree, IoError> {
if u64::try_from(bytes.len()).unwrap_or(u64::MAX) > MAX_BOUNDED_SVG_BYTES {
return Err(IoError::Svg("embedded SVG source exceeds the 16 MiB byte limit".into()));
}
let xml = inflate_with_limit(bytes, MAX_BOUNDED_SVG_BYTES)?;
if nesting_depth(&xml, MAX_XML_DEPTH) > MAX_XML_DEPTH {
return Err(IoError::Svg(format!("elements are nested more than {MAX_XML_DEPTH} levels deep")));
}
{
let mut counter = budget.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
counter.xml_bytes = counter
.xml_bytes
.checked_sub(u64::try_from(xml.len()).unwrap_or(u64::MAX))
.ok_or_else(|| IoError::Svg("embedded SVG XML exceeds the decoded byte budget".into()))?;
}
let 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;
}
let mut opt = options(fontdb);
let shared = budget.clone();
opt.image_href_resolver.resolve_data = Box::new(move |mime, data, options| bounded_data_image(mime, data, options, &shared, depth));
let tree = usvg::Tree::from_data(&xml, &opt);
if let Some(error) = budget.lock().unwrap_or_else(std::sync::PoisonError::into_inner).error.clone() {
return Err(IoError::Svg(error));
}
tree.map_err(|e| IoError::Svg(e.to_string()))
}
fn parse_bounded(bytes: &[u8], max_pixels: u64) -> Result<Parsed, IoError> {
parse_bounded_inner(bytes, max_pixels).map(|(tree, _)| tree)
}
fn parse_bounded_inner(bytes: &[u8], max_pixels: u64) -> Result<(Parsed, u64), IoError> {
parse_bounded_inner_with(bytes, max_pixels, fonts)
}
fn parse_bounded_inner_with(bytes: &[u8], max_pixels: u64, fonts: fn() -> Arc<usvg::fontdb::Database>) -> Result<(Parsed, u64), IoError> {
let budget = Arc::new(Mutex::new(ImageBudget {
pixels: max_pixels.min(MAX_RASTER_PIXELS),
xml_bytes: MAX_BOUNDED_SVG_BYTES,
svg_images: 0,
fontless_text: false,
error: None,
}));
let tree = parse_image_bounded_with(bytes, budget.clone(), 0, fonts)?;
let counter = budget.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
// resvg renders each embedded SVG through a temporary pixmap as large as its destination.
if counter.svg_images != 0 {
let area = canvas(&tree, &mut Vec::new()).size.area();
if area.checked_mul(counter.svg_images.saturating_add(1)).is_none_or(|pixels| pixels > max_pixels.min(MAX_RASTER_PIXELS)) {
return Err(IoError::Svg("embedded SVG intermediate images exceed the SVG cache budget".into()));
}
}
Ok((Parsed { tree, fontless_text: counter.fontless_text }, counter.svg_images))
}
/// 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)
}
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::read::MultiGzDecoder::new(bytes).take(MAX_INFLATED_BYTES.saturating_add(1));
let mut reader = flate2::read::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))
}
@@ -212,28 +485,98 @@ 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)
}
/// 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)
}
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)
}
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)
}
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());
@@ -252,12 +595,33 @@ 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());
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, images) = parse_bounded_inner(bytes, max_decoded_pixels)?;
let tree = parsed.tree;
if images != 0 {
let cv = canvas(&tree, &mut Vec::new());
let k = cv.scale * scale.clamp(1.0 / 64.0, 64.0);
let width = ((tree.size().width() * k).ceil() as u32).max(1);
let height = ((tree.size().height() * k).ceil() as u32).max(1);
if (u64::from(width) * u64::from(height)).checked_mul(images.saturating_add(1)).is_none_or(|pixels| pixels > max_decoded_pixels.min(MAX_RASTER_PIXELS))
{
return Err(IoError::Svg("embedded SVG intermediate images exceed the rendered pixel budget".into()));
}
}
rasterize_tree(&tree, scale)
}
fn rasterize_tree(tree: &usvg::Tree, scale: f32) -> Result<photocraft_compose::Buffer, IoError> {
let cv = canvas(tree, &mut Vec::new());
let k = cv.scale * scale.clamp(1.0 / 64.0, 64.0);
let side = |v: f32| ((v * k).ceil() as u32).max(1);
let (w, h) = (side(tree.size().width()), side(tree.size().height()));
let mut pixmap = pixmap(w, h).ok_or_else(|| IoError::Svg(format!("{w} × {h} px is too large to rasterise")))?;
resvg::render(&tree, tiny_skia::Transform::from_scale(k, k), &mut pixmap.as_mut());
resvg::render(tree, tiny_skia::Transform::from_scale(k, k), &mut pixmap.as_mut());
let rect = Rect::new(0, 0, w as i32, h as i32);
let px = pixmap.pixels().iter().map(|p| rgba(p.demultiply())).collect();
Ok(photocraft_compose::Buffer { rect, px })
@@ -291,7 +655,7 @@ 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 {
fn count_nodes(root: &usvg::Group, include_text: bool) -> usize {
let mut n = 0;
let mut stack: Vec<&usvg::Group> = vec![root];
while let Some(g) = stack.pop() {
@@ -300,6 +664,9 @@ fn count_nodes(root: &usvg::Group) -> usize {
if let usvg::Node::Group(child) = node {
stack.push(child);
}
if include_text && let usvg::Node::Text(text) = node {
stack.push(text.flattened());
}
if n > MAX_NODES {
return n;
}
@@ -727,6 +1094,80 @@ fn knots(path: &tiny_skia::Path) -> Vec<Subpath> {
mod tests {
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
}
#[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"));
let malformed = image_svg(&embedded_image("image/png", b"\x89PNG\r\n\x1a\n"));
assert!(parse_bounded(malformed.as_bytes(), MAX_RASTER_PIXELS).unwrap_err().to_string().contains("dimensions"));
assert_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\"/>"));
@@ -769,14 +1210,20 @@ mod tests {
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);
}
@@ -889,4 +1336,51 @@ mod tests {
assert_eq!((cv.size.width, cv.size.height), (MAX_SIDE, MAX_SIDE / 2));
assert_eq!(w.len(), 1);
}
#[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"));
}
/// 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());
configure_generic_fonts(&mut db);
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() });
configure_generic_fonts(&mut db);
assert_eq!(db.query(&usvg::fontdb::Query { families: &[usvg::fontdb::Family::Serif], ..Default::default() }), before);
}
}
+5
View File
@@ -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).
+1
View File
@@ -3919,3 +3919,4 @@ navigator Zoom Zvětšení
# 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.
Can't render this file because it contains an unexpected character in line 6 and column 47.
+10
View File
@@ -3317,3 +3317,13 @@ 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.
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -4008,3 +4008,4 @@ 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. Η αποθήκευση αντικαθιστά την ενσωματωμένη πηγή· οι ιδιότητες του αρχικού τύπου αρχείου μπορεί να αλλάξουν.
Can't render this file because it contains an unexpected character in line 1030 and column 18.
+1
View File
@@ -4270,3 +4270,4 @@ 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.
Can't render this file because it contains an unexpected character in line 4 and column 47.
+1
View File
@@ -3319,3 +3319,4 @@ 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.
Can't render this file because it contains an unexpected character in line 6 and column 47.
+10
View File
@@ -4224,3 +4224,13 @@ 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.
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -3306,3 +3306,4 @@ 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.
Can't render this file because it contains an unexpected character in line 4 and column 47.
+1
View File
@@ -4425,3 +4425,4 @@ 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.
Can't render this file because it contains an unexpected character in line 2659 and column 53.
+1
View File
@@ -3986,3 +3986,4 @@ 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 として編集します。保存すると埋め込みソースが置き換わります。形式固有の機能が変わる場合があります。
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -3498,3 +3498,4 @@ 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로 편집합니다. 저장하면 포함된 원본이 교체되며 형식별 기능이 변경될 수 있습니다.
Can't render this file because it contains an unexpected character in line 3173 and column 53.
+1
View File
@@ -4068,3 +4068,4 @@ 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.
Can't render this file because it contains an unexpected character in line 8 and column 34.
+1
View File
@@ -3908,3 +3908,4 @@ 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ć.
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -4098,3 +4098,4 @@ 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.
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -3286,3 +3286,4 @@ 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. Сохранение заменит встроенный источник; особенности исходного формата могут измениться.
Can't render this file because it contains an unexpected character in line 6 and column 47.
+10
View File
@@ -4379,3 +4379,13 @@ 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. Чување замењује уграђени извор; својства специфична за формат могу се променити.
Can't render this file because it contains an unexpected character in line 6 and column 47.
+10
View File
@@ -4035,3 +4035,13 @@ 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.
Can't render this file because it contains an unexpected character in line 8 and column 34.
+1
View File
@@ -4017,3 +4017,4 @@ 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. Збереження замінить вбудоване джерело; властивості початкового формату можуть змінитися.
Can't render this file because it contains an unexpected character in line 1394 and column 18.
+10
View File
@@ -3798,3 +3798,13 @@ 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.
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -2943,3 +2943,4 @@ 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 格式编辑。保存会替换嵌入的源文件;格式特有的功能可能会发生变化。
Can't render this file because it contains an unexpected character in line 2607 and column 53.
+1
View File
@@ -2928,3 +2928,4 @@ 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 格式編輯。儲存會取代嵌入的來源檔案;格式特有的功能可能會改變。
Can't render this file because it contains an unexpected character in line 6 and column 47.
+46
View File
@@ -198,6 +198,11 @@ mod single_line_drag;
mod sizing;
pub mod slice_ui;
pub mod smart_filter_dialog;
#[cfg(not(target_arch = "wasm32"))]
mod smart_svg_ui;
#[cfg(target_arch = "wasm32")]
#[path = "smart_svg_ui/browser.rs"]
mod smart_svg_ui;
pub mod smart_ui;
pub mod snap_ui;
pub(crate) mod solid_fill_ui;
@@ -455,6 +460,32 @@ pub type ClipboardSetFn = Box<dyn FnMut(u32, u32, &[u8]) -> Result<(), String>>;
pub type ClipboardGetFn = Box<dyn FnMut() -> Result<Option<(u32, u32, Vec<u8>)>, String>>;
/// 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 {
VectorCraft,
Illustrator,
Program(String),
}
/// A native editing session supplies stable saved source snapshots without blocking the UI.
pub trait ExternalSvgSession {
fn poll(&mut self) -> Option<Result<Vec<u8>, String>>;
/// No more snapshots can arrive, for example after the desktop chooser was cancelled.
fn finished(&self) -> bool {
false
}
}
/// An immutable embedded source. The shell converts non-SVG inputs in its owned working directory.
#[derive(Clone)]
pub struct ExternalVectorSource {
pub bytes: std::sync::Arc<Vec<u8>>,
pub extension: String,
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>)>>>;
/// Read the saved preferences text (`None` when there is none yet).
@@ -511,6 +542,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, …).
@@ -855,6 +888,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.
@@ -1047,6 +1081,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(),
@@ -1238,6 +1273,9 @@ impl PhotocraftApp {
if let Some(result) = panel_docking::command(self, id, &params) {
return result;
}
if let Some(result) = smart_svg_ui::intercept(self, id, &params) {
return result;
}
if let Some(r) = transform_tool::intercept(self, id) {
return r;
}
@@ -1602,6 +1640,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("Answer the TIFF, Targa or DDS Options before starting another save".into());
}
@@ -1662,6 +1703,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, and a DDS texture for its compression, 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 once answered.
@@ -2276,6 +2320,8 @@ impl eframe::App for PhotocraftApp {
tiff_options_ui::show(self, &ctx);
targa_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);
+6 -3
View File
@@ -7,10 +7,10 @@ use crate::state::DialogKind;
/// 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 blocks menu commands entirely, and a pending
/// crop blocks the commands Photoshop greys during one (`crop_ui::blocks`).
/// commands also usable through shortcuts. Camera Raw and the SVG editor choice block
/// menu commands entirely. 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::crop_ui::blocks(app, id) {
if app.camera_raw.is_some() || app.smart_svg.modal_open() || 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)
@@ -86,6 +86,9 @@ pub fn invoke(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: Va
/// [`invoke`] without the unsaved-changes prompt, for once the user has already answered it.
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, &params) {
return result;
}
if let Some(tool) = crate::tool_shortcuts::tool(id) {
crate::tool_memory::select(app, tool);
return Ok(json!({"tool": tool}));
+10 -2
View File
@@ -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 {
+2 -1
View File
@@ -472,7 +472,7 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) {
// 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()) && 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);
}
@@ -701,6 +701,7 @@ pub fn handle(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::new("tool-flyout")).is_some());
+437
View File
@@ -0,0 +1,437 @@
//! 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<Vec<u8>>,
}
#[derive(Default)]
pub(crate) struct State {
pending: Option<Pending>,
editing: Vec<Editing>,
internal_once: bool,
}
impl State {
pub(crate) fn modal_open(&self) -> bool {
self.pending.is_some()
}
}
fn tr(source: &str) -> &str {
crate::i18n::tr_ctx(crate::i18n::current(), "smartSvg", source)
}
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;
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)
}
fn same_target(app: &PhotocraftApp, target: &Target) -> bool {
app.session.active().is_some_and(|st| st.doc.id == target.doc)
}
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
})
}
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();
app.smart_svg.editing.push(Editing { target, session, saved: None });
app.ui.status = tr("SVG editor opened. Save the SVG to update this Smart Object.").into();
app.ui.status_error = false;
Ok(json!({"externalEditor": true}))
}
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}))
},
)
}
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::new("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("Edit SVG 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("Choose the application used to edit this SVG:"));
if pending.target.extension != "svg" {
ui.label(tr("AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded source; format-specific features may change."));
}
for (choice, label) in [
(Editor::VectorCraft, "VectorCraft"),
(Editor::Illustrator, "Adobe Illustrator"),
(Editor::Program, "Other application"),
(Editor::Internal, "PhotoCraft (internal editor)"),
] {
if choice == Editor::Illustrator && !cfg!(windows) {
continue;
}
let available = pending.target.extension == "svg" || matches!(choice, Editor::VectorCraft | Editor::Program);
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("Save to the opened SVG 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);
}
}
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()
&& 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() {
match result {
Ok(bytes) if bytes.len() <= MAX_SVG_BYTES => edit.saved = Some(bytes),
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 svg = match String::from_utf8(bytes) {
Ok(svg) => svg,
Err(_) => {
report(app, "The saved SVG is not UTF-8; save a valid SVG to retry".into());
app.smart_svg.editing.push(edit);
continue;
}
};
let params = json!({"document": edit.target.doc.0, "layer": edit.target.layer.0, "svg": svg, "fingerprint": edit.target.fingerprint, "contentsId": edit.target.contents_id});
match app.run("layer.smartObjects.updateSvg", 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");
report(app, if stale { format!("SVG syncing stopped: {error}") } else { format!("SVG update failed; save again 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,22 @@
//! Browser builds retain internal Edit Contents without desktop editor sessions.
use serde_json::Value;
use crate::PhotocraftApp;
#[derive(Default)]
pub(crate) struct State {}
impl State {
pub(crate) fn modal_open(&self) -> bool {
false
}
}
pub(crate) fn intercept(_app: &mut PhotocraftApp, _id: &str, _params: &Value) -> Option<Result<Value, String>> {
None
}
pub(crate) fn poll(_app: &mut PhotocraftApp, _ctx: &egui::Context) {}
pub(crate) fn show(_app: &mut PhotocraftApp, _ctx: &egui::Context) {}
+715
View File
@@ -0,0 +1,715 @@
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()
}
}
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);
}
#[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(TestSession(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(SVG.as_bytes().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=="Artwork.svg" && bytes.as_slice()==SVG.as_bytes()))
);
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();
}
#[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());
}
+90
View File
@@ -0,0 +1,90 @@
# 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 SVG working copy;
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
The chooser warns that the first accepted save replaces native AI/PDF/EPS bytes with SVG.
Format-specific effects and missing fonts can change appearance. This is not lossless
native AI/EPS editing. Test on a PSD copy. Original embedded bytes stay intact until a
valid changed save, and Undo restores them. No original file on disk is edited.
AI/PDF/EPS conversion requires vectorcraft-cli beside the selected VectorCraft executable.
It runs headless in the background, restricted to a private temporary read/write root.
Both open and export must succeed. Conversion has cancellation and a 60-second timeout.
The PSD source-pixel frame is retained when PDF points differ from stored dimensions.
Unsupported 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
SVG. 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 conversion still needs vectorcraft-cli alongside the discovered
executable. The tested local PhotoCraft package has read-only access to that deployment;
this source change adds no broad filesystem or host-process permissions. SVG editing
through the portal does not require access to the VectorCraft deployment. Explicit
Other application executables retain their direct-launch behavior. 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 conversion, 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.
Source input/output is limited to 16 MiB. Bounded SVG parsing limits decoded embedded
images, nesting and aggregate render/cache pixels to 24 million. 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 and layer.smartObjects.updateSvg validate
parent document, layer, shared-content identity and source fingerprint. They never
follow a linked disk pathname or launch processes. 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.
## 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,
screenshots and the acceptance harness are excluded from this repository.
![Embedded-vector editor chooser](images/embedded-vector-editor-en.png)
Linux Flatpak GUI acceptance also 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.
![Regular and PhotoCraft vector documents in one VectorCraft window](images/embedded-vector-tabs-linux.png)
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.
+6 -1
View File
@@ -1,6 +1,6 @@
# File-format parity
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-15 · **Change:** minor (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: 45 filters (31 more: 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: 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; earlier: CMYK Invert, Posterize and Threshold on the inks; 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) · **Target:** Adobe Photoshop 2026
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-22 · **Change:** minor (bounded embedded-vector SVG edit/save workflow; 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: 45 filters (31 more: 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: 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; earlier: CMYK Invert, Posterize and Threshold on the inks; 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) · **Target:** Adobe Photoshop 2026
Every format Photoshop 2026 (27.11.0) reads or writes, with PhotoCraft's support, fidelity and
tests. Photoshop's list comes from its `Info.plist` `CFBundleDocumentTypes`, the PiPL read/write
@@ -138,10 +138,15 @@ 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 and bounded SVG save-back, including nested PSB contents. Placement, shared instances, masks and Undo/Redo use existing smart-object transactions. Native vector bytes are replaced with SVG only after a valid changed save; this is not standalone EPS/DCS import or lossless native format preservation. 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-22 | minor | Embedded PSD vectors gain explicit bounded SVG editor/save-back; standalone EPS/DCS and native-format fidelity remain open. |
| 2026-10-15 | minor | PSD smart filters: Clouds, Difference Clouds, Fibers and Lens Flare open as editable filters with their seeds, colours and lens, and save like Photoshop (checked against a Photoshop 27 file); render filters on a smart object work within the object, so a flare sits where Photoshop puts it |
| 2026-10-15 | minor | DDS textures save (#1327): uncompressed or BC1/BC2/BC3 (DXT1/3/5) from a clean-room block encoder, optional box-filtered mip chain, legacy header; DDS Options in Save As and Save a Copy, `ddsCompression`/`ddsMipmaps` for scripts, the CLI and MCP |
| 2026-10-16 | minor | PSD smart filters: Perspective Warp (`perspectiveWarpTransform`: `vertices`, `warpedVertices`, `quads` of vertex indices, document pixels) opens as `layer.smartObjects.perspectiveWarp`, one plane per quad, and re-renders warped; save writes the original item with only the geometry rewritten, or a new one in Photoshop's layout; inconsistent descriptors stay verbatim (FILE-215-2, #6268) |
+3 -1
View File
@@ -1,6 +1,6 @@
# Where PhotoCraft falls short of Photoshop
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (G9: Preferences › File Handling › Save in Background takes effect, #6207, 24 of 169 preferences do nothing; G7: Content-Aware Move previews every selection the command takes, coarse then exact, TOOL-213-4 done, #6680; G4: Pattern Stamp and Background Eraser draw live, TOOL-213-7; G9: Animated Zoom pref read: holding the Zoom tool zooms continuously, #6210, prefs that do nothing 25 of 166; G2: a cancelled 24 MP Gaussian Blur job stops in at most 44 ms, P25 under its 200 ms budget in local runs, baseline not re-run, #6205; G1: generated large-file fixtures, a 4.38 GB PSB opens on Windows through io, FILE-216-2 done, #5107; G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (bounded embedded-vector SVG edit/save workflow; G9: Preferences › File Handling › Save in Background takes effect, #6207, 24 of 169 preferences do nothing; G7: Content-Aware Move previews every selection the command takes, coarse then exact, TOOL-213-4 done, #6680; G4: Pattern Stamp and Background Eraser draw live, TOOL-213-7; G9: Animated Zoom pref read: holding the Zoom tool zooms continuously, #6210, prefs that do nothing 25 of 166; G2: a cancelled 24 MP Gaussian Blur job stops in at most 44 ms, P25 under its 200 ms budget in local runs, baseline not re-run, #6205; G1: generated large-file fixtures, a 4.38 GB PSB opens on Windows through io, FILE-216-2 done, #5107; G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026
Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact
on the way to beta. This is the work list agents pick from: take the highest-ranked entry that
@@ -242,6 +242,7 @@ what an agent can't finish alone.
## File-format gaps
### G10. Missing and partial file formats
- **Progress (2026-10-22):** Embedded SVG/PDF-compatible AI/PDF/EPS smart objects can be edited through a bounded VectorCraft SVG working copy, including nested PSD payloads. This does not add standalone EPS/DCS import or lossless native-vector editing. See [workflow and limits](embedded-vector-editing.md); estimates and parity percentages are unchanged.
- **Missing:** JPEG Options dialog (FILE-215-19), Photoshop PDF read and its vector/text/multi-page
write (FILE-215-10, #2541; a single flattened page now saves via Save As / Save a Copy / CLI,
2026-10-11, #3401), AVIF read (#463) and default-build AVIF write, JPEG XL, JPEG 2000 (#1047),
@@ -290,6 +291,7 @@ what an agent can't finish alone.
| Date | Change | Summary |
|---|---|---|
| 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-15 | minor | G1 / #5972 A1: render only provably constant-RGB `GdFl` noise fills; variable noise synthesis remains open |
| 2026-10-18 | minor | G9: with no selection, Copy takes fill and adjustment layers whole and Paste recreates them (#2344); UI-217-23 stays partial (a single pixel layer copies its pixels only); estimate unchanged |
| 2026-10-15 | minor | G2: a cancelled 24 MP Gaussian Blur job stops within 44 ms max (P25, budget 200 ms; was about 400 ms; local runs, baseline not re-run), the uncancelled job on a locked-transparency layer about 40% faster; #6205 |
Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB