//! doc → .pcraft → doc equality, incremental saves, ids, previews. mod common; use common::*; use photocraft_color::{ColorMode, SampleType}; use photocraft_doc::text::{CharStyle, ParagraphStyle, TextRun}; use photocraft_doc::text_styles::{CharacterStyleDef, ParagraphStyleDef}; use photocraft_doc::{Document, Layer, LayerContent, LayerId, Slice, TextLayer, VarKind, VariableDef}; use photocraft_format::*; use photocraft_raster::Rgba8Image; const MODES: [ColorMode; 4] = [ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Cmyk, ColorMode::Lab]; #[test] fn custom_shape_stroke_roundtrip_keeps_every_style_field() { for mode in MODES { for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { let mut d = rich_doc(mode, depth); let stroke = photocraft_doc::ShapeStroke { width: 5.5, opacity: 0.35, align: photocraft_doc::StrokeAlign::Outside, cap: photocraft_doc::LineCap::Square, join: photocraft_doc::LineJoin::Bevel, miter_limit: 2.0, dashes: vec![0.0, 2.0, 4.0, 1.0, 1.5, 3.0], dash_offset: -0.5, ..Default::default() }; d.layers.push(Layer::new( "Custom stroke", LayerContent::Shape(photocraft_doc::ShapeLayer { path: photocraft_doc::Path::new(vec![photocraft_doc::Subpath::polygon(&[(10.0, 10.0), (40.0, 10.0), (25.0, 40.0)])]), stroke: Some(stroke), ..Default::default() }), )); let bytes = save_to_bytes(&d, &SaveOptions::default()).unwrap(); assert_eq!(load_from_bytes(&bytes).unwrap(), d); } } } fn check_zip(mode: ColorMode, depth: SampleType) { let doc = rich_doc(mode, depth); let bytes = save_to_bytes(&doc, &SaveOptions::default()).unwrap(); assert!(is_pcraft(&bytes)); let back = load_from_bytes(&bytes).unwrap(); assert_eq!(back, doc, "{mode:?} {depth:?}"); } macro_rules! zip_cases { ($($name:ident: $m:ident, $d:ident;)*) => {$( #[test] fn $name() { check_zip(ColorMode::$m, SampleType::$d); } )*}; } zip_cases! { rgb_u8: Rgb, U8; rgb_u16: Rgb, U16; rgb_f32: Rgb, F32; gray_u8: Grayscale, U8; gray_u16: Grayscale, U16; gray_f32: Grayscale, F32; cmyk_u8: Cmyk, U8; cmyk_u16: Cmyk, U16; cmyk_f32: Cmyk, F32; lab_u8: Lab, U8; lab_u16: Lab, U16; lab_f32: Lab, F32; } #[test] fn directory_bundle_roundtrip_all_modes() { for mode in MODES { for depth in SampleType::ALL { let doc = rich_doc(mode, depth); let dir = temp_dir("dir"); PcraftWriter::new().save_dir(&doc, &dir, &SaveOptions::default()).unwrap(); assert!(dir.join("manifest.json").is_file()); assert_eq!(load_path(&dir).unwrap(), doc, "{mode:?} {depth:?}"); std::fs::remove_dir_all(dir).unwrap(); } } } #[test] fn save_path_zip_file() { let doc = rich_doc(ColorMode::Rgb, SampleType::U8); let dir = temp_dir("file"); let path = dir.join("a.pcraft"); PcraftWriter::new().save_path(&doc, &path, &SaveOptions::default()).unwrap(); assert!(path.is_file()); assert_eq!(load_path(&path).unwrap(), doc); std::fs::remove_dir_all(dir).unwrap(); } /// The public [`ByteSource`] entry loads a saved directory like `load_path` does. #[test] fn load_bytes_source_matches_the_path_load() { let doc = rich_doc(ColorMode::Rgb, SampleType::U8); let dir = temp_dir("bytes-source"); let bundle = dir.join("a.pcraft"); PcraftWriter::new().save_dir(&doc, &bundle, &SaveOptions::default()).unwrap(); let loaded = load_bytes_source(&FsDir { root: bundle.clone() }, &LoadOptions::default()).unwrap(); assert_eq!(loaded, doc); assert!(load_bytes_source(&FsDir { root: bundle.join("no-such-dir") }, &LoadOptions::default()).is_err(), "a missing bundle errors"); std::fs::remove_dir_all(dir).unwrap(); } /// `read_entry` bounds the read itself, not the stat hint: a stream that ignores the hint /// and yields more than `max` errors instead of filling memory. #[test] fn read_entry_bounds_the_read_not_the_hint() { struct Endless; impl std::io::Read for Endless { fn read(&mut self, buf: &mut [u8]) -> std::io::Result { buf.fill(b'x'); Ok(buf.len()) } } let mut endless = Endless; let error = read_entry(&mut endless, 0, 100, "manifest.json").unwrap_err().to_string(); assert!(error.contains("exceeds"), "{error}"); let mut small = &b"hi"[..]; assert_eq!(read_entry(&mut small, 2, 100, "manifest.json").unwrap(), b"hi"); } /// Reads the on-disk bundle shape for [`load_bytes_source_matches_the_path_load`]. struct FsDir { root: std::path::PathBuf, } impl ByteSource for FsDir { fn get(&self, path: &str, max: usize) -> Result> { let p = self.root.join(path); let metadata = std::fs::metadata(&p).map_err(|_| FormatError::Corrupt(format!("missing `{path}`")))?; if !metadata.is_file() { return Err(FormatError::Corrupt(format!("`{path}` is not a regular file"))); } let mut file = std::fs::File::open(&p).map_err(|_| FormatError::Corrupt(format!("missing `{path}`")))?; read_entry(&mut file, metadata.len(), max, path) } } /// Deepest group nesting in `layers` (0 when there are no groups). fn group_depth(layers: &[Layer]) -> usize { layers.iter().map(|l| if let photocraft_doc::LayerContent::Group(g) = &l.content { 1 + group_depth(&g.children) } else { 0 }).max().unwrap_or(0) } /// [`rich_doc`] with its layers wrapped in groups until they are nested `levels` deep. fn nested_doc(levels: usize, depth: SampleType) -> Document { let mut doc = rich_doc(ColorMode::Rgb, depth); let mut layers = std::mem::take(&mut doc.layers); for i in group_depth(&layers)..levels { layers = vec![Layer::group(format!("Level {i}"), layers)]; } doc.layers = layers; assert_eq!(group_depth(&doc.layers), levels); doc } /// serde_json's default limit (128 levels) used to make bundles with 40+ nested groups unreadable. /// Runs on a 1 MiB stack, the smallest main-thread stack we ship on (Windows, wasm). #[test] fn deeply_nested_groups_roundtrip() { let run = || { for (levels, depth) in [(40, SampleType::U8), (MAX_GROUP_DEPTH, SampleType::U16), (MAX_GROUP_DEPTH, SampleType::F32)] { let doc = nested_doc(levels, depth); let back = load_from_bytes(&save_to_bytes(&doc, &SaveOptions::default()).unwrap()).unwrap(); assert_eq!(back, doc, "{levels} levels at {depth:?}"); } }; std::thread::Builder::new().stack_size(1 << 20).spawn(run).unwrap().join().unwrap(); } /// A save never writes a bundle the loader would reject for its nesting. #[test] fn nesting_beyond_the_load_limit_is_refused_at_save() { let e = save_to_bytes(&nested_doc(MAX_GROUP_DEPTH + 1, SampleType::U8), &SaveOptions::default()).unwrap_err(); assert!(matches!(e, FormatError::LimitExceeded(_)), "{e}"); } #[test] fn empty_document_roundtrips() { let doc = Document::new("empty", photocraft_doc::Size::new(1, 1), ColorMode::Rgb, SampleType::U8); assert_eq!(load_from_bytes(&save_to_bytes(&doc, &SaveOptions::default()).unwrap()).unwrap(), doc); } #[test] fn exhausted_document_ids_and_maximal_text_runs_roundtrip() { let mut doc = Document::new("exhausted", photocraft_doc::Size::new(2, 1), ColorMode::Rgb, SampleType::U8); doc.slices.list.push(Slice { id: u32::MAX, ..Default::default() }); doc.text_styles.character.push(CharacterStyleDef { id: u32::MAX, ..Default::default() }); doc.text_styles.paragraph.push(ParagraphStyleDef { id: u32::MAX, ..Default::default() }); doc.layers.push(Layer::new( "Text", LayerContent::Text(TextLayer { text: "ab".into(), runs: vec![TextRun { len: 1, style: CharStyle::default() }, TextRun { len: usize::MAX, style: CharStyle { size_pt: 24.0, ..Default::default() } }], paragraphs: vec![ photocraft_doc::text::ParagraphRun { len: 1, style: ParagraphStyle::default() }, photocraft_doc::text::ParagraphRun { len: usize::MAX, style: ParagraphStyle::default() }, ], ..Default::default() }), )); let bytes = save_to_bytes(&doc, &SaveOptions::default()).unwrap(); let loaded = load_from_bytes(&bytes).unwrap(); assert_eq!(loaded.slices.next_id(), None); assert_eq!(loaded.text_styles.next_char_id(), None); assert_eq!(loaded.text_styles.next_para_id(), None); let LayerContent::Text(text) = &loaded.layers.last().unwrap().content else { panic!("text layer must roundtrip") }; assert_eq!(text.char_runs().iter().map(|r| r.len).sum::(), text.text.len()); assert_eq!(text.char_runs()[1].len, 1); assert_eq!(text.char_runs()[1].style.size_pt, 24.0); assert_eq!(text.paragraph_runs().iter().map(|r| r.len).sum::(), text.text.len()); } #[test] fn zip_incremental_only_new_tiles() { let mut doc = rich_doc(ColorMode::Rgb, SampleType::U16); let mut w = PcraftWriter::new(); let (_, s1) = w.save_zip(&doc, &SaveOptions::default()).unwrap(); assert!(s1.tiles_total > 5); assert_eq!(s1.tiles_written, s1.tiles_total); let (_, s2) = w.save_zip(&doc, &SaveOptions::default()).unwrap(); assert_eq!(s2.tiles_written, 0); assert_eq!(s2.tiles_reused, s2.tiles_total); assert_eq!(s2.blobs_written, 0); // Touch one pixel of one layer: exactly one tile is new. let id = doc.layers[1].id; doc.layer_mut(id).unwrap().surface_mut().unwrap().write_pixel(3, 3, &[0.5, 0.5, 0.5, 1.0]); let (bytes, s3) = w.save_zip(&doc, &SaveOptions::default()).unwrap(); assert_eq!(s3.tiles_written, 1); assert_eq!(load_from_bytes(&bytes).unwrap(), doc); } #[test] fn directory_incremental_and_gc() { let mut doc = rich_doc(ColorMode::Rgb, SampleType::U8); let dir = temp_dir("gc"); let mut w = PcraftWriter::new(); let s1 = w.save_dir(&doc, &dir, &SaveOptions::default()).unwrap(); assert_eq!(s1.tiles_written, s1.tiles_total); let s2_same_writer = w.save_dir(&doc, &dir, &SaveOptions::default()).unwrap(); assert_eq!(s2_same_writer.tiles_written, 0); assert_eq!(s2_same_writer.tiles_reused, s2_same_writer.tiles_total); // A fresh writer still skips files already on disk. let s2 = PcraftWriter::new().save_dir(&doc, &dir, &SaveOptions::default()).unwrap(); assert_eq!(s2.tiles_written, 0); assert_eq!(s2.objects_removed, 0); // Delete a layer: its unique tiles get garbage-collected. let paint = doc.layers[1].id; doc.remove(paint); let s3 = w.save_dir(&doc, &dir, &SaveOptions::default()).unwrap(); assert!(s3.objects_removed > 0, "{s3:?}"); assert_eq!(load_path(&dir).unwrap(), doc); let files = std::fs::read_dir(dir.join("tiles")).unwrap().count(); assert_eq!(files, s3.tiles_total); std::fs::remove_dir_all(dir).unwrap(); } #[cfg(unix)] #[test] fn directory_save_rejects_object_directory_symlink_without_deleting_external_objects() { use std::os::unix::fs::symlink; let doc = rich_doc(ColorMode::Rgb, SampleType::U8); let dir = temp_dir("symlink-object-dir"); let outside = temp_dir("symlink-object-dir-outside"); let external_object = outside.join("unrelated.zst"); std::fs::write(&external_object, b"keep this object").unwrap(); symlink(&outside, dir.join("tiles")).unwrap(); assert!(PcraftWriter::new().save_dir(&doc, &dir, &SaveOptions::default()).is_err()); assert_eq!(std::fs::read(&external_object).unwrap(), b"keep this object"); assert!(!dir.join("manifest.json").exists()); std::fs::remove_file(dir.join("tiles")).unwrap(); std::fs::remove_dir_all(dir).unwrap(); std::fs::remove_dir_all(outside).unwrap(); } #[cfg(unix)] #[test] fn directory_save_rejects_object_symlink_without_overwriting_target() { use std::os::unix::fs::symlink; let doc = rich_doc(ColorMode::Rgb, SampleType::U8); let dir = temp_dir("symlink-object"); let outside = temp_dir("symlink-object-outside"); let mut writer = PcraftWriter::new(); writer.save_dir(&doc, &dir, &SaveOptions::default()).unwrap(); let object = std::fs::read_dir(dir.join("tiles")).unwrap().next().unwrap().unwrap().path(); std::fs::remove_file(&object).unwrap(); let external_object = outside.join("external.zst"); std::fs::write(&external_object, b"do not overwrite").unwrap(); symlink(&external_object, &object).unwrap(); assert!(writer.save_dir(&doc, &dir, &SaveOptions::default()).is_err()); assert_eq!(std::fs::read(&external_object).unwrap(), b"do not overwrite"); assert!(std::fs::symlink_metadata(&object).unwrap().file_type().is_symlink()); std::fs::remove_dir_all(dir).unwrap(); std::fs::remove_dir_all(outside).unwrap(); } #[cfg(unix)] #[test] fn directory_save_accepts_chosen_root_symlink_but_rejects_preview_symlink() { use std::os::unix::fs::symlink; let doc = rich_doc(ColorMode::Rgb, SampleType::U8); let outside = temp_dir("symlink-preview-outside"); let root_link_parent = temp_dir("symlink-root-parent"); let root_link = root_link_parent.join("linked.pcraft"); symlink(&outside, &root_link).unwrap(); PcraftWriter::new().save_dir(&doc, &root_link, &SaveOptions::default()).unwrap(); assert_eq!(load_path(&root_link).unwrap(), doc); let trailing_separator = std::path::PathBuf::from(format!("{}/", root_link.display())); PcraftWriter::new().save_dir(&doc, &trailing_separator, &SaveOptions::default()).unwrap(); assert!(outside.join("manifest.json").is_file()); let dir = temp_dir("symlink-preview"); let external_preview = outside.join("preview.png"); std::fs::write(&external_preview, b"keep preview").unwrap(); std::fs::create_dir(dir.join("composite")).unwrap(); symlink(&external_preview, dir.join("composite/preview.png")).unwrap(); let opts = SaveOptions { composite: Some(Rgba8Image::new(2, 2)), ..Default::default() }; assert!(PcraftWriter::new().save_dir(&doc, &dir, &opts).is_err()); assert_eq!(std::fs::read(&external_preview).unwrap(), b"keep preview"); assert!(!dir.join("manifest.json").exists()); std::fs::remove_file(root_link).unwrap(); std::fs::remove_dir_all(root_link_parent).unwrap(); std::fs::remove_dir_all(dir).unwrap(); std::fs::remove_dir_all(outside).unwrap(); } #[cfg(unix)] #[test] fn directory_save_rejects_manifest_symlink_without_overwriting_target() { use std::os::unix::fs::symlink; let doc = rich_doc(ColorMode::Rgb, SampleType::U8); let dir = temp_dir("symlink-manifest"); let outside = temp_dir("symlink-manifest-outside"); let external_manifest = outside.join("external.json"); std::fs::write(&external_manifest, b"keep manifest").unwrap(); symlink(&external_manifest, dir.join("manifest.json")).unwrap(); assert!(PcraftWriter::new().save_dir(&doc, &dir, &SaveOptions::default()).is_err()); assert_eq!(std::fs::read(&external_manifest).unwrap(), b"keep manifest"); assert_eq!(std::fs::read_dir(dir.join("tiles")).unwrap().count(), 0); std::fs::remove_dir_all(dir).unwrap(); std::fs::remove_dir_all(outside).unwrap(); } #[test] fn identical_tiles_are_stored_once() { let mut doc = Document::new("d", photocraft_doc::Size::new(10, 10), ColorMode::Rgb, SampleType::U8); let mut a = Layer::raster("a", doc.pixel_format()); scribble(a.surface_mut().unwrap(), 5, false); let b = a.duplicate(); doc.layers.push(a); doc.layers.push(b); let (_, s) = PcraftWriter::new().save_zip(&doc, &SaveOptions::default()).unwrap(); let per_layer = doc.layers[0].surface().unwrap().tile_count(); assert_eq!(s.tiles_total, per_layer); } /// A solid fill saves one tile however many coordinates share it; reopening must decode it once, /// charge the byte budget once and keep the sharing (#3315). The budget here holds one tile, /// standing in for a canvas too large to expand tile by tile. #[test] fn solid_fill_reopens_with_one_shared_tile() { for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { let size = photocraft_doc::Size::new(2048, 2048); let mut doc = Document::new("d", size, ColorMode::Rgb, depth); let mut l = Layer::raster("solid", doc.pixel_format()); l.surface_mut().unwrap().fill_rect(photocraft_geom::Rect::from_size(size), &[1.0, 0.5, 0.25, 1.0]); doc.layers.push(l); let bytes = save_to_bytes(&doc, &SaveOptions::default()).unwrap(); let one_tile = (256 * 256 * doc.pixel_format().bytes_per_pixel()) as u64; let back = load_from_bytes_with(&bytes, &LoadOptions { max_total_bytes: one_tile, ..Default::default() }).unwrap(); assert_eq!(back, doc, "{depth:?}"); let s = back.layers[0].surface().unwrap(); assert_eq!(s.tile_count(), 64, "{depth:?}"); let first = s.tiles().next().unwrap().1; assert!(s.tiles().all(|(_, t)| std::sync::Arc::ptr_eq(t, first)), "{depth:?}: tiles not shared after reopening"); } } #[test] fn ids_preserved_and_counter_advanced() { let doc = rich_doc(ColorMode::Rgb, SampleType::U8); let bytes = save_to_bytes(&doc, &SaveOptions::default()).unwrap(); // Simulate a file written with much larger ids. let mut m = read_manifest(&bytes).unwrap(); m.document.layers[0].id = LayerId::fresh().0 + 5000; let big = m.document.layers[0].id; let text = serde_json::to_vec(&m).unwrap(); let mut z = zip::ZipWriter::new(); z.add("manifest.json", &text).unwrap(); let r = zip::ZipReader::new(&bytes).unwrap(); for e in r.entries.iter().filter(|e| e.name != "manifest.json") { z.add(&e.name, &r.read(e, usize::MAX).unwrap()).unwrap(); } let back = load_from_bytes(&z.finish().unwrap()).unwrap(); assert_eq!(back.layers[0].id.0, big); assert!(LayerId::fresh().0 > big, "fresh ids must not collide with loaded ids"); } #[test] fn fresh_ids_option() { let doc = rich_doc(ColorMode::Rgb, SampleType::U8); let bytes = save_to_bytes(&doc, &SaveOptions::default()).unwrap(); let back = load_from_bytes_with(&bytes, &LoadOptions { preserve_ids: false, ..Default::default() }).unwrap(); assert_ne!(back.id, doc.id); assert_ne!(back.layers[0].id, doc.layers[0].id); assert_eq!(back.layers.len(), doc.layers.len()); assert_eq!(back.layers[1].surface(), doc.layers[1].surface()); } #[test] fn previews_embedded_and_readable() { let doc = rich_doc(ColorMode::Rgb, SampleType::U8); let mut thumb = Rgba8Image::new(4, 3); thumb.pixels.iter_mut().enumerate().for_each(|(i, p)| *p = i as u8); let opts = SaveOptions { thumbnail: Some(thumb.clone()), composite: Some(thumb.clone()) }; let bytes = save_to_bytes(&doc, &opts).unwrap(); let png = read_thumbnail(&bytes).unwrap().unwrap(); let img = photocraft_codecs::decode(&png).unwrap(); assert_eq!(img.dimensions(), (4, 3)); assert_eq!(img.to_rgba8(), thumb.pixels); let m = read_manifest(&bytes).unwrap(); assert_eq!(m.composite.as_deref(), Some("composite/preview.png")); assert_eq!(m.format_version, FORMAT_VERSION); assert!(read_thumbnail(&save_to_bytes(&doc, &SaveOptions::default()).unwrap()).unwrap().is_none()); } #[test] fn manifest_is_human_readable_json() { let doc = rich_doc(ColorMode::Rgb, SampleType::U8); let bytes = save_to_bytes(&doc, &SaveOptions::default()).unwrap(); let r = zip::ZipReader::new(&bytes).unwrap(); assert_eq!(r.entries[0].name, "manifest.json"); let text = String::from_utf8(r.read_by_name("manifest.json", usize::MAX).unwrap()).unwrap(); assert!(text.contains("\"format_version\": 1")); assert!(text.contains("\"Hue/Sat\"")); assert!(text.contains("filter.blur.gaussian")); assert!(r.entries.iter().any(|e| e.name.starts_with("tiles/") && e.name.ends_with(".zst"))); assert!(r.entries.iter().any(|e| e.name.starts_with("blobs/"))); } #[test] fn save_is_deterministic() { let doc = rich_doc(ColorMode::Cmyk, SampleType::U16); let a = save_to_bytes(&doc, &SaveOptions::default()).unwrap(); let b = save_to_bytes(&doc, &SaveOptions::default()).unwrap(); assert_eq!(a, b); } #[test] fn not_pcraft() { assert!(!is_pcraft(b"")); assert!(!is_pcraft(b"8BPS....")); let mut z = zip::ZipWriter::new(); z.add("other.txt", b"x").unwrap(); assert!(!is_pcraft(&z.finish().unwrap())); } #[test] fn vector_fields_roundtrip_and_default_when_absent() { let doc = rich_doc(ColorMode::Rgb, SampleType::U8); let back = load_from_bytes(&save_to_bytes(&doc, &SaveOptions::default()).unwrap()).unwrap(); assert_eq!(back.paths, doc.paths); assert_eq!(back.work_path, doc.work_path); assert_eq!(back.clipping_path, doc.clipping_path); let vm = |d: &Document| d.walk().into_iter().find_map(|(_, _, l)| l.vector_mask.clone()); assert!(vm(&back).is_some()); assert_eq!(vm(&back), vm(&doc)); // Manifests written before the vector fields existed still load (serde defaults). let m = read_manifest(&save_to_bytes(&doc, &SaveOptions::default()).unwrap()).unwrap(); let mut v = serde_json::to_value(&m.document).unwrap(); for k in ["paths", "work_path", "clipping_path"] { v.as_object_mut().unwrap().remove(k); } fn strip(layers: &mut serde_json::Value) { for l in layers.as_array_mut().unwrap() { l.as_object_mut().unwrap().remove("vector_mask"); let c = l["content"].as_object_mut().unwrap(); for k in ["path", "stroke", "live"] { c.remove(k); } if let Some(ch) = c.get_mut("children") { strip(ch); } } } strip(&mut v["layers"]); let old: photocraft_format::manifest::DocM = serde_json::from_value(v).unwrap(); assert!(old.paths.is_empty() && old.work_path.is_none()); assert!(old.layers.iter().all(|l| l.vector_mask.is_none())); } /// Type kerning (#206): the auto mode and manual kerning survive .pcraft; manifests written /// before `kern` existed load with no manual kerning. #[test] fn text_kerning_roundtrips_and_defaults() { use photocraft_doc::LayerContent; use photocraft_doc::text::Kerning; let doc = rich_doc(ColorMode::Rgb, SampleType::U8); let bytes = save_to_bytes(&doc, &SaveOptions::default()).unwrap(); let back = load_from_bytes(&bytes).unwrap(); let runs = |d: &Document| { d.walk() .into_iter() .find_map(|(_, _, l)| match &l.content { LayerContent::Text(t) => Some(t.runs.iter().map(|r| (r.len, r.style.kerning, r.style.kern)).collect::>()), _ => None, }) .unwrap() }; assert_eq!(runs(&back), vec![(1, Kerning::Off, 120.0), (5, Kerning::Metrics, 0.0), (9, Kerning::Optical, 0.0)]); assert_eq!(runs(&back), runs(&doc)); let m = read_manifest(&bytes).unwrap(); let mut v = serde_json::to_value(&m.document).unwrap(); fn strip(v: &mut serde_json::Value) { match v { serde_json::Value::Object(o) => { o.remove("kern"); o.values_mut().for_each(strip); } serde_json::Value::Array(a) => a.iter_mut().for_each(strip), _ => {} } } strip(&mut v); assert!(!v.to_string().contains("\"kern\"")); let old: photocraft_format::manifest::DocM = serde_json::from_value(v).unwrap(); let s = serde_json::to_string(&old).unwrap(); assert!(s.contains("\"kern\":0.0"), "kern defaults to 0"); } /// Type on a path (#218): the path, start offset and flip survive .pcraft. #[test] fn path_text_shape_roundtrips() { use photocraft_doc::text::TextShape; use photocraft_doc::vector::Subpath; let mut doc = rich_doc(ColorMode::Rgb, SampleType::U8); let path = Subpath::polyline(&[(0.0, 0.0), (50.0, 20.0), (120.0, 5.5)]); let ids: Vec = doc.walk().into_iter().filter(|(_, _, l)| matches!(l.content, LayerContent::Text(_))).map(|(_, _, l)| l.id).collect(); for id in ids { if let Some(LayerContent::Text(t)) = doc.layer_mut(id).map(|l| &mut l.content) { t.shape = TextShape::Path { path: path.clone(), start: 12.5, flip: true }; } } let back = load_from_bytes(&save_to_bytes(&doc, &SaveOptions::default()).unwrap()).unwrap(); let shape = back.walk().into_iter().find_map(|(_, _, l)| match &l.content { LayerContent::Text(t) => Some(t.shape.clone()), _ => None, }); assert_eq!(shape, Some(TextShape::Path { path, start: 12.5, flip: true })); } #[test] fn channel_restrictions_and_bevel_elements_roundtrip() { use photocraft_doc::{Bevel, BevelContour, BevelTexture, Contour, Effect}; let mut doc = rich_doc(ColorMode::Rgb, SampleType::U8); doc.layers[0].excluded_channels = 0b101; let mut b = Bevel { enabled: true, style: photocraft_doc::BevelStyle::Emboss, technique: photocraft_doc::BevelTechnique::ChiselHard, depth: 1.2, up: false, size: 9.0, soften: 1.0, angle: 30.0, altitude: 40.0, use_global_light: false, gloss_contour: Contour::Linear, highlight: photocraft_doc::FxCommon::new(photocraft_color::BlendMode::Screen, 0.7), highlight_color: photocraft_color::Color::WHITE, shadow: photocraft_doc::FxCommon::new(photocraft_color::BlendMode::Multiply, 0.6), shadow_color: photocraft_color::Color::BLACK, contour: None, texture: None, }; b.contour = Some(BevelContour { contour: Contour::Linear, range: 0.3, anti_alias: true }); b.texture = Some(BevelTexture { name: "p".into(), id: "i".into(), scale: 0.5, depth: -1.5, invert: true, link: false, phase: (1.0, 2.0) }); doc.layers[0].effects.items.push(Effect::BevelEmboss(Bevel { ..b })); let back = load_from_bytes(&save_to_bytes(&doc, &SaveOptions::default()).unwrap()).unwrap(); assert_eq!(back, doc); assert_eq!(back.layers[0].excluded_channels, 0b101); } #[test] fn blend_if_roundtrips() { use photocraft_doc::{BlendIf, BlendRange}; let mut doc = rich_doc(ColorMode::Rgb, SampleType::U16); let mut bi = BlendIf::default(); bi.set(0, [BlendRange { black: [20, 60], white: [255, 255] }, BlendRange::FULL]); bi.set(3, [BlendRange::FULL, BlendRange { black: [0, 0], white: [180, 220] }]); doc.layers[0].blend_if = bi.clone(); let back = load_from_bytes(&save_to_bytes(&doc, &SaveOptions::default()).unwrap()).unwrap(); assert_eq!(back, doc); assert_eq!(back.layers[0].blend_if, bi); // Layers without Blend If keep the default (the field is omitted from the manifest). assert!(back.layers.iter().skip(1).all(|l| l.blend_if.is_default())); } /// Advanced Blending (knockout, as-group switches, transparency shapes, masks hide effects) /// survives .pcraft at every depth; manifests written before the field existed load with /// Photoshop's defaults, and default layers don't write it. #[test] fn advanced_blending_roundtrips_and_defaults_when_absent() { use photocraft_doc::{AdvancedBlending, Knockout}; for depth in SampleType::ALL { let mut doc = rich_doc(ColorMode::Rgb, depth); let a = AdvancedBlending { knockout: Knockout::Deep, blend_interior: true, blend_clipped: false, transparency_shapes: false, layer_mask_hides_effects: true, vector_mask_hides_effects: true, }; doc.layers[0].advanced = a; if let Some(l) = doc.layers.get_mut(1) { l.advanced.knockout = Knockout::Shallow; } let bytes = save_to_bytes(&doc, &SaveOptions::default()).unwrap(); let back = load_from_bytes(&bytes).unwrap(); assert_eq!(back, doc, "{depth:?}"); assert_eq!(back.layers[0].advanced, a); // Only the non-default layers carry the field. let m = read_manifest(&bytes).unwrap(); let v = serde_json::to_value(&m.document).unwrap(); let with = v["layers"].as_array().unwrap().iter().filter(|l| l.get("advanced").is_some()).count(); assert_eq!(with, if doc.layers.len() > 1 { 2 } else { 1 }); // An older manifest (no `advanced`) loads with the defaults. let mut old = v.clone(); for l in old["layers"].as_array_mut().unwrap() { l.as_object_mut().unwrap().remove("advanced"); } let old: photocraft_format::manifest::DocM = serde_json::from_value(old).unwrap(); assert!(old.layers.iter().all(|l| l.advanced.is_default())); } } #[test] fn video_layer_frames_survive_roundtrip() { use photocraft_doc::{Timeline, VideoData, VideoSource}; use photocraft_geom::Rect; let mut doc = Document::new("Vid", photocraft_geom::Size { width: 8, height: 6 }, ColorMode::Rgb, SampleType::U8); doc.timeline = Some(Timeline::new(3, 24.0)); let fmt = doc.pixel_format(); let mut l = Layer::raster("Clip", fmt); let mut frames = Vec::new(); for i in 0..3u8 { let mut s = photocraft_raster::Surface::new(fmt); let v = i as f32 / 2.0; s.fill_rect(Rect::new(0, 0, 8, 6), &photocraft_raster::from_rgba(&fmt, [v, 0.0, 1.0 - v, 1.0])); frames.push(s); } let mut vd = VideoData::new(frames, 24.0); vd.original_frames = Some(vd.frames.clone()); vd.source = VideoSource::File { path: "/tmp/seq".into() }; vd.show_altered = false; l.video = Some(vd); doc.layers.push(l); let bytes = save_to_bytes(&doc, &SaveOptions::default()).unwrap(); let back = load_from_bytes(&bytes).unwrap(); let v = back.layers.last().unwrap().video.as_ref().expect("video survived"); assert_eq!(v.frames.len(), 3); assert_eq!(v.original_frames.as_ref().map(Vec::len), Some(3)); assert_eq!(v.original_frames.as_ref().unwrap()[2].rgba(1, 1), v.frames[2].rgba(1, 1)); assert_eq!(v.fps, 24.0); assert!(!v.show_altered); assert_eq!(v.source, VideoSource::File { path: "/tmp/seq".into() }); assert_eq!(back.timeline.as_ref().unwrap().duration, 3); // A middle frame's pixel survived. let mut px = [[0.0f32; 4]; 1]; v.frames[2].read_rgba_into(Rect::from_xywh(1, 1, 1, 1), &mut px); assert!(px[0][0] > 0.9 && px[0][2] < 0.1, "frame 2 is reddish: {:?}", px[0]); } /// Issue #1101: serde_json writes NaN and the infinities as `null`, which the manifest's float /// fields refuse on load, so such a document saved "successfully" and never opened again. The /// save is refused instead, naming the value; finite documents are unaffected. #[test] fn a_non_finite_float_is_refused_at_save_rather_than_breaking_every_later_load() { use photocraft_doc::Adjustment; type Mutate = Box; let base = rich_doc(ColorMode::Rgb, SampleType::U8); let cases: Vec<(&str, Mutate)> = vec![ ( "exposure", Box::new(|d| { d.insert_above(None, Layer::new("adj", LayerContent::Adjustment(Adjustment::Exposure { exposure: f32::NAN, offset: 0.0, gamma: 1.0 }))); }), ), ( "gamma", Box::new(|d| { d.insert_above( None, Layer::new("adj", LayerContent::Adjustment(Adjustment::Exposure { exposure: 0.0, offset: 0.0, gamma: f32::NEG_INFINITY })), ); }), ), ("resolution_dpi", Box::new(|d| d.resolution_dpi = f32::INFINITY)), ]; for (field, mutate) in cases { let mut doc = base.clone(); mutate(&mut doc); let err = save_to_bytes(&doc, &SaveOptions::default()).expect_err(field); assert!(matches!(err, FormatError::NonFinite { .. }), "{field}: {err:?}"); let msg = err.to_string(); assert!(msg.contains(field) && msg.contains("NaN or infinite"), "{field}: {msg}"); // The incremental writer shares the check, for autosave and directory bundles. let mut w = PcraftWriter::new(); assert!(matches!(w.save_zip(&doc, &SaveOptions::default()), Err(FormatError::NonFinite { .. })), "{field}: PcraftWriter"); } // The same document with finite values round-trips as before. let mut doc = base.clone(); doc.insert_above(None, Layer::new("adj", LayerContent::Adjustment(Adjustment::Exposure { exposure: 0.5, offset: 0.0, gamma: 1.0 }))); let back = load_from_bytes(&save_to_bytes(&doc, &SaveOptions::default()).unwrap()).unwrap(); assert_eq!(back, doc); } #[test] fn variables_follow_layer_id_remapping() { let mut doc = rich_doc(ColorMode::Rgb, SampleType::U8); let id_of = |d: &Document, name: &str| d.walk().into_iter().find(|(_, _, l)| l.name == name).unwrap().2.id; doc.variables.defs = vec![ VariableDef { name: "head".into(), layer: id_of(&doc, "Paint"), kind: VarKind::TextReplacement }, VariableDef { name: "badge".into(), layer: id_of(&doc, "Inner"), kind: VarKind::Visibility }, VariableDef { name: "gone".into(), layer: LayerId(u64::MAX), kind: VarKind::Visibility }, ]; let bytes = save_to_bytes(&doc, &SaveOptions::default()).unwrap(); // Preserved ids keep every definition exactly as saved, dangling ones included. assert_eq!(load_from_bytes(&bytes).unwrap(), doc); // Remapped ids carry the definitions to the copied layers; a dangling target stays // dangling and must not collide with a live layer (#4969). let back = load_from_bytes_with(&bytes, &LoadOptions { preserve_ids: false, ..Default::default() }).unwrap(); let defs = &back.variables.defs; assert_eq!(defs.iter().find(|d| d.name == "head").unwrap().layer, id_of(&back, "Paint")); assert_eq!(defs.iter().find(|d| d.name == "badge").unwrap().layer, id_of(&back, "Inner")); let gone = defs.iter().find(|d| d.name == "gone").unwrap().layer; assert!(back.walk().into_iter().all(|(_, _, l)| l.id != gone), "dangling target must not address a live layer"); }