mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-24 09:57:08 -06:00
Integration: stack #5815 (its merge onto main, conflicts resolved)
This commit is contained in:
@@ -281,10 +281,11 @@ pub enum Adjustment {
|
||||
relative: bool,
|
||||
adjustments: [[f32; 4]; 9],
|
||||
},
|
||||
/// A PSD adjustment we can't evaluate yet; preserved raw for round-trip.
|
||||
/// A PSD adjustment we can't evaluate yet; preserved raw for round-trip, shared between
|
||||
/// document snapshots (#4535).
|
||||
Unsupported {
|
||||
psd_key: String,
|
||||
raw: Vec<u8>,
|
||||
raw: std::sync::Arc<[u8]>,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -846,7 +846,10 @@ pub type PsdGlobalBlock = ([u8; 4], [u8; 4], Arc<Vec<u8>>);
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Metadata {
|
||||
pub xmp: Option<String>,
|
||||
/// The XMP packet as stored, kept as text so properties PhotoCraft doesn't model survive.
|
||||
/// Shared like the other payloads here: cloning a document or a history state never copies
|
||||
/// it (#4535).
|
||||
pub xmp: Option<Arc<str>>,
|
||||
pub exif: Option<Arc<Vec<u8>>>,
|
||||
/// Free-form image text (PNG tEXt/zTXt/iTXt, TIFF ASCII tags), including duplicate keys.
|
||||
pub text: Vec<(String, String)>,
|
||||
|
||||
@@ -57,8 +57,9 @@ impl DuotoneInk {
|
||||
pub struct Duotone {
|
||||
pub inks: Vec<DuotoneInk>,
|
||||
/// The PSD colour-mode data this came from, written back unchanged while the inks are unedited.
|
||||
/// Shared between document snapshots (#4535).
|
||||
#[serde(default)]
|
||||
pub psd_raw: Option<Vec<u8>>,
|
||||
pub psd_raw: Option<std::sync::Arc<[u8]>>,
|
||||
}
|
||||
|
||||
impl DuotoneInk {
|
||||
|
||||
@@ -1114,7 +1114,7 @@ fn file_info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let xmp = s.active().ok_or(EngineError::NoDocument)?.doc.metadata.xmp.clone();
|
||||
let new = write_file_info(xmp.as_deref(), p);
|
||||
s.edit("File Info", |doc, _| {
|
||||
doc.metadata.xmp = Some(new);
|
||||
doc.metadata.xmp = Some(new.into());
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
|
||||
@@ -771,7 +771,7 @@ fn file_info_translations_survive_undo_and_psd_round_trip() {
|
||||
for depth in [8, 16, 32] {
|
||||
let mut s = session(8, 8, depth);
|
||||
s.edit("Set metadata", |doc, _| {
|
||||
doc.metadata.xmp = Some(MULTILINGUAL_XMP.to_string());
|
||||
doc.metadata.xmp = Some(MULTILINGUAL_XMP.into());
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
@@ -781,7 +781,7 @@ fn file_info_translations_survive_undo_and_psd_round_trip() {
|
||||
assert!(s.undo());
|
||||
assert_eq!(doc(&s).metadata.xmp.as_deref(), Some(MULTILINGUAL_XMP));
|
||||
assert!(s.redo());
|
||||
assert_eq!(doc(&s).metadata.xmp.as_deref(), Some(edited.as_str()));
|
||||
assert_eq!(doc(&s).metadata.xmp.as_deref(), Some(&*edited));
|
||||
let (bytes, _) = encode(doc(&s), "x.psd", None).unwrap();
|
||||
let back = photocraft_io::import("x.psd", &bytes).unwrap().document;
|
||||
assert_translations_kept(back.metadata.xmp.as_deref().unwrap());
|
||||
|
||||
@@ -224,7 +224,7 @@ mod tests {
|
||||
s.execute("file.new", json!({"width": 4, "height": 4})).unwrap();
|
||||
s.execute("layer.newAdjustmentLayer.invert", json!({})).unwrap();
|
||||
s.edit("unsupported fixture", |doc, active| {
|
||||
let l = Layer::new("Mystery", LayerContent::Adjustment(Adjustment::Unsupported { psd_key: "xyzw".into(), raw: vec![1, 2, 3] }));
|
||||
let l = Layer::new("Mystery", LayerContent::Adjustment(Adjustment::Unsupported { psd_key: "xyzw".into(), raw: vec![1, 2, 3].into() }));
|
||||
let id = l.id;
|
||||
doc.layers.push(l);
|
||||
*active = Some(id);
|
||||
|
||||
@@ -1789,3 +1789,37 @@ fn restore_layer_selection_puts_back_what_still_exists() {
|
||||
assert_eq!((st.active_layer, st.selected_layers(), st.layer_anchor), (None, vec![], None), "no layer selected");
|
||||
assert!(Session::new().restore_layer_selection(None, &[], None).is_err(), "no document");
|
||||
}
|
||||
|
||||
/// #4535: every edit snapshots the document into history. The large payloads it carries (the
|
||||
/// XMP packet, Duotone's PSD data, an unsupported adjustment's bytes) are shared between the
|
||||
/// snapshot and the document, not copied per state.
|
||||
#[test]
|
||||
fn history_states_share_large_document_payloads() {
|
||||
use photocraft_doc::{Adjustment, Duotone, DuotoneInk, Layer, LayerContent};
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 16, "height": 16})).unwrap();
|
||||
let raw_id = {
|
||||
let st = s.active_mut().unwrap();
|
||||
let doc = std::sync::Arc::make_mut(&mut st.doc);
|
||||
doc.metadata.xmp = Some("x".repeat(1 << 20).into());
|
||||
doc.duotone = Some(Duotone { inks: vec![DuotoneInk::new("Black", [0.0; 3])], psd_raw: Some(vec![7u8; 1 << 16].into()) });
|
||||
let layer = Layer::new("kept", LayerContent::Adjustment(Adjustment::Unsupported { psd_key: "abcd".into(), raw: vec![9u8; 1 << 16].into() }));
|
||||
let id = layer.id;
|
||||
doc.layers.push(layer);
|
||||
id
|
||||
};
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
let st = s.active().unwrap();
|
||||
let before = st.history.state(st.history.past_len() - 1).unwrap();
|
||||
let (now, then) = (&st.doc, &before);
|
||||
assert!(!std::sync::Arc::ptr_eq(now, then), "the edit made a new document");
|
||||
let xmp = |d: &Document| d.metadata.xmp.clone().unwrap();
|
||||
assert!(std::sync::Arc::ptr_eq(&xmp(now), &xmp(then)), "the XMP packet is shared");
|
||||
let duotone = |d: &Document| d.duotone.as_ref().and_then(|d| d.psd_raw.clone()).unwrap();
|
||||
assert!(std::sync::Arc::ptr_eq(&duotone(now), &duotone(then)), "the Duotone data is shared");
|
||||
let raw = |d: &Document| match &d.layer(raw_id).unwrap().content {
|
||||
LayerContent::Adjustment(Adjustment::Unsupported { raw, .. }) => raw.clone(),
|
||||
other => panic!("{other:?}"),
|
||||
};
|
||||
assert!(std::sync::Arc::ptr_eq(&raw(now), &raw(then)), "the adjustment's bytes are shared");
|
||||
}
|
||||
|
||||
@@ -236,7 +236,7 @@ pub(crate) fn doc_m(d: &Document, sink: &mut dyn Sink) -> DocM {
|
||||
ruler_origin: d.ruler_origin,
|
||||
selection: d.selection.as_ref().map(|s| surface_m(s, sink)),
|
||||
metadata: MetadataM {
|
||||
xmp: d.metadata.xmp.clone(),
|
||||
xmp: d.metadata.xmp.as_deref().map(str::to_owned),
|
||||
exif: opt_blob(&d.metadata.exif, sink),
|
||||
text: d.metadata.text.clone(),
|
||||
psd_resources: d.metadata.psd_resources.iter().map(|(id, n, b)| (*id, n.clone(), sink.blob(b))).collect(),
|
||||
@@ -581,7 +581,7 @@ impl Loader<'_> {
|
||||
}
|
||||
let quick_mask = m.quick_mask.as_ref().map(|c| self.channel(c)).transpose()?;
|
||||
let mut md = Metadata {
|
||||
xmp: m.metadata.xmp.clone(),
|
||||
xmp: m.metadata.xmp.as_deref().map(std::sync::Arc::from),
|
||||
exif: self.opt_blob(&m.metadata.exif)?,
|
||||
text: m.metadata.text.clone(),
|
||||
psd_resources: Vec::new(),
|
||||
|
||||
@@ -228,7 +228,7 @@ pub fn rich_doc(mode: ColorMode, depth: SampleType) -> Document {
|
||||
d.color_table = Some(photocraft_doc::ColorTable { colors: vec![[0, 0, 0], [255, 128, 0]], transparent: Some(1) });
|
||||
d.duotone = Some(photocraft_doc::Duotone {
|
||||
inks: vec![photocraft_doc::DuotoneInk::new("Black", [0.0; 3]), photocraft_doc::DuotoneInk::new("PANTONE 151 C", [1.0, 0.5, 0.0])],
|
||||
psd_raw: Some(vec![1, 2, 3]),
|
||||
psd_raw: Some(vec![1, 2, 3].into()),
|
||||
});
|
||||
d.guides =
|
||||
Guides { horizontal: vec![10.0, 20.5], vertical: vec![100.25], colors: vec![GuideColor { vertical: true, position: 100.25, rgb: [0x33, 0x66, 0xff] }] };
|
||||
|
||||
@@ -29,7 +29,7 @@ fn bef32(d: &[u8], at: usize) -> Option<f32> {
|
||||
}
|
||||
|
||||
fn unsupported(key: &[u8; 4], data: &[u8]) -> Adjustment {
|
||||
Adjustment::Unsupported { psd_key: String::from_utf8_lossy(key).into_owned(), raw: data.to_vec() }
|
||||
Adjustment::Unsupported { psd_key: String::from_utf8_lossy(key).into_owned(), raw: data.into() }
|
||||
}
|
||||
|
||||
fn levels_rec(d: &[u8], at: usize) -> Option<LevelsChannel> {
|
||||
@@ -813,7 +813,7 @@ pub fn write(adj: &Adjustment) -> Vec<([u8; 4], Vec<u8>)> {
|
||||
Adjustment::Unsupported { psd_key, raw } => {
|
||||
let k = psd_key.as_bytes();
|
||||
if k.len() == 4 {
|
||||
return vec![([k[0], k[1], k[2], k[3]], raw.clone())];
|
||||
return vec![([k[0], k[1], k[2], k[3]], raw.to_vec())];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1032,7 +1032,7 @@ mod tests {
|
||||
let inkl = Adjustment::Levels { master: lc(0, 255), per_channel: [lc(0, 255), lc(5, 250), lc(0, 255)], space: ToneSpace::Cmyk, black: lc(30, 255) };
|
||||
let b = write(&inkl);
|
||||
assert_eq!(parse(&b[0].0, &b[0].1, None, Channels::Cmyk), inkl);
|
||||
rt(Adjustment::Unsupported { psd_key: "selc".into(), raw: vec![1, 2, 3] });
|
||||
rt(Adjustment::Unsupported { psd_key: "selc".into(), raw: vec![1, 2, 3].into() });
|
||||
rt(Adjustment::GradientMap { stops: vec![(0.0, [0.0, 0.0, 0.0]), (0.5, [1.0, 0.0, 0.0]), (1.0, [1.0, 1.0, 1.0])], reverse: true, dither: false });
|
||||
rt(Adjustment::GradientMap { stops: vec![(0.0, [0.0, 0.0, 0.0]), (1.0, [1.0, 1.0, 1.0])], reverse: false, dither: true });
|
||||
rt(Adjustment::SelectiveColor { relative: true, adjustments: std::array::from_fn(|r| [r as f32 * 10.0 - 40.0, 5.0, -100.0, 100.0]) });
|
||||
@@ -1346,7 +1346,7 @@ mod tests {
|
||||
assert_eq!(*t, photocraft_cms::lutfile::LutFile::identity(2).data);
|
||||
}
|
||||
// Every kind except a malformed Unsupported key has an encoding.
|
||||
assert!(write(&Adjustment::Unsupported { psd_key: "abc".into(), raw: vec![] }).is_empty());
|
||||
assert!(write(&Adjustment::Unsupported { psd_key: "abc".into(), raw: Vec::new().into() }).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -282,7 +282,7 @@ pub(crate) fn image_to_document(name: &str, img: &Image) -> Result<ImportResult,
|
||||
doc.layers.push(layer);
|
||||
doc.icc_profile = img.icc.clone().map(Arc::new);
|
||||
doc.metadata.exif = img.meta.exif.clone().map(Arc::new);
|
||||
doc.metadata.xmp = img.meta.xmp.clone();
|
||||
doc.metadata.xmp = img.meta.xmp.as_deref().map(Arc::from);
|
||||
doc.metadata.text = img.meta.text.clone();
|
||||
if let Some((x, y)) = img.meta.dpi {
|
||||
doc.resolution_dpi = x;
|
||||
@@ -487,7 +487,7 @@ fn document_to_image_for(doc: &Document, warnings: &mut Vec<String>, cmyk_target
|
||||
};
|
||||
let meta = codecs::Metadata {
|
||||
exif: doc.metadata.exif.as_ref().map(|e| e.to_vec()),
|
||||
xmp: doc.metadata.xmp.clone(),
|
||||
xmp: doc.metadata.xmp.as_deref().map(str::to_owned),
|
||||
text: doc.metadata.text.clone(),
|
||||
dpi: Some((doc.resolution_dpi, doc.resolution_dpi)),
|
||||
..Default::default()
|
||||
|
||||
@@ -630,8 +630,8 @@ impl Ex {
|
||||
photocraft_doc::Adjustment::Unsupported { psd_key, .. } => Some(psd_key.as_bytes()),
|
||||
_ => None,
|
||||
};
|
||||
let keep =
|
||||
keep || matches!(a, photocraft_doc::Adjustment::Unsupported { raw: r, .. } if raw.iter().any(|(k, d)| own == Some(&k[..]) && d == r));
|
||||
let keep = keep
|
||||
|| matches!(a, photocraft_doc::Adjustment::Unsupported { raw: r, .. } if raw.iter().any(|(k, d)| own == Some(&k[..]) && d[..] == r[..]));
|
||||
if !keep {
|
||||
raw.retain(|(k, _)| !adjust_map::ADJUSTMENT_KEYS.contains(&k) && k != b"CgEd" && own != Some(&k[..]));
|
||||
let w = adjust_map::write(a);
|
||||
@@ -1592,7 +1592,7 @@ pub(crate) fn estimate_psd_size(doc: &Document) -> Option<u64> {
|
||||
|
||||
structural_bytes = structural_bytes
|
||||
.checked_add(u64::try_from(doc.icc_profile.as_ref().map_or(0, |profile| profile.len())).ok()?)?
|
||||
.checked_add(u64::try_from(doc.metadata.xmp.as_ref().map_or(0, String::len)).ok()?)?
|
||||
.checked_add(u64::try_from(doc.metadata.xmp.as_deref().map_or(0, str::len)).ok()?)?
|
||||
.checked_add(u64::try_from(doc.metadata.exif.as_ref().map_or(0, |data| data.len())).ok()?)?;
|
||||
for (_, name, data) in &doc.metadata.psd_resources {
|
||||
structural_bytes = structural_bytes.checked_add(u64::try_from(name.len()).ok()?)?.checked_add(u64::try_from(data.len()).ok()?)?;
|
||||
@@ -1643,7 +1643,7 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c
|
||||
.duotone
|
||||
.as_ref()
|
||||
.filter(|d| !d.inks.is_empty() && doc.mode == ColorMode::Duotone && fmt.mode == ColorMode::Grayscale && fmt.sample == SampleType::U8)
|
||||
.map(|d| d.psd_raw.clone().unwrap_or_else(|| crate::duotone_map::write(d)));
|
||||
.map(|d| d.psd_raw.as_deref().map_or_else(|| crate::duotone_map::write(d), <[u8]>::to_vec));
|
||||
let sample = fmt.sample;
|
||||
let cc = fmt.mode.color_channels();
|
||||
let big = doc.size.width > 30_000 || doc.size.height > 30_000;
|
||||
|
||||
@@ -505,7 +505,7 @@ impl Ctx<'_> {
|
||||
self.warn(format!(
|
||||
"layer \"{name}\": its adjustment settings could not be read (unknown adjustment type \"{key}\"); the layer is kept as saved but has no effect"
|
||||
));
|
||||
let raw = rec.block(&k).map(|b| b.data.clone()).unwrap_or_default();
|
||||
let raw = rec.block(&k).map(|b| b.data.as_slice()).unwrap_or_default().into();
|
||||
LayerContent::Adjustment(photocraft_doc::Adjustment::Unsupported { psd_key: key, raw })
|
||||
} else {
|
||||
LayerContent::Raster(self.record_surface(rec, &name))
|
||||
@@ -870,7 +870,7 @@ pub(crate) fn psd_to_document_decoded(
|
||||
}
|
||||
}
|
||||
ids::ICC_PROFILE => doc.icc_profile = Some(Arc::new(r.data.clone())),
|
||||
ids::XMP => doc.metadata.xmp = Some(String::from_utf8_lossy(&r.data).into_owned()),
|
||||
ids::XMP => doc.metadata.xmp = Some(String::from_utf8_lossy(&r.data).into()),
|
||||
ids::EXIF => doc.metadata.exif = Some(Arc::new(r.data.clone())),
|
||||
1032 => parse_guides(&r.data, &mut doc),
|
||||
ids::GLOBAL_ANGLE | ids::GLOBAL_ALTITUDE => {
|
||||
@@ -1087,7 +1087,7 @@ pub(crate) fn psd_to_document_decoded(
|
||||
cx.warn("duotone inks are not interpreted (shown as grayscale)");
|
||||
}
|
||||
let inks = inks.unwrap_or_else(|| vec![photocraft_doc::DuotoneInk::new("Black", [0.0; 3])]);
|
||||
doc.duotone = Some(photocraft_doc::Duotone { inks, psd_raw: Some(file.color_mode_data.clone()) });
|
||||
doc.duotone = Some(photocraft_doc::Duotone { inks, psd_raw: Some(file.color_mode_data.as_slice().into()) });
|
||||
} else if !file.color_mode_data.is_empty() {
|
||||
cx.warn("color mode data is not preserved");
|
||||
}
|
||||
|
||||
@@ -276,7 +276,7 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions, ctl: &photocr
|
||||
// importer reads it back from there unchanged. A second copy in `meta.exif` (the codec's
|
||||
// Exif directory) would be read back in its place, rebuilt without the thumbnail and IFD0
|
||||
// orientation (#1545).
|
||||
xmp: doc.metadata.xmp.clone().filter(|_| opts.xmp == crate::XmpEmbed::All),
|
||||
xmp: doc.metadata.xmp.as_deref().map(str::to_owned).filter(|_| opts.xmp == crate::XmpEmbed::All),
|
||||
text: if opts.xmp == crate::XmpEmbed::All { doc.metadata.text.clone() } else { Vec::new() },
|
||||
dpi: Some((doc.resolution_dpi, doc.resolution_dpi)),
|
||||
photoshop_resources: Some(resources),
|
||||
|
||||
@@ -101,7 +101,7 @@ fn adjustments() -> Vec<(Adjustment, bool)> {
|
||||
(Adjustment::SelectiveColor { relative: true, adjustments: std::array::from_fn(|r| [r as f32 * 10.0 - 40.0, 5.0, -30.0, 20.0]) }, true),
|
||||
(Adjustment::SelectiveColor { relative: false, adjustments: std::array::from_fn(|r| [0.0, r as f32 * 5.0, 0.0, -10.0]) }, true),
|
||||
// A block this version cannot read (a truncated Selective Color) is kept verbatim.
|
||||
(Adjustment::Unsupported { psd_key: "selc".into(), raw: vec![0, 1, 0, 0] }, true),
|
||||
(Adjustment::Unsupported { psd_key: "selc".into(), raw: vec![0, 1, 0, 0].into() }, true),
|
||||
];
|
||||
// Compile-time guard: a new Adjustment kind must be added to the list above.
|
||||
for (a, _) in &all {
|
||||
|
||||
@@ -19,7 +19,7 @@ fn reopen(adj: Adjustment, path: &str, opts: &ExportOptions) -> (Document, Vec<S
|
||||
#[test]
|
||||
fn unreadable_adjustments_warn_on_open() {
|
||||
// A Color Lookup that uses a profile, not a LUT file: kept verbatim, not applied.
|
||||
let profile = Adjustment::Unsupported { psd_key: "clrL".into(), raw: vec![0, 2, 0, 0] };
|
||||
let profile = Adjustment::Unsupported { psd_key: "clrL".into(), raw: vec![0, 2, 0, 0].into() };
|
||||
for (path, opts) in [("x.psd", ExportOptions::default()), ("x.psb", ExportOptions { force_psb: true, ..Default::default() })] {
|
||||
let (doc, warnings) = reopen(profile.clone(), path, &opts);
|
||||
assert!(matches!(&doc.layers[0].content, LayerContent::Adjustment(Adjustment::Unsupported { .. })), "{path}");
|
||||
@@ -28,7 +28,7 @@ fn unreadable_adjustments_warn_on_open() {
|
||||
assert!(w[0].contains("Color Lookup") && w[0].contains("version 2") && w[0].contains("no effect"), "{path}: {}", w[0]);
|
||||
}
|
||||
// Another kind without a specific reason still names the layer and the adjustment.
|
||||
let (_, warnings) = reopen(Adjustment::Unsupported { psd_key: "levl".into(), raw: vec![0, 9] }, "x.psd", &ExportOptions::default());
|
||||
let (_, warnings) = reopen(Adjustment::Unsupported { psd_key: "levl".into(), raw: vec![0, 9].into() }, "x.psd", &ExportOptions::default());
|
||||
assert!(warnings.iter().any(|w| w.contains("Teal Look") && w.contains("Levels settings could not be read;")), "{warnings:?}");
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ fn photoshop_layout_color_lookup_round_trips_through_psb() {
|
||||
// working Color Lookup, with no warning naming the layer (#1763).
|
||||
let raw = photoshop_clr_l_block();
|
||||
let opts = ExportOptions { force_psb: true, ..Default::default() };
|
||||
let (doc, warnings) = reopen(Adjustment::Unsupported { psd_key: "clrL".into(), raw: raw.clone() }, "x.psb", &opts);
|
||||
let (doc, warnings) = reopen(Adjustment::Unsupported { psd_key: "clrL".into(), raw: raw.as_slice().into() }, "x.psb", &opts);
|
||||
assert!(warnings.iter().all(|w| !w.contains("Teal Look")), "{warnings:?}");
|
||||
let LayerContent::Adjustment(Adjustment::ColorLookup { name, size, lut: Some(table), tetrahedral: false, dither: false }) = &doc.layers[0].content else {
|
||||
panic!("{:?}", doc.layers[0].content)
|
||||
|
||||
@@ -234,7 +234,7 @@ fn gradient_maps_with_cmyk_lab_gray_stops_import_and_keep_their_space() {
|
||||
}
|
||||
let fmt = doc.pixel_format();
|
||||
doc.layers.push(raster("Background", fmt, doc.bounds(), 3, false));
|
||||
doc.layers.push(Layer::new("Map", LayerContent::Adjustment(Adjustment::Unsupported { psd_key: "grdm".into(), raw: block.clone() })));
|
||||
doc.layers.push(Layer::new("Map", LayerContent::Adjustment(Adjustment::Unsupported { psd_key: "grdm".into(), raw: block.as_slice().into() })));
|
||||
let bytes = export(&doc, "x.psd", &ExportOptions::default()).unwrap().bytes;
|
||||
let mut back = import("x.psd", &bytes).unwrap().document;
|
||||
let LayerContent::Adjustment(Adjustment::GradientMap { stops: got, .. }) = &back.layers[1].content else {
|
||||
|
||||
@@ -172,7 +172,7 @@ pub fn gen_doc(mode: ColorMode, depth: SampleType, f: Features) -> Document {
|
||||
black: Vec::new(),
|
||||
pencil: false,
|
||||
},
|
||||
Adjustment::Unsupported { psd_key: "selc".into(), raw: vec![0, 1, 0, 0] },
|
||||
Adjustment::Unsupported { psd_key: "selc".into(), raw: vec![0, 1, 0, 0].into() },
|
||||
];
|
||||
// Colour adjustments (Photoshop offers them for colour documents only: they would tint
|
||||
// a grayscale composite).
|
||||
|
||||
@@ -633,9 +633,7 @@ fn adjustment_and_fill_layers_from_psd() {
|
||||
use photocraft_doc::{Adjustment, Fill};
|
||||
assert!(matches!(d.layers[n - 4].content, LayerContent::Adjustment(Adjustment::Invert)));
|
||||
assert!(matches!(d.layers[n - 3].content, LayerContent::Adjustment(Adjustment::Posterize { levels: 4 })));
|
||||
assert!(
|
||||
matches!(&d.layers[n - 2].content, LayerContent::Adjustment(Adjustment::Unsupported { psd_key, raw }) if psd_key == "selc" && raw == &vec![1, 2, 3])
|
||||
);
|
||||
assert!(matches!(&d.layers[n - 2].content, LayerContent::Adjustment(Adjustment::Unsupported { psd_key, raw }) if psd_key == "selc" && **raw == [1, 2, 3]));
|
||||
assert!(matches!(&d.layers[n - 1].content, LayerContent::Fill(Fill::Solid(c)) if c.c[0] == 1.0));
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ fn unknown_adjustment_key_opens_as_unsupported_and_saves_verbatim() {
|
||||
let (d, warnings) = open(&f);
|
||||
let k = String::from_utf8_lossy(&key).into_owned();
|
||||
assert!(
|
||||
matches!(content(&d, "Mystery"), LayerContent::Adjustment(Adjustment::Unsupported { psd_key, raw }) if *psd_key == k && *raw == data),
|
||||
matches!(content(&d, "Mystery"), LayerContent::Adjustment(Adjustment::Unsupported { psd_key, raw }) if *psd_key == k && **raw == data[..]),
|
||||
"{k}: {:?}",
|
||||
content(&d, "Mystery")
|
||||
);
|
||||
|
||||
@@ -17,7 +17,7 @@ fn doc_with_xmp() -> Document {
|
||||
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.metadata.xmp = Some(XMP.into());
|
||||
d
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ fn an_unsupported_adjustment_row_carries_a_warning_marker() {
|
||||
let mut s = photocraft_engine::Session::new();
|
||||
s.execute("file.new", json!({"width": 64, "height": 48})).unwrap();
|
||||
let plain = LayerId(s.execute("layer.new.layer", json!({"name": "plain"})).unwrap()["layer"].as_u64().unwrap());
|
||||
let mystery = Layer::new("Mystery", LayerContent::Adjustment(Adjustment::Unsupported { psd_key: "xyzw".into(), raw: vec![1, 2, 3] }));
|
||||
let mystery = Layer::new("Mystery", LayerContent::Adjustment(Adjustment::Unsupported { psd_key: "xyzw".into(), raw: vec![1, 2, 3].into() }));
|
||||
let mystery_id = mystery.id;
|
||||
s.edit("unsupported fixture", |doc, active| {
|
||||
doc.layers.push(mystery);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# PhotoCraft architecture
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-20 · **Change:** minor (accurate GPU matrix/TRC display conversion; original design plan retained) · **Target:** Adobe Photoshop 2026
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-20 · **Change:** minor (accurate GPU matrix/TRC display conversion; large payloads in the document model are `Arc`-shared, #4535; original design plan retained) · **Target:** Adobe Photoshop 2026
|
||||
|
||||
## Current state (2026-10-10)
|
||||
|
||||
@@ -53,7 +53,7 @@ This plan sets out:
|
||||
2. **Everything is a command.** Each user-visible action has a stable `CommandId` (such as `filter.blur.gaussianBlur`) with typed, serializable parameters. The menu, command palette, shortcuts, recorded actions/macros, CLI, MCP and plugins all dispatch the same commands.
|
||||
3. **Data describes the UI; toolkits draw it.** Tools produce *overlay primitives* (lines, handles, marching-ants paths) as data. Filter and adjustment dialogs are generated from *parameter schemas*. A new toolkit implements one renderer for each, not about 150 bespoke dialogs.
|
||||
4. **One algorithm, one parameter struct.** Every algorithm has a CPU reference implementation (Rust + rayon, deterministic). Some also get a GPU implementation (WGSL). Both read the same `#[repr(C)]` `bytemuck::Pod` parameter struct. We never keep hand-synced copies of an algorithm in several languages.
|
||||
5. **Immutable snapshots, copy-on-write tiles.** Pixel data is stored in `Arc`-shared 256² tiles. Taking a document snapshot is O(layers), which makes undo, background jobs, autosave and UI reads cheap and lock-free.
|
||||
5. **Immutable snapshots, copy-on-write tiles.** Pixel data is stored in `Arc`-shared 256² tiles. Taking a document snapshot is O(layers), which makes undo, background jobs, autosave and UI reads cheap and lock-free. Every other large payload in `photocraft-doc` is `Arc`-shared too (the XMP packet, EXIF, raw PSD resources and blocks, a layer's or Duotone's raw PSD data, an unsupported adjustment's bytes), so a snapshot never copies one; a new field that can hold a large payload follows the same rule (#4535).
|
||||
6. **Determinism.** The same input gives the same output across CPU and GPU (within tolerance), across thread counts, and between preview and export. Noise is hashed from document coordinates.
|
||||
7. **Pure Rust by default.** C/C++ dependencies are allowed only behind Cargo features, in I/O-edge crates (for example an optional LibRaw).
|
||||
8. **Clean-room.** We study other editors' *behaviour* only, and we must **not copy proprietary source** (Rust, WGSL, C++ or JS), even where it ships as readable source. Specs come from public format docs (Adobe PSD spec, ISO/ICC), academic papers (PatchMatch, Poisson blending, ARAP) and observed behaviour. Never paste code from another product.
|
||||
@@ -589,6 +589,7 @@ Each phase ends with a demoable build and green CI on all targets. Phases 8–10
|
||||
|---|---|---|
|
||||
| 2026-10-21 | minor | Removed references to the deleted `rust-framework-options.md`; dropped the UI toolkit and plug-in items from Open decisions, both recorded as decided |
|
||||
| 2026-10-20 | minor | GPU display matrix/shaper path preserves near-black ICC values; complex transforms retain the 3D LUT fallback (#7958) |
|
||||
| 2026-10-14 | minor | Principle 5: every large payload in the document model is `Arc`-shared, so snapshots never copy one (#4535) |
|
||||
| 2026-10-13 | minor | Workspace size and `#[test]` counts recounted (393k → 539k lines, 5,460 → 8,040 tests); other current-state figures unchanged |
|
||||
| 2026-10-10 | minor | Current-state summary and deviations from the plan; status line; broken link to `photoshop-parity.md` repointed to `target-app-parity.md` |
|
||||
| 2026-09-30 | major | Design plan v1 |
|
||||
|
||||
Reference in New Issue
Block a user