mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-14 19:26:51 -04:00
* 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 <[email protected]> --------- Co-authored-by: Brandon Thomas <[email protected]> Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
10 files changed
+118
-9
No files matched your search
@@ -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 || {
|
||||
|
||||
@@ -156,6 +156,7 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
|
||||
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(|| {
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
+17
-1
@@ -98,6 +98,19 @@ pub struct ExportResult {
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// 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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -219,7 +219,7 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions) -> Result<Exp
|
||||
img.icc = doc.icc_profile.as_ref().map(|i| i.to_vec());
|
||||
img.meta = codecs::Metadata {
|
||||
exif: doc.metadata.exif.as_ref().map(|e| e.to_vec()),
|
||||
xmp: doc.metadata.xmp.clone(),
|
||||
xmp: doc.metadata.xmp.clone().filter(|_| opts.xmp == crate::XmpEmbed::All),
|
||||
dpi: Some((doc.resolution_dpi, doc.resolution_dpi)),
|
||||
photoshop_resources: Some(resources),
|
||||
photoshop_layers: Some(layers),
|
||||
|
||||
@@ -120,10 +120,15 @@ fn metadata_roundtrip_png() {
|
||||
d.resolution_dpi = 300.0;
|
||||
d.icc_profile = Some(std::sync::Arc::new(sample_icc()));
|
||||
d.metadata.xmp = Some("<x:xmpmeta xmlns:x='adobe:ns:meta/'/>".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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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#"<rdf:Description tiff:Orientation="6"/>"#.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");
|
||||
|
||||
|
||||
@@ -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 = "<x:xmpmeta xmlns:x=\"adobe:ns:meta/\"><rdf:RDF>\
|
||||
<photoshop:DocumentAncestors><rdf:Bag><rdf:li>xmp.did:11111111-2222-3333-4444-555555555555</rdf:li></rdf:Bag></photoshop:DocumentAncestors>\
|
||||
<photoshop:TextLayers><rdf:Bag><rdf:li>Secret campaign copy</rdf:li></rdf:Bag></photoshop:TextLayers>\
|
||||
</rdf:RDF></x:xmpmeta>";
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ pub fn open(app: &mut PhotocraftApp) -> Result<u64, String> {
|
||||
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, Value>) -> String {
|
||||
}
|
||||
|
||||
fn settings(f: &Map<String, Value>) -> 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<String, Valu
|
||||
crate::widgets::checkbox(ui, &mut tr, tl!("Transparency"));
|
||||
f.insert("transparency".into(), json!(tr));
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new(tl!("Metadata")).color(t.text_dim));
|
||||
let mut m = s(f, "metadata");
|
||||
let opts: Vec<(String, &str)> = 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<String, Valu
|
||||
ui.vertical(|ui| {
|
||||
let layer = f.get("__layer").and_then(Value::as_u64);
|
||||
let key = egui::Id::new(("export-preview", doc.id.0, app.session.active().map_or(0, |s| s.revision), layer));
|
||||
let sig =
|
||||
format!("{}{}{}{}", s_fmt(f), n(f, "quality", 85.0), f.get("transparency").map(|v| v.to_string()).unwrap_or_default(), n(f, "scale", 100.0));
|
||||
let sig = format!(
|
||||
"{}{}{}{}{}",
|
||||
s_fmt(f),
|
||||
n(f, "quality", 85.0),
|
||||
f.get("transparency").map(|v| v.to_string()).unwrap_or_default(),
|
||||
n(f, "scale", 100.0),
|
||||
s(f, "metadata")
|
||||
);
|
||||
let cached: Option<(String, Option<u64>, Arc<egui::TextureHandle>)> = ui.data(|d| d.get_temp(key));
|
||||
let (size, tex) = match cached.filter(|c| c.0 == sig) {
|
||||
Some((_, size, tex)) => (size, tex),
|
||||
|
||||
@@ -151,11 +151,15 @@ pub struct ExportSettings {
|
||||
pub jpeg_quality: Option<u8>,
|
||||
/// 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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user