From 6ee3e4f752e298255bc4dd090fdb3e226c9755ef Mon Sep 17 00:00:00 2001 From: Elvis33LE <67311580+Elvis33LE@users.noreply.github.com> Date: Wed, 7 Oct 2026 23:19:17 +0200 Subject: [PATCH] Flat exports embed no document XMP by default; Export As gains a Metadata choice (#647) (#733) * Flat exports embed no document XMP by default; Export As gains a Metadata choice (#647) The document's XMP packet lists the text of every type layer (photoshop:TextLayers) and one id per placed document (photoshop:DocumentAncestors) - data a flat web image should not carry: 14-116 KB per export on real designer PSDs, and layer text leaking into images that show only part of the design. - io: ExportOptions.xmp (XmpEmbed::All | None, default None) drops the packet from flat exports before the codecs see it. Layered saves (PSD, PSB, .pcraft) always keep everything; DPI, ICC and EXIF are unaffected. Export As, Save a Copy, the CLI, Layers/Artboards to Files and Batch all share this path and get the safe default. - The Export As dialog gains a Metadata dropdown (None / All, default None, like the source application's Export As); the file-size estimate follows the choice. - Tests: new xmp_export.rs covers the default (png/jpg/webp/tif carry no packet), the All opt-in and the layered always-keep; metadata_roundtrip_png and the stale-orientation test document the new contract (with All, an embedded stale orientation is still rewritten upright). * Export As alone starts at Metadata: None; Save As and conversions keep the XMP ExportSettings::default() and ExportOptions::default() also serve File > Save As, file.saveAs, the CLI and automation, which keep the document's metadata (as Photoshop's Save As does). Only the Export As dialog defaults to None. A layered TIFF honours the choice too. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- apps/photocraft-web/src/web.rs | 1 + apps/photocraft/src/services.rs | 1 + crates/io/src/flat.rs | 7 +++- crates/io/src/lib.rs | 18 +++++++++- crates/io/src/tiff_layers.rs | 2 +- crates/io/tests/flat.rs | 7 +++- crates/io/tests/orientation.rs | 8 ++++- crates/io/tests/xmp_export.rs | 56 +++++++++++++++++++++++++++++ crates/ui-egui/src/export_dialog.rs | 21 +++++++++-- crates/ui-egui/src/lib.rs | 6 +++- 10 files changed, 118 insertions(+), 9 deletions(-) create mode 100644 crates/io/tests/xmp_export.rs diff --git a/apps/photocraft-web/src/web.rs b/apps/photocraft-web/src/web.rs index 61b5d42b..3fb95b6e 100644 --- a/apps/photocraft-web/src/web.rs +++ b/apps/photocraft-web/src/web.rs @@ -145,6 +145,7 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services { opts.encode.jpeg_quality = q; } opts.tiff_layers = settings.tiff_layers; + opts.xmp = if settings.xmp_all { photocraft_io::XmpEmbed::All } else { photocraft_io::XmpEmbed::None }; photocraft_io::export(doc, path, &opts).map(|r| (r.bytes, r.warnings)).map_err(|e| e.to_string()) })), pick_open: Some(Box::new(move || { diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index 9a18cc1b..7644b5bd 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -156,6 +156,7 @@ pub fn native(automation: Option) -> opts.encode.jpeg_quality = q; } opts.tiff_layers = settings.tiff_layers; + opts.xmp = if settings.xmp_all { photocraft_io::XmpEmbed::All } else { photocraft_io::XmpEmbed::None }; crate::crash_guard::guard("Export", || photocraft_io::export(doc, path, &opts).map(|r| (r.bytes, r.warnings)).map_err(|e| e.to_string())) })), pick_open: Some(Box::new(|| { diff --git a/crates/io/src/flat.rs b/crates/io/src/flat.rs index 6dcaf730..16c45dbc 100644 --- a/crates/io/src/flat.rs +++ b/crates/io/src/flat.rs @@ -8,7 +8,7 @@ use photocraft_doc::{Document, Layer, LayerContent}; use photocraft_geom::{Rect, Size, TILE_SIZE}; use photocraft_raster::Surface; -use crate::{ExportOptions, ExportResult, ImportResult, IoError}; +use crate::{ExportOptions, ExportResult, ImportResult, IoError, XmpEmbed}; /// Bytes per band when converting or exporting a band of rows at a time. const BAND_BYTES: usize = 32 << 20; @@ -277,6 +277,11 @@ pub fn export_flat(doc: &Document, format: Format, opts: &ExportOptions) -> Resu } let mut warnings = Vec::new(); let mut img = document_to_image(doc, &mut warnings)?; + if opts.xmp == XmpEmbed::None { + // Export As's Metadata: None: the packet lists the text of every type layer and one id + // per placed document (#647). + img.meta.xmp = None; + } if img.layout().has_alpha() && !format.caps().alpha { // Flattened over white, as saving a transparent document without transparency does. img = matte_over_white(&img)?; diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 1f72557d..721e5b3d 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -98,6 +98,19 @@ pub struct ExportResult { pub warnings: Vec, } +/// Which part of the document's XMP packet a flat export embeds. Layered saves (PSD, PSB, +/// `.pcraft`) always keep everything. Save As and conversions keep the whole packet, as +/// Photoshop's Save As does; Export As starts at `None`, because the packet lists the text of +/// every type layer and one id per placed document (#647). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum XmpEmbed { + /// Embed the document's whole XMP packet. + #[default] + All, + /// Embed no XMP. + None, +} + /// Export options. #[derive(Debug, Clone)] pub struct ExportOptions { @@ -108,11 +121,14 @@ pub struct ExportOptions { /// TIFF: keep the layers (Photoshop layer data in tag 37724). `false` is Photoshop's /// "Discard Layers and Save a Copy": a flat TIFF. pub tiff_layers: bool, + /// Which part of the document's XMP packet a flat export embeds (PSD/PSB/`.pcraft` + /// always keep everything). Everything by default, as Save As does; Export As offers None. + pub xmp: XmpEmbed, } impl Default for ExportOptions { fn default() -> Self { - ExportOptions { encode: EncodeOptions::default(), force_psb: false, tiff_layers: true } + ExportOptions { encode: EncodeOptions::default(), force_psb: false, tiff_layers: true, xmp: XmpEmbed::All } } } diff --git a/crates/io/src/tiff_layers.rs b/crates/io/src/tiff_layers.rs index 98582de3..6ad712c1 100644 --- a/crates/io/src/tiff_layers.rs +++ b/crates/io/src/tiff_layers.rs @@ -219,7 +219,7 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions) -> Result".into()); - let r = export(&d, "png", &ExportOptions::default()).unwrap(); + // DPI and ICC always travel with the file; XMP unless Export As asks for none (#647). + let none = ExportOptions { xmp: photocraft_io::XmpEmbed::None, ..ExportOptions::default() }; + let r = export(&d, "png", &none).unwrap(); let back = import("x.png", &r.bytes).unwrap().document; assert!((back.resolution_dpi - 300.0).abs() < 1.0); assert_eq!(back.icc_profile, d.icc_profile); + assert_eq!(back.metadata.xmp, None); + let r = export(&d, "png", &ExportOptions::default()).unwrap(); + let back = import("x.png", &r.bytes).unwrap().document; assert_eq!(back.metadata.xmp, d.metadata.xmp); } diff --git a/crates/io/tests/orientation.rs b/crates/io/tests/orientation.rs index 0832df23..308041ae 100644 --- a/crates/io/tests/orientation.rs +++ b/crates/io/tests/orientation.rs @@ -96,9 +96,15 @@ fn stale_orientation_in_document_metadata_is_never_exported() { let mut d = import("IMG_0001.JPG", &iphone_jpeg()).unwrap().document; d.metadata.exif = Some(std::sync::Arc::new(exif(6))); d.metadata.xmp = Some(r#""#.into()); - let jpg = export(&d, "jpg", &ExportOptions::default()).unwrap().bytes; + let none = ExportOptions { xmp: photocraft_io::XmpEmbed::None, ..ExportOptions::default() }; + let jpg = export(&d, "jpg", &none).unwrap().bytes; let back = decode(&jpg).unwrap(); assert_eq!(exif_orientation(back.meta.exif.as_deref().unwrap()), 1); + // Export As's Metadata: None carries no XMP at all (#647), so nothing stale can hide in it; + // when the packet is embedded (the default), the orientation inside is rewritten upright. + assert!(back.meta.xmp.is_none()); + let jpg = export(&d, "jpg", &ExportOptions::default()).unwrap().bytes; + let back = decode(&jpg).unwrap(); assert!(back.meta.xmp.as_deref().unwrap().contains(r#"tiff:Orientation="1""#)); assert_upright(&back, "jpg"); diff --git a/crates/io/tests/xmp_export.rs b/crates/io/tests/xmp_export.rs new file mode 100644 index 00000000..8d22017f --- /dev/null +++ b/crates/io/tests/xmp_export.rs @@ -0,0 +1,56 @@ +//! #647: Export As's Metadata: None (`XmpEmbed::None`) embeds none of the document's XMP (the +//! packet lists the text of every type layer and one id per placed document); the default, +//! `XmpEmbed::All`, keeps it as Save As does, and layered saves (PSD, `.pcraft`) always keep it. + +use photocraft_color::{ColorMode, SampleType}; +use photocraft_doc::{Document, Layer, LayerContent, Size}; +use photocraft_io::{ExportOptions, XmpEmbed, export}; +use photocraft_raster::Surface; + +const XMP: &str = "\ +xmp.did:11111111-2222-3333-4444-555555555555\ +Secret campaign copy\ +"; + +fn doc_with_xmp() -> Document { + let mut d = Document::new("x", Size::new(8, 6), ColorMode::Rgb, SampleType::U8); + let mut s = Surface::new(d.pixel_format()); + s.fill_rect(photocraft_geom::Rect::new(0, 0, 8, 6), &[0.5, 0.4, 0.3, 1.0]); + d.layers.push(Layer::new("flat", LayerContent::Raster(s))); + d.metadata.xmp = Some(XMP.to_string()); + d +} + +fn leaks_xmp(bytes: &[u8]) -> bool { + let s = String::from_utf8_lossy(bytes); + s.contains("x:xmpmeta") || s.contains("DocumentAncestors") || s.contains("Secret campaign copy") +} + +#[test] +fn metadata_none_embeds_no_xmp() { + let opts = ExportOptions { xmp: XmpEmbed::None, ..ExportOptions::default() }; + for ext in ["png", "jpg", "webp", "tif"] { + let r = export(&doc_with_xmp(), &format!("out.{ext}"), &opts).expect(ext); + assert!(!leaks_xmp(&r.bytes), "{ext}: the document XMP leaked into the export"); + } +} + +#[test] +fn all_embeds_the_whole_packet_and_is_the_default() { + assert_eq!(ExportOptions::default().xmp, XmpEmbed::All); + let opts = ExportOptions { xmp: XmpEmbed::All, ..ExportOptions::default() }; + for ext in ["png", "jpg", "webp"] { + let r = export(&doc_with_xmp(), &format!("out.{ext}"), &opts).expect(ext); + assert!(leaks_xmp(&r.bytes), "{ext}: XmpEmbed::All must embed the packet"); + } +} + +#[test] +fn layered_saves_keep_the_xmp_whatever_the_option() { + for opts in [ExportOptions::default(), ExportOptions { xmp: XmpEmbed::None, ..ExportOptions::default() }] { + let psd = export(&doc_with_xmp(), "out.psd", &opts).expect("psd"); + assert!(leaks_xmp(&psd.bytes), "PSD always keeps the document XMP"); + let pcraft = export(&doc_with_xmp(), "out.pcraft", &opts).expect("pcraft"); + assert!(leaks_xmp(&pcraft.bytes), ".pcraft always keeps the document XMP"); + } +} diff --git a/crates/ui-egui/src/export_dialog.rs b/crates/ui-egui/src/export_dialog.rs index 046c9faa..6710565d 100644 --- a/crates/ui-egui/src/export_dialog.rs +++ b/crates/ui-egui/src/export_dialog.rs @@ -22,6 +22,7 @@ pub fn open(app: &mut PhotocraftApp) -> Result { f.insert("quality".into(), json!(85)); f.insert("transparency".into(), json!(true)); f.insert("scale".into(), json!(100)); + f.insert("metadata".into(), json!("none")); f.insert("__w".into(), json!(st.doc.size.width)); f.insert("__h".into(), json!(st.doc.size.height)); Ok(app.ui.open_dialog(DialogKind::Command, f)) @@ -96,7 +97,7 @@ fn s_fmt(f: &Map) -> String { } fn settings(f: &Map) -> ExportSettings { - ExportSettings { jpeg_quality: (s_fmt(f) == "jpg").then(|| n(f, "quality", 85.0).clamp(1.0, 100.0) as u8), ..Default::default() } + ExportSettings { jpeg_quality: (s_fmt(f) == "jpg").then(|| n(f, "quality", 85.0).clamp(1.0, 100.0) as u8), xmp_all: s(f, "metadata") == "all", ..Default::default() } } /// Estimated size (bytes) from a ≤512 px proxy encode, scaled by pixel count. @@ -136,6 +137,14 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map = vec![("none".into(), tl!("None")), ("all".into(), tl!("All"))]; + if crate::widgets::dropdown(ui, "export-metadata", &mut m, &opts, 130.0) { + f.insert("metadata".into(), json!(m)); + } + }); ui.add_space(8.0); ui.label(egui::RichText::new(tl!("Image Size")).font(crate::theme::semibold(12.0)).color(t.text)); let mut sc = n(f, "scale", 100.0) as f32; @@ -153,8 +162,14 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map, Arc)> = ui.data(|d| d.get_temp(key)); let (size, tex) = match cached.filter(|c| c.0 == sig) { Some((_, size, tex)) => (size, tex), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index b737c677..385937ce 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -151,11 +151,15 @@ pub struct ExportSettings { pub jpeg_quality: Option, /// TIFF: keep the layers (Photoshop layer data); `false` is "Discard Layers and Save a Copy". pub tiff_layers: bool, + /// Embed the document's whole XMP packet. `true` by default (Save As keeps the metadata); + /// Export As starts at Metadata: None, since the packet can carry the text of every type + /// layer and one id per placed document (#647). + pub xmp_all: bool, } impl Default for ExportSettings { fn default() -> Self { - ExportSettings { jpeg_quality: None, tiff_layers: true } + ExportSettings { jpeg_quality: None, tiff_layers: true, xmp_all: true } } }