From 22272b8d9550b5aaed3143fed88ef4dd892f4ea3 Mon Sep 17 00:00:00 2001 From: ashokram-si <347905169+ashokram-si@users.noreply.github.com> Date: Wed, 28 Oct 2026 14:40:56 +0530 Subject: [PATCH 01/31] JPEG Options: show how big the file will be (FILE-215-19, #7843) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Choosing a JPEG quality is a trade between how the picture looks and how big the file gets, but the dialog gave no idea of the size. It now shows it at the left of OK and Cancel ("152.3K", "1.5M"), updated as Quality and Format Options change. The size is the file's: the document is encoded in memory with the settings OK writes (same encoder and ExportSettings as the save, so the file's own quality, profile and metadata count). The encode runs on a worker thread in the native app and in the frame on the web; one runs at a time and its answer is kept per settings, so a frame that changes nothing encodes nothing, and the last size stays up while a new one is worked out. Documents over 40 MP (4 MP on the web) read "—". Docs: gaps (G10), file-format and target-app parity, the scorecard row and the book drop the size preview from what JPEG Options lacks. --- book/src/formats/raster-formats.md | 2 +- crates/ui-egui/src/dialogs/jpeg_options_ui.rs | 10 +- .../dialogs/jpeg_options_ui/size_readout.rs | 176 ++++++++++++++++++ .../jpeg_options_ui/size_readout/tests.rs | 154 +++++++++++++++ crates/ui-egui/src/i18n/cs.tsv | 3 + crates/ui-egui/src/i18n/de.tsv | 3 + crates/ui-egui/src/i18n/el.tsv | 3 + crates/ui-egui/src/i18n/es.tsv | 3 + crates/ui-egui/src/i18n/fr.tsv | 3 + crates/ui-egui/src/i18n/hi.tsv | 3 + crates/ui-egui/src/i18n/hu.tsv | 3 + crates/ui-egui/src/i18n/id.tsv | 3 + crates/ui-egui/src/i18n/it.tsv | 3 + crates/ui-egui/src/i18n/ja.tsv | 3 + crates/ui-egui/src/i18n/ko.tsv | 3 + crates/ui-egui/src/i18n/nl.tsv | 3 + crates/ui-egui/src/i18n/pl.tsv | 3 + crates/ui-egui/src/i18n/pt-br.tsv | 3 + crates/ui-egui/src/i18n/ru.tsv | 3 + crates/ui-egui/src/i18n/sr.tsv | 3 + crates/ui-egui/src/i18n/tr.tsv | 3 + crates/ui-egui/src/i18n/uk.tsv | 3 + crates/ui-egui/src/i18n/vi.tsv | 3 + crates/ui-egui/src/i18n/zh-hans.tsv | 3 + crates/ui-egui/src/i18n/zh-hant.tsv | 3 + docs/file-format-parity.md | 5 +- docs/gaps.md | 5 +- docs/scorecard.md | 2 +- docs/target-app-parity.md | 5 +- scorecard/file_compat.toml | 2 +- 30 files changed, 413 insertions(+), 11 deletions(-) create mode 100644 crates/ui-egui/src/dialogs/jpeg_options_ui/size_readout.rs create mode 100644 crates/ui-egui/src/dialogs/jpeg_options_ui/size_readout/tests.rs diff --git a/book/src/formats/raster-formats.md b/book/src/formats/raster-formats.md index e1a2cc23fa..0f6e119968 100644 --- a/book/src/formats/raster-formats.md +++ b/book/src/formats/raster-formats.md @@ -19,7 +19,7 @@ Every route that writes a document to a flat format (Save As, the CLI, MCP `doc_ - Formats without transparency (JPEG, Radiance HDR) get the image composited over white, as Export As and Quick Export do; in CMYK, white is no ink. - Formats that can't embed an ICC profile (GIF, BMP, TGA, QOI, ICO, Netpbm/PFM) get RGB documents in another profile (linear light, Display P3, …) converted to sRGB through the colour engine (perceptual intent), since an untagged file is read as sRGB. OpenEXR and Radiance HDR get linear sRGB instead (linear gray for a grayscale OpenEXR), which is how they are tagged when opened. - TGA, as Photoshop's Targa: an RGB Targa is 24 or 32 bits per pixel. Save As and Save a Copy ask (Targa Options, starting at 32 with an alpha channel, else 24); Export As has a Resolution choice (Auto, 24 or 32 bits/pixel); scripts pass `tgaBits` (`--tga-bits` on the CLI). At 32 bits the document's alpha channel is the file's alpha when it has exactly one (spot channels don't count; with several, none is used), and the colours are written exactly, never premultiplied or cleared where the alpha is 0, so a game texture's mask and its colours both survive. Without a usable alpha channel the layers' transparency is the alpha (opaque without any). 24 bits write no alpha, compositing transparency over white. Without a choice: 32 with an alpha channel or transparency, else 24. A 32-bit Targa opens as an opaque Background with its alpha as "Alpha 1", so it round-trips. Grayscale Targas are 8-bit. -- JPEG, as Photoshop's JPEG Options: File › Save, Save As and Save a Copy ask for the Quality on Photoshop's 0–12 scale (with its Low, Medium, High and Maximum presets) and a Format Option: Baseline ("Standard"), Baseline Optimized (Huffman tables fitted to the image) or Progressive. The quality starts at the opened JPEG's own quality, else at the Export preference's JPEG quality; left there, an opened JPEG is written at exactly its own quality. Scripts pass `"quality": 0..12` and `"jpegFormat": "baseline" | "optimized" | "progressive"` (on the CLI, `--quality` on the encoder's 1–100 scale and `--jpeg-format`) and aren't asked; recorded actions replay the answer. The scale maps onto the encoder's 1–100 as `round(q / 12 × 99 + 1)`. RGB JPEGs are written 4:2:0, where Baseline Optimized keeps the standard tables for now (Gray and CMYK, written 4:4:4, get fitted ones). Matte, the progressive scan count (Scans 3–5) and the file-size preview aren't offered. +- JPEG, as Photoshop's JPEG Options: File › Save, Save As and Save a Copy ask for the Quality on Photoshop's 0–12 scale (with its Low, Medium, High and Maximum presets) and a Format Option: Baseline ("Standard"), Baseline Optimized (Huffman tables fitted to the image) or Progressive. The quality starts at the opened JPEG's own quality, else at the Export preference's JPEG quality; left there, an opened JPEG is written at exactly its own quality. Scripts pass `"quality": 0..12` and `"jpegFormat": "baseline" | "optimized" | "progressive"` (on the CLI, `--quality` on the encoder's 1–100 scale and `--jpeg-format`) and aren't asked; recorded actions replay the answer. The scale maps onto the encoder's 1–100 as `round(q / 12 × 99 + 1)`. RGB JPEGs are written 4:2:0, where Baseline Optimized keeps the standard tables for now (Gray and CMYK, written 4:4:4, get fitted ones). Below the Format Options the dialog shows how big the file will be ("152.3K", "1.5M"), so the quality can be chosen with the size in view; it is worked out in the background as the options change, for documents up to 40 megapixels (4 on the web), and reads "—" for larger ones. Matte and the progressive scan count (Scans 3–5) aren't offered. - DDS, for game and 3D textures: Save As and Save a Copy ask (DDS Options) for the compression (None, uncompressed and lossless; BC1/DXT1, whose alpha is on or off; BC2/DXT3; BC3/DXT5) and whether to generate mipmaps (the full chain down to 1×1, each level a box filter of the one above). Scripts pass `ddsCompression` (`none`, `bc1`, `bc2`, `bc3`) and `ddsMipmaps` (`--dds-compression`, `--dds-mipmaps` on the CLI). Opening one reads its top mip level. Flat TIFF and JPEG exports of composited CMYK documents convert the RGB composite back through diff --git a/crates/ui-egui/src/dialogs/jpeg_options_ui.rs b/crates/ui-egui/src/dialogs/jpeg_options_ui.rs index e59d694b58..a44a042512 100644 --- a/crates/ui-egui/src/dialogs/jpeg_options_ui.rs +++ b/crates/ui-egui/src/dialogs/jpeg_options_ui.rs @@ -2,8 +2,9 @@ //! a JPEG, as Photoshop's JPEG Options does. Quality is Photoshop's 0–12 scale (Low 0–4, Medium //! 5–7, High 8–9, Maximum 10–12), mapped to the encoder's 1–100 by //! `photocraft_engine::file_cmds::jpeg_quality`; Format Options are Baseline ("Standard"), Baseline -//! Optimized and Progressive. Matte, Scans and the size preview aren't offered yet. Re-saving an -//! opened JPEG starts at the file's own quality, as File › Save keeps it (#4032). +//! Optimized and Progressive, and the dialog shows the size the file will have. Matte and Scans +//! aren't offered yet. Re-saving an opened JPEG starts at the file's own quality, as File › Save +//! keeps it (#4032). //! //! The save is parked in [`Prompt`] while the modal is up (egui is immediate-mode) and written //! once answered. Cancel drops the save. Scripts skip the prompt by passing `quality` or @@ -21,6 +22,8 @@ use serde_json::Value; use crate::{ExportSettings, PhotocraftApp}; +mod size_readout; + /// The highest quality on Photoshop's JPEG scale. pub const MAX_QUALITY: u8 = 12; @@ -193,6 +196,7 @@ pub fn park_at(app: &mut PhotocraftApp, path: String, copy: bool, record: &'stat } pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { + let size_source = size_readout::Source::of(app); let Some(p) = &mut app.jpeg_options else { return }; let (mut ok, mut cancel) = (false, false); let (mut quality, mut format) = (p.quality, p.format); @@ -227,6 +231,8 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { ui.spacing_mut().item_spacing.x = 10.0; ok = crate::widgets::primary_button(ui, tl!("OK"), 84.0).clicked(); cancel = crate::widgets::secondary_button(ui, tl!("Cancel"), 84.0).clicked(); + // How big the file will be with these options, at the left of the buttons. + ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| size_readout::show(ui, size_source.as_ref(), quality, format)); }); }); cancel |= modal.should_close(); diff --git a/crates/ui-egui/src/dialogs/jpeg_options_ui/size_readout.rs b/crates/ui-egui/src/dialogs/jpeg_options_ui/size_readout.rs new file mode 100644 index 0000000000..30a96af377 --- /dev/null +++ b/crates/ui-egui/src/dialogs/jpeg_options_ui/size_readout.rs @@ -0,0 +1,176 @@ +//! JPEG Options' file size: while choosing a quality, the dialog shows how big the JPEG will be +//! ("152.3K"), and updates it as Quality and Format Options change. The dialog's document is +//! encoded in memory with the settings OK would write it with (the same encoder and +//! [`ExportSettings`] as the save, so the size is the file's, metadata and profile included). +//! +//! The encode runs on a worker thread in the native app, so the dialog never waits for it; the web +//! build has no threads and encodes in the frame, so its limit is lower. One encode runs at a time, +//! and its answer is kept for its settings: a frame that changes nothing encodes nothing, and +//! while a new size is worked out the last one for the same document stays up. Documents over +//! [`MAX_PX`] aren't encoded and read "—". + +use std::sync::{Arc, Mutex, PoisonError}; + +use photocraft_codecs::JpegFormat; +use photocraft_doc::{DocId, Document}; + +use crate::{ExportSettings, PhotocraftApp}; + +/// The largest document, in pixels, whose size is worked out. Flattening and encoding 40 MP takes +/// a second or two on a worker and about 160 MB at 8 bits, as Export As's exact size does (#6595). +#[cfg(not(target_arch = "wasm32"))] +const MAX_PX: u64 = 40_000_000; +/// The web build encodes in the frame: up to 4 MP keeps a settings change under a frame or so. +#[cfg(target_arch = "wasm32")] +const MAX_PX: u64 = 4_000_000; + +/// Whether a document of `size` gets a size readout (see [`MAX_PX`]). +pub(super) fn estimable(size: photocraft_doc::Size) -> bool { + size.area() <= MAX_PX +} + +/// A size as the readout shows it: kilobytes with one decimal under a megabyte +/// ("152.3K"), megabytes above ("1.5M"), in units of 1024. +pub(super) fn size_label(bytes: u64) -> String { + const K: f64 = 1024.0; + let kilo = (bytes as f64 / K * 10.0).round() / 10.0; + if kilo < K { format!("{kilo:.1}K") } else { format!("{:.1}M", bytes as f64 / (K * K)) } +} + +/// The bytes `doc` takes written as a JPEG with `settings`, as the save writes it; `None` when it +/// can't be encoded. +pub(super) fn encoded_size(doc: &Document, settings: &ExportSettings) -> Option { + let bytes = photocraft_io::export(doc, "size.jpg", &settings.io_options()).ok()?.bytes; + u64::try_from(bytes.len()).ok() +} + +/// What one size is for: the document as it is, and the encoder quality (1–100) and format. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) struct Key { + doc: DocId, + revision: u64, + quality: u8, + format: JpegFormat, +} + +/// Where a worker leaves its answer: `Some` once done (`Some(None)`: couldn't be encoded). +type Slot = Arc>>>; + +/// The readout between frames (kept in egui's temporary data). +#[derive(Default)] +pub(super) struct Readout { + /// The last size worked out, and what it is for. + shown: Option<(Key, Option)>, + /// The encode running, and what it is for. + running: Option<(Key, Slot)>, +} + +impl Readout { + /// The size to show for `key`. Unless `key`'s size is known or being worked out, starts the + /// job `make` returns, which calls `done` when it finishes; a change made while another + /// encode runs starts once that one is done. Until `key`'s size is known, the last size of + /// the same document as it is now shows (`None` if there is none). + pub(super) fn update(&mut self, key: Key, make: impl FnOnce() -> J, done: impl FnOnce() + Send + 'static) -> Option + where + J: FnOnce() -> Option + Send + 'static, + { + self.collect(); + if self.running.is_none() && !self.is_current(key) { + let slot = Slot::default(); + run(make(), Arc::clone(&slot), done); + self.running = Some((key, slot)); + // The web build encodes in the frame: its answer is already there. + self.collect(); + } + self.shown.filter(|(k, _)| k.doc == key.doc && k.revision == key.revision).and_then(|(_, size)| size) + } + + /// Whether the size shown is `key`'s. + pub(super) fn is_current(&self, key: Key) -> bool { + self.shown.is_some_and(|(k, _)| k == key) + } + + /// Takes a finished encode's answer. + fn collect(&mut self) { + let Some((key, slot)) = &self.running else { return }; + let Some(size) = slot.lock().unwrap_or_else(PoisonError::into_inner).take() else { return }; + self.shown = Some((*key, size)); + self.running = None; + } +} + +/// Runs `job` on a worker (in the web build, right here), leaves its answer in `slot`, then calls +/// `done`. A job that panics answers `None`. +fn run(job: impl FnOnce() -> Option + Send + 'static, slot: Slot, done: impl FnOnce() + Send + 'static) { + let answer = Arc::clone(&slot); + let work = move || { + let size = std::panic::catch_unwind(std::panic::AssertUnwindSafe(job)).unwrap_or(None); + *answer.lock().unwrap_or_else(PoisonError::into_inner) = Some(size); + done(); + }; + #[cfg(not(target_arch = "wasm32"))] + if std::thread::Builder::new().name("jpeg-options-size".into()).spawn(work).is_err() { + *slot.lock().unwrap_or_else(PoisonError::into_inner) = Some(None); + } + #[cfg(target_arch = "wasm32")] + { + let _ = slot; + work(); + } +} + +/// What the readout encodes: the waiting save's document, with the settings its save starts from. +/// Taken before the dialog is drawn, which borrows the prompt. +pub(super) struct Source { + doc: Arc, + revision: u64, + base: ExportSettings, + own_quality: Option, +} + +impl Source { + /// For the JPEG Options prompt waiting, if any. + pub(super) fn of(app: &PhotocraftApp) -> Option { + let p = app.jpeg_options.as_ref()?; + let st = app.session.documents().iter().find(|st| st.doc.id == p.doc)?; + Some(Source { doc: Arc::clone(&st.doc), revision: st.revision, base: app.save_settings(), own_quality: p.own_quality }) + } + + /// The encoder quality (1–100) OK writes for `quality` (0–12): the opened file's own when left + /// on its step, as `confirm` keeps it. + fn encoder_quality(&self, quality: u8) -> u8 { + let own = self.own_quality.filter(|&q| super::quality_from_percent(u32::from(q)) == quality); + own.unwrap_or_else(|| photocraft_engine::file_cmds::jpeg_quality(f64::from(quality))) + } + + /// The settings OK writes with for `quality` (0–12) and `format`. + fn settings(&self, quality: u8, format: JpegFormat) -> ExportSettings { + ExportSettings { jpeg_quality: Some(self.encoder_quality(quality)), jpeg_format: format, ..self.base.clone() } + } + + /// The readout for `quality` (0–12) and `format`: the size, or "—" while there is none. + fn text(&self, ctx: &egui::Context, quality: u8, format: JpegFormat) -> String { + if !estimable(self.doc.size) { + return "—".into(); + } + let key = Key { doc: self.doc.id, revision: self.revision, quality: self.encoder_quality(quality), format }; + let state = ctx.data_mut(|d| Arc::clone(d.get_temp_mut_or_default::>>(egui::Id::unique("jpeg-options-size")))); + let mut readout = state.lock().unwrap_or_else(PoisonError::into_inner); + let make = || { + let (doc, settings) = (Arc::clone(&self.doc), self.settings(quality, format)); + move || encoded_size(&doc, &settings) + }; + let repaint = ctx.clone(); + readout.update(key, make, move || repaint.request_repaint()).map_or_else(|| "—".into(), size_label) + } +} + +/// Draws the readout for the dialog's current `quality` (0–12) and `format`. +pub(super) fn show(ui: &mut egui::Ui, source: Option<&Source>, quality: u8, format: JpegFormat) { + let text = source.map_or_else(|| "—".into(), |s| s.text(ui.ctx(), quality, format)); + let weak = ui.visuals().weak_text_color(); + ui.label(egui::RichText::new(text).color(weak)).on_hover_text(tl!("Estimated file size")); +} + +#[cfg(test)] +mod tests; diff --git a/crates/ui-egui/src/dialogs/jpeg_options_ui/size_readout/tests.rs b/crates/ui-egui/src/dialogs/jpeg_options_ui/size_readout/tests.rs new file mode 100644 index 0000000000..bae9405f9e --- /dev/null +++ b/crates/ui-egui/src/dialogs/jpeg_options_ui/size_readout/tests.rs @@ -0,0 +1,154 @@ +use super::*; +use egui_kittest::kittest::Queryable; +use serde_json::json; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::time::{Duration, Instant}; + +/// A 96 × 64 RGB document of 2 × 2 noise and gradients: detail a JPEG's quality shows in its size. +fn detailed() -> Arc { + let mut doc = Document::with_background( + "x", + photocraft_doc::Size::new(96, 64), + photocraft_doc::ColorMode::Rgb, + photocraft_doc::SampleType::U8, + photocraft_doc::Color::WHITE, + ); + let id = doc.layers[0].id; + let surf = doc.layer_mut(id).unwrap().surface_mut().unwrap(); + let mut seed = 12345u32; + for y in (0..64).step_by(2) { + for x in (0..96).step_by(2) { + seed = seed.wrapping_mul(1664525).wrapping_add(1013904223); + let v = (seed >> 24) as f32 / 255.0; + surf.fill_rect(photocraft_geom::Rect::new(x, y, x + 2, y + 2), &[v, x as f32 / 96.0, y as f32 / 64.0, 1.0]); + } + } + Arc::new(doc) +} + +fn source(own_quality: Option) -> Source { + Source { doc: detailed(), revision: 1, base: ExportSettings::default(), own_quality } +} + +/// The readout: kilobytes with one decimal under a megabyte, then megabytes. +#[test] +fn sizes_read_in_kilobytes_then_megabytes() { + assert_eq!(size_label(0), "0.0K"); + assert_eq!(size_label(155_955), "152.3K"); + assert_eq!(size_label(1023 * 1024), "1023.0K"); + assert_eq!(size_label(1024 * 1024 - 1), "1.0M", "1024.0K would round up past a megabyte"); + assert_eq!(size_label(1024 * 1024), "1.0M"); + assert_eq!(size_label(3 * 1024 * 1024 / 2), "1.5M"); + assert_eq!(size_label(8_703_181), "8.3M"); +} + +/// The readout is the size of the file the save writes: Maximum (12) is larger than Low (0), and +/// each matches the bytes written with the settings OK would use. +#[test] +fn quality_12_reads_larger_than_quality_0() { + let src = source(None); + let (low, high) = (src.settings(0, JpegFormat::Baseline), src.settings(12, JpegFormat::Baseline)); + let size = |s: &ExportSettings| photocraft_io::export(&src.doc, "a.jpg", &s.io_options()).unwrap().bytes.len() as u64; + assert_eq!(encoded_size(&src.doc, &low), Some(size(&low))); + assert_eq!(encoded_size(&src.doc, &high), Some(size(&high))); + assert!(size(&high) > size(&low), "quality 12: {} bytes, quality 0: {} bytes", size(&high), size(&low)); + assert!(size(&high) > 2 * size(&low), "the quality is what is encoded, not a default"); +} + +/// Left on an opened JPEG's own quality, OK writes that quality exactly, so the readout encodes +/// it too; another step encodes the step's quality. +#[test] +fn the_files_own_quality_is_what_is_estimated() { + let src = source(Some(95)); + assert_eq!(src.settings(11, JpegFormat::Progressive).jpeg_quality, Some(95)); + assert_eq!(src.settings(11, JpegFormat::Progressive).jpeg_format, JpegFormat::Progressive); + assert_eq!(src.settings(10, JpegFormat::Baseline).jpeg_quality, Some(photocraft_engine::file_cmds::jpeg_quality(10.0))); +} + +/// Waits for `readout` to answer `key`, encoding with `job`. +fn answer(readout: &mut Readout, key: Key, job: impl Fn() -> Option + Send + Sync + Clone + 'static) -> Option { + let deadline = Instant::now() + Duration::from_secs(20); + loop { + if let Some(size) = readout.update(key, || job.clone(), || {}).filter(|_| readout.is_current(key)) { + return Some(size); + } + assert!(Instant::now() < deadline, "no size arrived for {key:?}"); + std::thread::sleep(Duration::from_millis(2)); + } +} + +/// A frame that changes nothing encodes nothing: the size is kept per settings. A change encodes +/// once, showing the earlier size until the new one is ready, and only one encode runs at a time. +#[test] +fn unchanged_settings_are_not_encoded_again() { + let encodes = Arc::new(AtomicU32::new(0)); + let counted = |size: u64| { + let encodes = encodes.clone(); + move || { + encodes.fetch_add(1, Ordering::SeqCst); + Some(size) + } + }; + let key = Key { doc: photocraft_doc::DocId(1), revision: 3, quality: 84, format: JpegFormat::Baseline }; + let mut readout = Readout::default(); + assert_eq!(answer(&mut readout, key, counted(1000)), Some(1000)); + for _ in 0..10 { + assert_eq!(readout.update(key, || counted(1), || {}), Some(1000)); + } + assert_eq!(encodes.load(Ordering::SeqCst), 1, "the same settings are encoded once"); + + let progressive = Key { format: JpegFormat::Progressive, ..key }; + let (gate, wait) = std::sync::mpsc::channel::<()>(); + let wait = Arc::new(Mutex::new(wait)); + let slow = { + let encodes = encodes.clone(); + move || { + let _ = wait.lock().unwrap().recv(); + encodes.fetch_add(1, Ordering::SeqCst); + Some(2000) + } + }; + assert_eq!(readout.update(progressive, || slow.clone(), || {}), Some(1000), "the earlier size shows meanwhile"); + let other = Key { quality: 92, ..key }; + assert_eq!(readout.update(other, || counted(3), || {}), Some(1000), "a second encode waits for the first"); + gate.send(()).unwrap(); + assert_eq!(answer(&mut readout, progressive, slow), Some(2000)); + assert_eq!(answer(&mut readout, key, counted(1)), Some(1), "another change encodes again"); + assert_eq!(encodes.load(Ordering::SeqCst), 3); + + let edited = Key { revision: 4, ..key }; + assert_eq!(readout.update(edited, || || None, || {}), None, "an edited document shows no size of the old one"); +} + +/// Documents larger than the limit are not encoded: the readout is a dash. +#[test] +fn large_documents_are_not_encoded() { + assert!(estimable(photocraft_doc::Size::new(6000, 4000)), "24 MP"); + assert!(!estimable(photocraft_doc::Size::new(10_000, 10_000)), "100 MP"); + assert!(!estimable(photocraft_doc::Size::new(u32::MAX, u32::MAX))); +} + +/// The dialog shows the size its settings write, next to its buttons. +#[test] +fn the_dialog_shows_the_size_it_will_write() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 64, "height": 48})).unwrap(); + app.sync_views(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(800.0, 600.0)).build_ui_state( + |ui, app| { + super::super::show(app, ui.ctx()); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + super::super::park(h.state_mut(), "p.jpg".into(), false, "file.saveAs").unwrap(); + let src = Source::of(h.state()).unwrap(); + let quality = h.state().jpeg_options.as_ref().unwrap().quality; + let expected = size_label(encoded_size(&src.doc, &src.settings(quality, JpegFormat::Baseline)).unwrap()); + let deadline = Instant::now() + Duration::from_secs(20); + while h.query_by_label(&expected).is_none() { + assert!(Instant::now() < deadline, "JPEG Options never showed {expected}"); + h.run_steps(1); + std::thread::sleep(Duration::from_millis(5)); + } +} diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index ad024ccd97..f0eb22b3f8 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -4235,3 +4235,6 @@ cameraRaw Dehaze Odstranění oparu Font size presets Předvolby velikosti písma Leading presets Předvolby prokladu Tracking presets Předvolby prostrkání + +# JPEG Options: the size the file will have (#7843) + Estimated file size Odhadovaná velikost souboru diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index c1dfc027ed..e9864e56ef 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3509,3 +3509,6 @@ curves Smooth Glätten Font size presets Schriftgrößen-Vorgaben Leading presets Zeilenabstand-Vorgaben Tracking presets Laufweiten-Vorgaben + +# JPEG Options: the size the file will have (#7843) + Estimated file size Geschätzte Dateigröße diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index b2b35dbbdc..847a064313 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -4188,3 +4188,6 @@ curves Smooth Εξομάλυνση Font size presets Προεπιλογές μεγέθους γραμματοσειράς Leading presets Προεπιλογές διάστιχου Tracking presets Προεπιλογές διαστήματος χαρακτήρων + +# JPEG Options: the size the file will have (#7843) + Estimated file size Εκτιμώμενο μέγεθος αρχείου diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index b42f3be02c..dfbacd8e26 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4447,3 +4447,6 @@ navigator Zoom Zoom Font size presets Valores predefinidos de tamaño de fuente Leading presets Valores predefinidos de interlineado Tracking presets Valores predefinidos de tracking + +# JPEG Options: the size the file will have (#7843) + Estimated file size Tamaño estimado del archivo diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 3bad30d474..b6e1c0218b 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4527,3 +4527,6 @@ shape Appearance Apparence Font size presets Tailles de police prédéfinies Leading presets Interlignages prédéfinis Tracking presets Approches prédéfinies + +# JPEG Options: the size the file will have (#7843) + Estimated file size Taille de fichier estimée diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 3d6ddab7cb..674e3074b3 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -563,3 +563,6 @@ cameraRaw Yellow पीला Target लक्ष्य Units इकाइयाँ Demosaic डिमोज़ेक + +# JPEG Options: the size the file will have (#7843) + Estimated file size अनुमानित फ़ाइल आकार diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index d632cb52f1..0a812b78ce 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4399,3 +4399,6 @@ cameraRaw Yellow Sárga Font size presets Betűméret-előbeállítások Leading presets Sorköz-előbeállítások Tracking presets Betűköz-előbeállítások + +# JPEG Options: the size the file will have (#7843) + Estimated file size Becsült fájlméret diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 771e04e80a..726a102eb8 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3507,3 +3507,6 @@ curves Smooth Haluskan Font size presets Preset ukuran font Leading presets Preset leading Tracking presets Preset tracking + +# JPEG Options: the size the file will have (#7843) + Estimated file size Perkiraan ukuran file diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index b1598ac237..3293d134d8 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4602,3 +4602,6 @@ shape Appearance Aspetto Font size presets Predefiniti dimensione font Leading presets Predefiniti interlinea Tracking presets Predefiniti spaziatura + +# JPEG Options: the size the file will have (#7843) + Estimated file size Dimensioni stimate del file diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 435813b0c7..eb53cf1fc7 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4164,3 +4164,6 @@ curves Smooth スムーズ Font size presets フォントサイズのプリセット Leading presets 行送りのプリセット Tracking presets トラッキングのプリセット + +# JPEG Options: the size the file will have (#7843) + Estimated file size 推定ファイルサイズ diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 30ab8cb165..a00a47bc7f 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3704,3 +3704,6 @@ curves Smooth 매끄럽게 Font size presets 글꼴 크기 사전 설정 Leading presets 행간 사전 설정 Tracking presets 자간 사전 설정 + +# JPEG Options: the size the file will have (#7843) + Estimated file size 예상 파일 크기 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index d03260ecaf..672bbb53e2 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -4245,3 +4245,6 @@ curves Smooth Vloeiend maken Font size presets Voorinstellingen lettergrootte Leading presets Voorinstellingen interlinie Tracking presets Voorinstellingen spatiëring + +# JPEG Options: the size the file will have (#7843) + Estimated file size Geschatte bestandsgrootte diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index ba41a4e34d..6f25b4f720 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -4105,3 +4105,6 @@ curves Smooth Wygładź Font size presets Ustawienia wstępne rozmiaru czcionki Leading presets Ustawienia wstępne interlinii Tracking presets Ustawienia wstępne światła + +# JPEG Options: the size the file will have (#7843) + Estimated file size Szacowany rozmiar pliku diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index f3335cd9c6..b028bb1e15 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -4415,3 +4415,6 @@ layerLabel No Color Sem cor Font size presets Predefinições de tamanho da fonte Leading presets Predefinições de entrelinha Tracking presets Predefinições de tracking + +# JPEG Options: the size the file will have (#7843) + Estimated file size Tamanho estimado do arquivo diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index b74c398312..8fef1f93b3 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4688,3 +4688,6 @@ shape Appearance Оформление Font size presets Наборы размеров шрифта Leading presets Наборы интерлиньяжа Tracking presets Наборы трекинга + +# JPEG Options: the size the file will have (#7843) + Estimated file size Примерный размер файла diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index a81eb06da2..5474de609a 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -4557,3 +4557,6 @@ shape Appearance Izgled Font size presets Unapred podešene veličine fonta Leading presets Unapred podešeni proredi Tracking presets Unapred podešeni razmaci slova + +# JPEG Options: the size the file will have (#7843) + Estimated file size Procenjena veličina datoteke diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index f5b9d13fd6..86442150cc 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -4213,3 +4213,6 @@ cameraData Lens Lens Font size presets Yazı tipi boyutu hazır ayarları Leading presets Satır aralığı hazır ayarları Tracking presets İzleme hazır ayarları + +# JPEG Options: the size the file will have (#7843) + Estimated file size Tahmini dosya boyutu diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 8d911b19a8..9b53ed3dbb 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4198,3 +4198,6 @@ curves Smooth Згладити Font size presets Набори розмірів шрифту Leading presets Набори інтерліньяжу Tracking presets Набори трекінгу + +# JPEG Options: the size the file will have (#7843) + Estimated file size Орієнтовний розмір файлу diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index f348662975..92eb9384cf 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3983,3 +3983,6 @@ curves Smooth Làm mượt Font size presets Cỡ chữ đặt sẵn Leading presets Giãn dòng đặt sẵn Tracking presets Khoảng cách chữ đặt sẵn + +# JPEG Options: the size the file will have (#7843) + Estimated file size Kích thước tệp ước tính diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index d9f437214a..c8c16cc972 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -3168,3 +3168,6 @@ curves Smooth 平滑 Font size presets 字号预设 Leading presets 行距预设 Tracking presets 字距调整预设 + +# JPEG Options: the size the file will have (#7843) + Estimated file size 预计文件大小 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 9d46dcb21e..1f71a9e471 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3124,3 +3124,6 @@ curves Smooth 平滑 Font size presets 字體大小預設 Leading presets 行距預設 Tracking presets 字距調整預設 + +# JPEG Options: the size the file will have (#7843) + Estimated file size 預估檔案大小 diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index f49fa461de..0058bb4aef 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-24 · **Change:** minor (PSD Filter Gallery: Halftone Pattern dot/circle/line settings survive PSD/PSB saves (#8940); Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency, #8877; layered TIFF writes the TySh text version and descriptor in PSD big-endian order while the header, matrix, warp and bounds keep container order, #9098; layered TIFF writes Photoshop's 14-byte global layer mask, so Photoshop opens the layers instead of only the composite, #9095; legacy PSD point text placed from the layout ink box after font resolution, #6094; `.atn`: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; `.asl` layer style libraries read and written, #1051; JPEG Options on File › Save, Save As and Save a Copy, FILE-215-19 partial (no Matte, progressive Scans or size preview); 32-bit Hue/Saturation unclipped and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; JPEG Options on Save As and Save a Copy, FILE-215-19; TIFF compression for scripted saves, `tiffCompression`, #5301; the Nikon D4 white level follows the sample depth (12 and 14 bit), #5978; Apply Layer Comp action mapping, #7101; Cineon write: 10-bit gray/RGB; animated WebP frames load into video layers, #1198; 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: 53 filters (39 more: Pinch, Polar Coordinates, Ripple, Spherize, Twirl, Wave, ZigZag, Invert, 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: smart objects keep Photoshop's Cylinder warp through re-renders and saves, #4112; 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; 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; Amiga IFF write: 24-plane ILBM, Save As `.iff`; PSD stores spot channels as Photoshop does, ink 0 = solid, and numbers them in 1053, #7415 split; TIFF writes and reads alpha and spot channels as extra samples with Photoshop's channel resources, PSD output unchanged, FILE-215-7, FILE-215-17's TIFF part, #7415; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-28 · **Change:** minor (JPEG Options shows the size the file will have, FILE-215-19 partial; PSD Filter Gallery: Halftone Pattern dot/circle/line settings survive PSD/PSB saves (#8940); Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency, #8877; layered TIFF writes the TySh text version and descriptor in PSD big-endian order while the header, matrix, warp and bounds keep container order, #9098; layered TIFF writes Photoshop's 14-byte global layer mask, so Photoshop opens the layers instead of only the composite, #9095; legacy PSD point text placed from the layout ink box after font resolution, #6094; `.atn`: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; `.asl` layer style libraries read and written, #1051; JPEG Options on File › Save, Save As and Save a Copy, FILE-215-19 partial (no Matte, progressive Scans or size preview); 32-bit Hue/Saturation unclipped and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; JPEG Options on Save As and Save a Copy, FILE-215-19; TIFF compression for scripted saves, `tiffCompression`, #5301; the Nikon D4 white level follows the sample depth (12 and 14 bit), #5978; Apply Layer Comp action mapping, #7101; Cineon write: 10-bit gray/RGB; animated WebP frames load into video layers, #1198; 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: 53 filters (39 more: Pinch, Polar Coordinates, Ripple, Spherize, Twirl, Wave, ZigZag, Invert, 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: smart objects keep Photoshop's Cylinder warp through re-renders and saves, #4112; 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; 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; Amiga IFF write: 24-plane ILBM, Save As `.iff`; PSD stores spot channels as Photoshop does, ink 0 = solid, and numbers them in 1053, #7415 split; TIFF writes and reads alpha and spot channels as extra samples with Photoshop's channel resources, PSD output unchanged, FILE-215-7, FILE-215-17's TIFF part, #7415; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency) · **Target:** Adobe Photoshop 2026 **PSD Filter Gallery (#8940, 2026-10-23):** Halftone Pattern maps its size, contrast and dot/circle/line pattern as editable settings on import and export, with the reference application's descriptor keys (`HlSz`, `Cntr`, `ScrT`: `ScrD`/`ScrC`/`ScrL`). PSD/PSB round trips pass at 8/16/32 bits. This does not claim pixel equivalence for the existing renderer. Unknown or malformed settings remain verbatim on import. @@ -27,7 +27,7 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity. | PSB | R/W | yes | yes | Auto-switches above 2 GB (REL-212-2); generated 20 MP, 150-layer, 2 GB PSD and 4.4 GB PSB fixtures opened, saved and re-opened by io tests/it/large_files.rs (FILE-216-2, 2026-10-13; the 4.4 GB PSB opens on Windows); > 4 GB from the app's File › Open on Windows: #375 | | Cloud PSD (.psdc) | R/W | no | no | Out of scope (Adobe cloud) | | TIFF | R/W | yes | yes | 8/16/32f, Gray/RGB/CMYK, BigTIFF, every IFD, banded parallel decode; layered TIFF both byte orders, including Photoshop mixed-order type blocks on read (#4758/#4771) and TySh text descriptors written in PSD order (#9098) and Photoshop's 14-byte `LMsk` block (#9095); LZW/ZIP for scripted saves by `tiffCompression`; undecodable type keeps saved pixels (FILE-215-7); alpha and spot channels as extra samples (at most 16) with Photoshop's names, ink colours and solidity, spot ink stored 0 = solid (#7415); no Save Transparency choice yet (channels are written as Photoshop does with it off) | -| JPEG | R/W | yes | yes | 8-bit Gray/RGB/CMYK, ICC/EXIF/XMP; Save keeps the opened file's quality (estimated from its quantisation tables, #2399); File › Save, Save As and Save a Copy ask JPEG Options: Quality 0–12, starting at the opened file's quality or else the Export preference, and Baseline ("Standard") / Baseline Optimized / Progressive; scripts pass `quality`/`jpegFormat`, the CLI `--jpeg-format` (FILE-215-19). Baseline Optimized fits the Huffman tables only at 4:4:4 (Gray, CMYK) or progressive: RGB is written 4:2:0, which zune-jpeg 0.5.15 decodes wrongly with fitted tables, as in Save for Web. No Matte, progressive Scans (3–5) or size preview | +| JPEG | R/W | yes | yes | 8-bit Gray/RGB/CMYK, ICC/EXIF/XMP; Save keeps the opened file's quality (estimated from its quantisation tables, #2399); File › Save, Save As and Save a Copy ask JPEG Options: Quality 0–12, starting at the opened file's quality or else the Export preference, and Baseline ("Standard") / Baseline Optimized / Progressive; scripts pass `quality`/`jpegFormat`, the CLI `--jpeg-format` (FILE-215-19). Baseline Optimized fits the Huffman tables only at 4:4:4 (Gray, CMYK) or progressive: RGB is written 4:2:0, which zune-jpeg 0.5.15 decodes wrongly with fitted tables, as in Save for Web. The dialog shows the size the file will have ("152.3K", "1.5M"), encoded in memory as OK would write it, off the UI thread, for documents up to 40 MP (4 MP on the web; larger ones read "—"). No Matte or progressive Scans (3–5) | | PNG | R/W | yes | yes | 8/16-bit, ICC/text; APNG first frame only; alpha channels on open (#2225); palette PNGs open in Indexed Color mode with their transparent entry (#4804); Save keeps a stereo PNG's `sTER` pairing (#6063) | | GIF | R/W | yes | yes | File › Open keeps the first frame; New Video Layer from File and Video Frames to Layers take every frame, with the timeline rate from the delays (#572). Per-frame delays and keeping the frames in `.pcraft` are not done | | BMP | R/W | yes | yes | 8-bit; palette BMPs open in Indexed Color mode and Indexed Color documents save as palette BMPs (no transparency) | @@ -144,6 +144,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | Date | Change | Summary | |---|---|---| +| 2026-10-28 | minor | JPEG Options shows the size the file will have (FILE-215-19 partial: no Matte or progressive Scans) | | 2026-10-24 | minor | Layered TIFF: the `LMsk` block in tag 37724 carries Photoshop's default global layer mask (14 bytes) instead of none; with an empty block Photoshop read only the composite (#9095). PSD output unchanged | | 2026-10-24 | minor | Layered TIFF writes the TySh text version and descriptor in PSD big-endian order while retaining container order for the block header, transform matrix, warp section and bounds (#9098); the mixed-order read path remains covered by #4758/#4771. | | 2026-10-23 | minor | PSD: spot channels are stored and read as Photoshop 27.11's PSDs store them (ink 0 = solid; all 7 of its spot PSDs store the 100/25/75/0 % columns as 0/191/64/255), and resource 1053 numbers the extra channels 3, 4, 5, … after 0 for the merged transparency. A PSD with spot channels whose 1057 is exactly what PhotoCraft 0.1.0–0.16.0 wrote, and with no 1053 or one kept from an opened Photoshop file that doesn't list every channel, is read as those versions stored it (straight past the channels that 1053 lists), so it keeps its ink. Multichannel is unchanged (#7415 split, part 4) | diff --git a/docs/gaps.md b/docs/gaps.md index 0c94561436..50fc10de83 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (G1: Halftone Pattern Filter Gallery settings survive PSD/PSB saves (#8940); cropped selection morphology and command mask conversion performance evidence added; G9: 12 of 181 preferences do nothing, as the scorecard prefs audit counts them; G6: Art History Brush landed as partial, TOOL-213-20, #1610, so no toolbar tool is missing; G4: five hard-round built-in presets use 5% spacing to remove stacked-tip outlines and reduce pressure-opacity bands, #8842; G9: Crop tool canvas menu, #6561; G2: Content-Aware Scale seam carving 1.65–3.6× faster with unchanged output, P28 row 3.3 → 1.6 s on a 4-core VM, #5236; G9: UI scale presets done, 105–120% steps, #3222; G14: lossy DNG, raw sidecars, Camera Raw Defaults live, Calibration and Auto, CR3 assessed as not clean-room feasible; G8: PSD type on a path opens on its path and saves back into the file's `Txt2`, #7962; G11: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; adjustment clipping stays visible in compact Properties, #5231; G9: Color panel modes, ramp and copy commands, the same in every theme, UI-217-17, #6000; G17: Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; G10: `.asl` style libraries landed, #1051; G8: area type (TYPE-218-10); G1: smart objects keep Photoshop's Cylinder warp, #4112; G8: paragraph direction buttons in the Paragraph panel and options bar, TYPE-218-2 partial; G1: 32-bit Hue/Saturation and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; G11: compound layer properties export as separate Photoshop Set events, rename last, #8304; G11: Batch and droplets take the result at the action's Save step, so actions ending in Save As and Close work; G11: `.atn` steps that switch to another open document import, play and save back, #7103; G14: the Nikon D4 white level follows the sample depth, so 12-bit D4 NEFs no longer open 2.6 stops dark, #5978; G14: a Nikon NEF's Adobe RGB preview is read as Adobe RGB (maker-note ColorSpace 0x001E), #5987; Apply Layer Comp action mapping, #7101; G10: JPEG Options dialog landed, FILE-215-19 partial (no Matte, progressive Scans or size preview); G10: Cineon write; G10: DPX read and write, #1049, Cineon and Amiga IFF read; Classical Object Finder improvement; learned segmentation remains open; G4: named, stateful section locks and visible keyboard focus in Brush Settings; G4: independent Brush Pose tilt-axis overrides, #3825 partial; G4: Transfer Mixer-only controls, #3825 partial; 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; G10: Amiga IFF write; G10: PSD spot channels in Photoshop's polarity, earlier PhotoCraft PSDs keep their ink, #7415 split; G10: TIFF alpha and spot channels written and read as extra samples, FILE-215-7 done, FILE-215-17's TIFF part, #7415) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (G10: JPEG Options shows the size the file will have; FILE-215-19 stays partial (Matte, Scans); G1: Halftone Pattern Filter Gallery settings survive PSD/PSB saves (#8940); cropped selection morphology and command mask conversion performance evidence added; G9: 12 of 181 preferences do nothing, as the scorecard prefs audit counts them; G6: Art History Brush landed as partial, TOOL-213-20, #1610, so no toolbar tool is missing; G4: five hard-round built-in presets use 5% spacing to remove stacked-tip outlines and reduce pressure-opacity bands, #8842; G9: Crop tool canvas menu, #6561; G2: Content-Aware Scale seam carving 1.65–3.6× faster with unchanged output, P28 row 3.3 → 1.6 s on a 4-core VM, #5236; G9: UI scale presets done, 105–120% steps, #3222; G14: lossy DNG, raw sidecars, Camera Raw Defaults live, Calibration and Auto, CR3 assessed as not clean-room feasible; G8: PSD type on a path opens on its path and saves back into the file's `Txt2`, #7962; G11: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; adjustment clipping stays visible in compact Properties, #5231; G9: Color panel modes, ramp and copy commands, the same in every theme, UI-217-17, #6000; G17: Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; G10: `.asl` style libraries landed, #1051; G8: area type (TYPE-218-10); G1: smart objects keep Photoshop's Cylinder warp, #4112; G8: paragraph direction buttons in the Paragraph panel and options bar, TYPE-218-2 partial; G1: 32-bit Hue/Saturation and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; G11: compound layer properties export as separate Photoshop Set events, rename last, #8304; G11: Batch and droplets take the result at the action's Save step, so actions ending in Save As and Close work; G11: `.atn` steps that switch to another open document import, play and save back, #7103; G14: the Nikon D4 white level follows the sample depth, so 12-bit D4 NEFs no longer open 2.6 stops dark, #5978; G14: a Nikon NEF's Adobe RGB preview is read as Adobe RGB (maker-note ColorSpace 0x001E), #5987; Apply Layer Comp action mapping, #7101; G10: JPEG Options dialog landed, FILE-215-19 partial (no Matte, progressive Scans or size preview); G10: Cineon write; G10: DPX read and write, #1049, Cineon and Amiga IFF read; Classical Object Finder improvement; learned segmentation remains open; G4: named, stateful section locks and visible keyboard focus in Brush Settings; G4: independent Brush Pose tilt-axis overrides, #3825 partial; G4: Transfer Mixer-only controls, #3825 partial; 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; G10: Amiga IFF write; G10: PSD spot channels in Photoshop's polarity, earlier PhotoCraft PSDs keep their ink, #7415 split; G10: TIFF alpha and spot channels written and read as extra samples, FILE-215-7 done, FILE-215-17's TIFF part, #7415) · **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 @@ -333,7 +333,7 @@ what an agent can't finish alone. ## File-format gaps ### G10. Missing and partial file formats -- **Missing:** JPEG Options' Matte, progressive Scans and size preview (FILE-215-19 partial), Photoshop PDF read and its vector/text/multi-page +- **Missing:** JPEG Options' Matte and progressive Scans (FILE-215-19 partial), 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), DICOM, EPS/DCS, Pixar, WBMP, MPO/JPS, PICT, Scitex CT, Photoshop @@ -396,6 +396,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-28 | minor | G10: JPEG Options shows the size the file will have ("152.3K"), worked out off the UI thread as Quality and Format Options change; FILE-215-19 stays partial (Matte, progressive Scans); no readiness percentage or estimate changed | | 2026-10-27 | minor | G9: 12 of 181 preferences do nothing, the scorecard's count with integration batch 9 (#9775) on main; no estimate changed | | 2026-10-26 | minor | Expand/Contract/Border distance work, mask reads and replacement cropped to nonzero bounds; bounded distances for radii ≤5; 36 MP exact-output comparisons improve 1.54–37.82× across eight cases; percentages, estimates and scorecard budgets unchanged | | 2026-10-25 | minor | G9: the count of preferences that do nothing is the scorecard's again, 12 of 178 (it still said 24 of 171, from 2026-10-15); no estimate changed | diff --git a/docs/scorecard.md b/docs/scorecard.md index b9f9bb0a1e..041edd9ee2 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -138,7 +138,7 @@ Corpus floors (`crates/io/tests/it/corpus.rs`): the enforced minimums; the corpu | FILE-215-16 | Advanced Blending (knockout, blend as group, mask hides effects) end to end | partial | [#215](https://github.com/storytold/photocraft/issues/215) | doc advanced.rs + .pcraft + PSD/layered TIFF (knko infx clbl tsly lmgm vmgm), CPU compose advanced.rs (GPU falls back), blendingOptions command + dialog; 7 psd-tools knockout oracles now pass; knockout-isolated-groups (0.45 % AA edges), advanced-blending, blend-and-clipping still differ | | FILE-215-17 | CMYK/RGB documents preview spot inks; spot plates export (TIFF extra samples, PDF) | partial | [#215](https://github.com/storytold/photocraft/issues/215) | TIFF: spot channels written and read as extra samples with DisplayInfo (1077) ink colour and solidity, ink stored 0 = solid (#7415). PSD: spot planes stored and read 0 = solid as Photoshop does, identifiers in 1053; PhotoCraft 0.1.0–0.16.0 PSDs (straight planes) keep their ink. Missing: spot ink preview in RGB/CMYK documents (compose multichannel.rs:1 previews Multichannel only) and PDF Separation | | FILE-215-18 | Save for Web per-slice settings remembered; Export As remembers and offers nearest scaling | done | [#215](https://github.com/storytold/photocraft/issues/215) | Save for Web settings survive a restart through the remembered dialog settings in prefs.dialogs (web_cmds::remember_web_settings stores them on a complete export, never the destination; file_ui.rs open_web seeds from the session's last export, else the preferences). export_dialog seeds Export As the same way (seed_remembered: format, quality, transparency, scale, resample, metadata, TGA bits) and confirm remembers them as used. Export As › Image Size › Resample offers Photoshop's six methods (Bicubic Automatic default, Nearest Neighbor included; export_preset_cmds::RESAMPLE, unknown values are errors) for the exported copy only; also layer.exportAs `resample` and export presets (#5838). Tests: export_dialog nearest_neighbor_doubles_each_pixel_into_an_exact_block (8/16-bit), resample_choices_are_checked, resample_is_remembered_and_a_bad_saved_value_falls_back; layer_menu_cmds export_layer_resamples_with_the_chosen_method; tests/it/export_as_scaling.rs export_as_resample_menu_writes_nearest_neighbor_blocks | -| FILE-215-19 | Save As JPEG opens a JPEG Options dialog (quality 0-12, format options) | partial | [#215](https://github.com/storytold/photocraft/issues/215) | jpeg_options_ui.rs: File > Save, Save As and Save a Copy to a JPEG ask for Quality 0-12 (Low/Medium/High/Maximum) and Baseline / Baseline Optimized / Progressive before writing, starting at an opened JPEG's own quality (file_encoding) or else the Export preference; scripts pass quality/jpegFormat (file.saveAs, file.saveACopy, app.save, CLI --jpeg-format) and recorded actions replay the answer (discard_ui/tests/jpeg_tests.rs, codecs roundtrip jpeg_format_options_round_trip). Baseline Optimized fits the Huffman tables only at 4:4:4 (Gray, CMYK) or progressive: RGB is written 4:2:0, which zune-jpeg 0.5.15 decodes wrongly with fitted tables, as in Save for Web, so RGB Baseline Optimized writes the same file as Baseline. Missing: Matte, progressive Scans (3-5) and the size preview | +| FILE-215-19 | Save As JPEG opens a JPEG Options dialog (quality 0-12, format options) | partial | [#215](https://github.com/storytold/photocraft/issues/215) | jpeg_options_ui.rs: File > Save, Save As and Save a Copy to a JPEG ask for Quality 0-12 (Low/Medium/High/Maximum) and Baseline / Baseline Optimized / Progressive before writing, starting at an opened JPEG's own quality (file_encoding) or else the Export preference; scripts pass quality/jpegFormat (file.saveAs, file.saveACopy, app.save, CLI --jpeg-format) and recorded actions replay the answer (discard_ui/tests/jpeg_tests.rs, codecs roundtrip jpeg_format_options_round_trip). Baseline Optimized fits the Huffman tables only at 4:4:4 (Gray, CMYK) or progressive: RGB is written 4:2:0, which zune-jpeg 0.5.15 decodes wrongly with fitted tables, as in Save for Web, so RGB Baseline Optimized writes the same file as Baseline. The dialog shows the size the file will have (152.3K, 1.5M), encoded in memory with its settings off the UI thread, up to 40 MP (jpeg_options_ui/size_readout.rs). Missing: Matte and progressive Scans (3-5) | | FILE-215-20 | Artboard tool with handles and + buttons, Duplicate Artboard beside, vector PDF pages | partial | [#215](https://github.com/storytold/photocraft/issues/215) | artboard_tool.rs: Artboard tool draws, selects, moves and resizes boards by their handles (⇧ keeps proportions), its + buttons add a board or (⌥) a copy beside the selected one (#3033); Duplicate Artboard places the copy beside (#1584); raster PDF pages only | | FILE-215-21 | Local libraries of colours, patterns, images and layers, as linked items or copies | missing | [#215](https://github.com/storytold/photocraft/issues/215) | Only preset libraries (preset_store.rs) | | FILE-216-1 | Every corpus fetched and enforced in CI, cached by pin | done | [#216](https://github.com/storytold/photocraft/issues/216) | ci.yml has a corpus job (name: corpus tests) on every PR and on main: the corpora are pinned in xtask/src/corpus_pins.rs, sha256-verified after both a cache restore and a download, and a missing corpus fails the tests. The cache key follows the pins, so it is cached by pin as this row asks (#190) | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 26b95ea479..7ac4214600 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # PhotoCraft vs Adobe Photoshop: target-app parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-26 · **Change:** minor (cropped selection morphology and command mask conversion performance evidence added; 12 of 178 preferences do nothing, from the scorecard; the Camera Raw row names per-camera colour for non-DNG cameras as missing, not the Calibration panel, which landed 2026-10-13; Content-Aware Scale measurements added, #5236; Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; area type, TYPE-218-10 partial, Type scorecard 5 / 5 / 0; compound layer properties export as separate Photoshop Set events, #8304; JPEG Options dialog landed, FILE-215-19 partial; Print › Functions › Bleed through `file.print`; Content-Aware Move live preview for every selection size, TOOL-213-4 done, #6680; retouching evidence: Pattern Stamp and Background Eraser draw live, only the Mixer Brush is left of TOOL-213-7; fill and adjustment layers copy and paste whole, UI-217-23 partial, #2344; shape layers take Density and Feather, TOOL-214-12 done, #5846; on-canvas corner radius widgets, TOOL-214-12; `.atn` import maps 112 events; Free Transform H/V skew fields; stale rows corrected against `main` and the scorecard, estimates unchanged; Photoshop Actions `.atn` load and save; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment; stale missing-tool rows corrected: Color Replacement and Red Eye are live toolbar tools, Frame landed; percentages unchanged; PSD spot plates in Photoshop's polarity, #7415 split; TIFF spot plates, FILE-215-17's TIFF part, #7415) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-28 · **Change:** minor (JPEG Options shows the size the file will have, FILE-215-19 partial; percentages unchanged; cropped selection morphology and command mask conversion performance evidence added; 12 of 178 preferences do nothing, from the scorecard; the Camera Raw row names per-camera colour for non-DNG cameras as missing, not the Calibration panel, which landed 2026-10-13; Content-Aware Scale measurements added, #5236; Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; area type, TYPE-218-10 partial, Type scorecard 5 / 5 / 0; compound layer properties export as separate Photoshop Set events, #8304; JPEG Options dialog landed, FILE-215-19 partial; Print › Functions › Bleed through `file.print`; Content-Aware Move live preview for every selection size, TOOL-213-4 done, #6680; retouching evidence: Pattern Stamp and Background Eraser draw live, only the Mixer Brush is left of TOOL-213-7; fill and adjustment layers copy and paste whole, UI-217-23 partial, #2344; shape layers take Density and Feather, TOOL-214-12 done, #5846; on-canvas corner radius widgets, TOOL-214-12; `.atn` import maps 112 events; Free Transform H/V skew fields; stale rows corrected against `main` and the scorecard, estimates unchanged; Photoshop Actions `.atn` load and save; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment; stale missing-tool rows corrected: Color Replacement and Red Eye are live toolbar tools, Frame landed; percentages unchanged; PSD spot plates in Photoshop's polarity, #7415 split; TIFF spot plates, FILE-215-17's TIFF part, #7415) · **Target:** Adobe Photoshop 2026 The authoritative answer to "how close is PhotoCraft to Photoshop, and how much work is left". The one-page summary is [`ROADMAP.md`](../ROADMAP.md); the ranked work list is [`gaps.md`](gaps.md); @@ -179,7 +179,7 @@ parity for the area and are already contained in the dimension rows above. | Camera Raw and computational photo (Photomerge, HDR, lens) | 45% | 120–200 | Raw for DNG/CR2/NEF/ARW/RW2/ORF/RAF(uncompressed)/PEF; no CR3; ACR dialog partial (no masking; no per-camera colour for non-DNG cameras); generic lens profiles only | | Automation, actions, batch, scripting | 45% | 120–200 | CLI/MCP/control channel ahead of Photoshop; Actions panel and recording not document-independent (AUTO-219-1/2); `.atn` load/save since 2026-10-12 (112 events mapped, the rest kept verbatim); compound layer properties export as separate Set events with rename last (#8304, 2026-10-21; independent Photoshop replay pending); no ExtendScript/UXP | | File I/O | 60% | 300–500 | See [file-format-parity.md](file-format-parity.md) | -| Print, export, Save for Web | 50% | 60–100 | JPEG Options without Matte, progressive Scans or size preview (FILE-215-19), no Photoshop PDF, no separations, Save for Web settings not remembered | +| Print, export, Save for Web | 50% | 60–100 | JPEG Options without Matte or progressive Scans (FILE-215-19), no Photoshop PDF, no separations, Save for Web settings not remembered | | Video / timeline, data-driven graphics | 30% | 100–200 | Menu items live; frame model persists in `.pcraft`; no PSD video-layer mapping | | Workspace, panels, preferences | 40% | 150–250 | 12 of 178 prefs do nothing (scorecard, 2026-10-25); no floating/OS-window panel groups; Libraries, Comments, Contextual Task Bar missing | | AI / generative | 10% | 300–600 | See the dimension row | @@ -580,6 +580,7 @@ See [method, raw measurements and reproduction](five-algorithm-performance.md). | Date | Change | Summary | |---|---|---| +| 2026-10-28 | minor | JPEG Options shows the size the file will have (FILE-215-19 partial: no Matte or progressive Scans); percentages unchanged | | 2026-10-26 | minor | Expand/Contract/Border distance work, mask reads and replacement cropped to nonzero bounds; bounded distances for radii ≤5; 36 MP exact-output comparisons improve 1.54–37.82× across eight cases; percentages, estimates and scorecard budgets unchanged | | 2026-10-25 | minor | UI / UX and Workspace rows: 12 of 178 preferences do nothing, the scorecard's count. Camera Raw row: the Calibration panel landed on 2026-10-13 (#5128); what is missing is per-camera colour for non-DNG cameras ([gaps.md](gaps.md) G14). Percentages and estimates unchanged | | 2026-10-23 | minor | Colour management and modes: PSD spot plates are stored as Photoshop stores them (ink 0 = solid), and PSDs from earlier PhotoCraft versions keep their ink (#7415 split, part 4); estimates unchanged | diff --git a/scorecard/file_compat.toml b/scorecard/file_compat.toml index 0eeb4c1c54..1cf8af7cf3 100644 --- a/scorecard/file_compat.toml +++ b/scorecard/file_compat.toml @@ -136,7 +136,7 @@ id = "FILE-215-19" target = "Save As JPEG opens a JPEG Options dialog (quality 0-12, format options)" status = "partial" issue = 215 -note = "jpeg_options_ui.rs: File > Save, Save As and Save a Copy to a JPEG ask for Quality 0-12 (Low/Medium/High/Maximum) and Baseline / Baseline Optimized / Progressive before writing, starting at an opened JPEG's own quality (file_encoding) or else the Export preference; scripts pass quality/jpegFormat (file.saveAs, file.saveACopy, app.save, CLI --jpeg-format) and recorded actions replay the answer (discard_ui/tests/jpeg_tests.rs, codecs roundtrip jpeg_format_options_round_trip). Baseline Optimized fits the Huffman tables only at 4:4:4 (Gray, CMYK) or progressive: RGB is written 4:2:0, which zune-jpeg 0.5.15 decodes wrongly with fitted tables, as in Save for Web, so RGB Baseline Optimized writes the same file as Baseline. Missing: Matte, progressive Scans (3-5) and the size preview" +note = "jpeg_options_ui.rs: File > Save, Save As and Save a Copy to a JPEG ask for Quality 0-12 (Low/Medium/High/Maximum) and Baseline / Baseline Optimized / Progressive before writing, starting at an opened JPEG's own quality (file_encoding) or else the Export preference; scripts pass quality/jpegFormat (file.saveAs, file.saveACopy, app.save, CLI --jpeg-format) and recorded actions replay the answer (discard_ui/tests/jpeg_tests.rs, codecs roundtrip jpeg_format_options_round_trip). Baseline Optimized fits the Huffman tables only at 4:4:4 (Gray, CMYK) or progressive: RGB is written 4:2:0, which zune-jpeg 0.5.15 decodes wrongly with fitted tables, as in Save for Web, so RGB Baseline Optimized writes the same file as Baseline. The dialog shows the size the file will have (152.3K, 1.5M), encoded in memory with its settings off the UI thread, up to 40 MP (jpeg_options_ui/size_readout.rs). Missing: Matte and progressive Scans (3-5)" [[item]] id = "FILE-215-20" From c1b2670f60f158a6277a26fc5e078a50efadd700 Mon Sep 17 00:00:00 2001 From: ashokram-si <347905169+ashokram-si@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:32:32 +0530 Subject: [PATCH 02/31] contributors: add my name for the About window --- contributors/people.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index ac36371f38..25ca405ef1 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -282,3 +282,6 @@ sync_github_name = false [people.jasallen] real_name = "Jason DeBoever" sync_github_name = true + +[people.ashokram-si] +real_name = "Ashok Ram" From 9a9eaa786009b1fce755fb5c3b3e125297da0484 Mon Sep 17 00:00:00 2001 From: ashokram-si <347905169+ashokram-si@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:22:53 +0530 Subject: [PATCH 03/31] JPEG Options size: keep recent sizes, mark a pending one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up for the size readout: - The last 8 worked-out sizes are kept by their full settings, so going back to a quality or format already tried shows its size at once instead of encoding the document again. - While the size for new settings is worked out, the last size of the same document stays up marked as pending ("152.3K…"), or "…" when there is none yet, so it is never read as the new settings' size. - The book, parity, gaps and scorecard lines say the estimate runs on a worker thread on desktop and within the frame on the web. --- book/src/formats/raster-formats.md | 2 +- .../dialogs/jpeg_options_ui/size_readout.rs | 73 ++++++++++++++----- .../jpeg_options_ui/size_readout/tests.rs | 54 ++++++++++++-- docs/file-format-parity.md | 2 +- docs/gaps.md | 2 +- docs/scorecard.md | 2 +- scorecard/file_compat.toml | 2 +- 7 files changed, 106 insertions(+), 31 deletions(-) diff --git a/book/src/formats/raster-formats.md b/book/src/formats/raster-formats.md index 0f6e119968..c5501f0b1f 100644 --- a/book/src/formats/raster-formats.md +++ b/book/src/formats/raster-formats.md @@ -19,7 +19,7 @@ Every route that writes a document to a flat format (Save As, the CLI, MCP `doc_ - Formats without transparency (JPEG, Radiance HDR) get the image composited over white, as Export As and Quick Export do; in CMYK, white is no ink. - Formats that can't embed an ICC profile (GIF, BMP, TGA, QOI, ICO, Netpbm/PFM) get RGB documents in another profile (linear light, Display P3, …) converted to sRGB through the colour engine (perceptual intent), since an untagged file is read as sRGB. OpenEXR and Radiance HDR get linear sRGB instead (linear gray for a grayscale OpenEXR), which is how they are tagged when opened. - TGA, as Photoshop's Targa: an RGB Targa is 24 or 32 bits per pixel. Save As and Save a Copy ask (Targa Options, starting at 32 with an alpha channel, else 24); Export As has a Resolution choice (Auto, 24 or 32 bits/pixel); scripts pass `tgaBits` (`--tga-bits` on the CLI). At 32 bits the document's alpha channel is the file's alpha when it has exactly one (spot channels don't count; with several, none is used), and the colours are written exactly, never premultiplied or cleared where the alpha is 0, so a game texture's mask and its colours both survive. Without a usable alpha channel the layers' transparency is the alpha (opaque without any). 24 bits write no alpha, compositing transparency over white. Without a choice: 32 with an alpha channel or transparency, else 24. A 32-bit Targa opens as an opaque Background with its alpha as "Alpha 1", so it round-trips. Grayscale Targas are 8-bit. -- JPEG, as Photoshop's JPEG Options: File › Save, Save As and Save a Copy ask for the Quality on Photoshop's 0–12 scale (with its Low, Medium, High and Maximum presets) and a Format Option: Baseline ("Standard"), Baseline Optimized (Huffman tables fitted to the image) or Progressive. The quality starts at the opened JPEG's own quality, else at the Export preference's JPEG quality; left there, an opened JPEG is written at exactly its own quality. Scripts pass `"quality": 0..12` and `"jpegFormat": "baseline" | "optimized" | "progressive"` (on the CLI, `--quality` on the encoder's 1–100 scale and `--jpeg-format`) and aren't asked; recorded actions replay the answer. The scale maps onto the encoder's 1–100 as `round(q / 12 × 99 + 1)`. RGB JPEGs are written 4:2:0, where Baseline Optimized keeps the standard tables for now (Gray and CMYK, written 4:4:4, get fitted ones). Below the Format Options the dialog shows how big the file will be ("152.3K", "1.5M"), so the quality can be chosen with the size in view; it is worked out in the background as the options change, for documents up to 40 megapixels (4 on the web), and reads "—" for larger ones. Matte and the progressive scan count (Scans 3–5) aren't offered. +- JPEG, as Photoshop's JPEG Options: File › Save, Save As and Save a Copy ask for the Quality on Photoshop's 0–12 scale (with its Low, Medium, High and Maximum presets) and a Format Option: Baseline ("Standard"), Baseline Optimized (Huffman tables fitted to the image) or Progressive. The quality starts at the opened JPEG's own quality, else at the Export preference's JPEG quality; left there, an opened JPEG is written at exactly its own quality. Scripts pass `"quality": 0..12` and `"jpegFormat": "baseline" | "optimized" | "progressive"` (on the CLI, `--quality` on the encoder's 1–100 scale and `--jpeg-format`) and aren't asked; recorded actions replay the answer. The scale maps onto the encoder's 1–100 as `round(q / 12 × 99 + 1)`. RGB JPEGs are written 4:2:0, where Baseline Optimized keeps the standard tables for now (Gray and CMYK, written 4:4:4, get fitted ones). Below the Format Options the dialog shows how big the file will be ("152.3K", "1.5M"), so the quality can be chosen with the size in view; it is worked out as the options change, in the background in the desktop app and within the frame in the web build, for documents up to 40 megapixels (4 on the web), and reads "—" for larger ones. Matte and the progressive scan count (Scans 3–5) aren't offered. - DDS, for game and 3D textures: Save As and Save a Copy ask (DDS Options) for the compression (None, uncompressed and lossless; BC1/DXT1, whose alpha is on or off; BC2/DXT3; BC3/DXT5) and whether to generate mipmaps (the full chain down to 1×1, each level a box filter of the one above). Scripts pass `ddsCompression` (`none`, `bc1`, `bc2`, `bc3`) and `ddsMipmaps` (`--dds-compression`, `--dds-mipmaps` on the CLI). Opening one reads its top mip level. Flat TIFF and JPEG exports of composited CMYK documents convert the RGB composite back through diff --git a/crates/ui-egui/src/dialogs/jpeg_options_ui/size_readout.rs b/crates/ui-egui/src/dialogs/jpeg_options_ui/size_readout.rs index 30a96af377..46346075fd 100644 --- a/crates/ui-egui/src/dialogs/jpeg_options_ui/size_readout.rs +++ b/crates/ui-egui/src/dialogs/jpeg_options_ui/size_readout.rs @@ -5,9 +5,12 @@ //! //! The encode runs on a worker thread in the native app, so the dialog never waits for it; the web //! build has no threads and encodes in the frame, so its limit is lower. One encode runs at a time, -//! and its answer is kept for its settings: a frame that changes nothing encodes nothing, and -//! while a new size is worked out the last one for the same document stays up. Documents over -//! [`MAX_PX`] aren't encoded and read "—". +//! and the last few answers are kept for their settings: a frame that changes nothing encodes +//! nothing, and going back to settings already worked out shows their size at once. While a new +//! size is worked out, the last one for the same document stays up marked as pending ("152.3K…"). +//! Documents over [`MAX_PX`] aren't encoded and read "—". + +use std::collections::VecDeque; use std::sync::{Arc, Mutex, PoisonError}; @@ -53,49 +56,83 @@ pub(super) struct Key { format: JpegFormat, } +/// How many worked-out sizes are kept, so stepping between a few qualities and formats doesn't +/// encode again. +const KEPT: usize = 8; + +/// What the readout says for one set of settings. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum Estimate { + /// The size for these settings (`None`: they couldn't be encoded). + Known(Option), + /// Still being worked out, with the last size of the same document as it is now, if any. + Pending(Option), +} + +impl Estimate { + /// "152.3K" when known, "152.3K…" while the last size stands in, "…" with none yet, and "—" + /// when the settings couldn't be encoded. + pub(super) fn label(self) -> String { + match self { + Estimate::Known(Some(bytes)) => size_label(bytes), + Estimate::Known(None) => "—".into(), + Estimate::Pending(Some(bytes)) => format!("{}…", size_label(bytes)), + Estimate::Pending(None) => "…".into(), + } + } +} + /// Where a worker leaves its answer: `Some` once done (`Some(None)`: couldn't be encoded). type Slot = Arc>>>; /// The readout between frames (kept in egui's temporary data). #[derive(Default)] pub(super) struct Readout { - /// The last size worked out, and what it is for. - shown: Option<(Key, Option)>, + /// The last [`KEPT`] sizes worked out and what each is for, newest last. + known: VecDeque<(Key, Option)>, /// The encode running, and what it is for. running: Option<(Key, Slot)>, } impl Readout { - /// The size to show for `key`. Unless `key`'s size is known or being worked out, starts the - /// job `make` returns, which calls `done` when it finishes; a change made while another - /// encode runs starts once that one is done. Until `key`'s size is known, the last size of - /// the same document as it is now shows (`None` if there is none). - pub(super) fn update(&mut self, key: Key, make: impl FnOnce() -> J, done: impl FnOnce() + Send + 'static) -> Option + /// What to show for `key`. Unless `key`'s size is known or being worked out, starts the job + /// `make` returns, which calls `done` when it finishes; a change made while another encode + /// runs starts once that one is done. + pub(super) fn update(&mut self, key: Key, make: impl FnOnce() -> J, done: impl FnOnce() + Send + 'static) -> Estimate where J: FnOnce() -> Option + Send + 'static, { self.collect(); - if self.running.is_none() && !self.is_current(key) { + if self.running.is_none() && self.size_of(key).is_none() { let slot = Slot::default(); run(make(), Arc::clone(&slot), done); self.running = Some((key, slot)); // The web build encodes in the frame: its answer is already there. self.collect(); } - self.shown.filter(|(k, _)| k.doc == key.doc && k.revision == key.revision).and_then(|(_, size)| size) + if let Some(size) = self.size_of(key) { + return Estimate::Known(size); + } + let same_doc = self.known.iter().rev().find(|(k, _)| k.doc == key.doc && k.revision == key.revision); + Estimate::Pending(same_doc.and_then(|&(_, size)| size)) } - /// Whether the size shown is `key`'s. - pub(super) fn is_current(&self, key: Key) -> bool { - self.shown.is_some_and(|(k, _)| k == key) + /// `key`'s size if it has been worked out (`Some(None)`: it couldn't be). + fn size_of(&self, key: Key) -> Option> { + self.known.iter().find(|(k, _)| *k == key).map(|&(_, size)| size) } /// Takes a finished encode's answer. fn collect(&mut self) { let Some((key, slot)) = &self.running else { return }; let Some(size) = slot.lock().unwrap_or_else(PoisonError::into_inner).take() else { return }; - self.shown = Some((*key, size)); + let key = *key; self.running = None; + self.known.retain(|(k, _)| *k != key); + if self.known.len() == KEPT { + self.known.pop_front(); + } + self.known.push_back((key, size)); } } @@ -148,7 +185,7 @@ impl Source { ExportSettings { jpeg_quality: Some(self.encoder_quality(quality)), jpeg_format: format, ..self.base.clone() } } - /// The readout for `quality` (0–12) and `format`: the size, or "—" while there is none. + /// The readout for `quality` (0–12) and `format` (see [`Estimate::label`]). fn text(&self, ctx: &egui::Context, quality: u8, format: JpegFormat) -> String { if !estimable(self.doc.size) { return "—".into(); @@ -161,7 +198,7 @@ impl Source { move || encoded_size(&doc, &settings) }; let repaint = ctx.clone(); - readout.update(key, make, move || repaint.request_repaint()).map_or_else(|| "—".into(), size_label) + readout.update(key, make, move || repaint.request_repaint()).label() } } diff --git a/crates/ui-egui/src/dialogs/jpeg_options_ui/size_readout/tests.rs b/crates/ui-egui/src/dialogs/jpeg_options_ui/size_readout/tests.rs index bae9405f9e..f5cce74ce9 100644 --- a/crates/ui-egui/src/dialogs/jpeg_options_ui/size_readout/tests.rs +++ b/crates/ui-egui/src/dialogs/jpeg_options_ui/size_readout/tests.rs @@ -69,8 +69,8 @@ fn the_files_own_quality_is_what_is_estimated() { fn answer(readout: &mut Readout, key: Key, job: impl Fn() -> Option + Send + Sync + Clone + 'static) -> Option { let deadline = Instant::now() + Duration::from_secs(20); loop { - if let Some(size) = readout.update(key, || job.clone(), || {}).filter(|_| readout.is_current(key)) { - return Some(size); + if let Estimate::Known(size) = readout.update(key, || job.clone(), || {}) { + return size; } assert!(Instant::now() < deadline, "no size arrived for {key:?}"); std::thread::sleep(Duration::from_millis(2)); @@ -78,7 +78,8 @@ fn answer(readout: &mut Readout, key: Key, job: impl Fn() -> Option + Send } /// A frame that changes nothing encodes nothing: the size is kept per settings. A change encodes -/// once, showing the earlier size until the new one is ready, and only one encode runs at a time. +/// once, showing the earlier size marked as pending until the new one is ready, and only one +/// encode runs at a time. #[test] fn unchanged_settings_are_not_encoded_again() { let encodes = Arc::new(AtomicU32::new(0)); @@ -93,7 +94,7 @@ fn unchanged_settings_are_not_encoded_again() { let mut readout = Readout::default(); assert_eq!(answer(&mut readout, key, counted(1000)), Some(1000)); for _ in 0..10 { - assert_eq!(readout.update(key, || counted(1), || {}), Some(1000)); + assert_eq!(readout.update(key, || counted(1), || {}), Estimate::Known(Some(1000))); } assert_eq!(encodes.load(Ordering::SeqCst), 1, "the same settings are encoded once"); @@ -108,16 +109,53 @@ fn unchanged_settings_are_not_encoded_again() { Some(2000) } }; - assert_eq!(readout.update(progressive, || slow.clone(), || {}), Some(1000), "the earlier size shows meanwhile"); + assert_eq!(readout.update(progressive, || slow.clone(), || {}), Estimate::Pending(Some(1000)), "the earlier size stands in, pending"); let other = Key { quality: 92, ..key }; - assert_eq!(readout.update(other, || counted(3), || {}), Some(1000), "a second encode waits for the first"); + assert_eq!(readout.update(other, || counted(3), || {}), Estimate::Pending(Some(1000)), "a second encode waits for the first"); gate.send(()).unwrap(); assert_eq!(answer(&mut readout, progressive, slow), Some(2000)); - assert_eq!(answer(&mut readout, key, counted(1)), Some(1), "another change encodes again"); + assert_eq!(answer(&mut readout, other, counted(3)), Some(3), "the waiting change encodes next"); assert_eq!(encodes.load(Ordering::SeqCst), 3); let edited = Key { revision: 4, ..key }; - assert_eq!(readout.update(edited, || || None, || {}), None, "an edited document shows no size of the old one"); + assert_eq!(readout.update(edited, || || None, || {}), Estimate::Pending(None), "an edited document shows no size of the old one"); +} + +/// Going back to settings already worked out shows their size at once, without encoding again; +/// only the last [`KEPT`] are kept. +#[test] +fn earlier_settings_are_not_encoded_again() { + let encodes = Arc::new(AtomicU32::new(0)); + let counted = |size: u64| { + let encodes = encodes.clone(); + move || { + encodes.fetch_add(1, Ordering::SeqCst); + Some(size) + } + }; + let at = |quality: u8| Key { doc: photocraft_doc::DocId(1), revision: 3, quality, format: JpegFormat::Baseline }; + let mut readout = Readout::default(); + assert_eq!(answer(&mut readout, at(84), counted(1000)), Some(1000)); + assert_eq!(answer(&mut readout, at(92), counted(2000)), Some(2000)); + assert_eq!(readout.update(at(84), || counted(1), || {}), Estimate::Known(Some(1000)), "back to 84: its size, at once"); + assert_eq!(readout.update(at(92), || counted(1), || {}), Estimate::Known(Some(2000))); + assert_eq!(encodes.load(Ordering::SeqCst), 2); + + for q in 1..=KEPT as u8 { + assert_eq!(answer(&mut readout, at(q), counted(u64::from(q))), Some(u64::from(q))); + } + assert_eq!(encodes.load(Ordering::SeqCst), 2 + KEPT as u32); + assert_eq!(answer(&mut readout, at(84), counted(1000)), Some(1000), "the oldest sizes make room"); + assert_eq!(encodes.load(Ordering::SeqCst), 3 + KEPT as u32); +} + +/// The readout marks a size that stands in for one still being worked out. +#[test] +fn a_pending_size_is_marked() { + assert_eq!(Estimate::Known(Some(155_955)).label(), "152.3K"); + assert_eq!(Estimate::Pending(Some(155_955)).label(), "152.3K…"); + assert_eq!(Estimate::Pending(None).label(), "…"); + assert_eq!(Estimate::Known(None).label(), "—"); } /// Documents larger than the limit are not encoded: the readout is a dash. diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 0058bb4aef..5f03fd50c9 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -27,7 +27,7 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity. | PSB | R/W | yes | yes | Auto-switches above 2 GB (REL-212-2); generated 20 MP, 150-layer, 2 GB PSD and 4.4 GB PSB fixtures opened, saved and re-opened by io tests/it/large_files.rs (FILE-216-2, 2026-10-13; the 4.4 GB PSB opens on Windows); > 4 GB from the app's File › Open on Windows: #375 | | Cloud PSD (.psdc) | R/W | no | no | Out of scope (Adobe cloud) | | TIFF | R/W | yes | yes | 8/16/32f, Gray/RGB/CMYK, BigTIFF, every IFD, banded parallel decode; layered TIFF both byte orders, including Photoshop mixed-order type blocks on read (#4758/#4771) and TySh text descriptors written in PSD order (#9098) and Photoshop's 14-byte `LMsk` block (#9095); LZW/ZIP for scripted saves by `tiffCompression`; undecodable type keeps saved pixels (FILE-215-7); alpha and spot channels as extra samples (at most 16) with Photoshop's names, ink colours and solidity, spot ink stored 0 = solid (#7415); no Save Transparency choice yet (channels are written as Photoshop does with it off) | -| JPEG | R/W | yes | yes | 8-bit Gray/RGB/CMYK, ICC/EXIF/XMP; Save keeps the opened file's quality (estimated from its quantisation tables, #2399); File › Save, Save As and Save a Copy ask JPEG Options: Quality 0–12, starting at the opened file's quality or else the Export preference, and Baseline ("Standard") / Baseline Optimized / Progressive; scripts pass `quality`/`jpegFormat`, the CLI `--jpeg-format` (FILE-215-19). Baseline Optimized fits the Huffman tables only at 4:4:4 (Gray, CMYK) or progressive: RGB is written 4:2:0, which zune-jpeg 0.5.15 decodes wrongly with fitted tables, as in Save for Web. The dialog shows the size the file will have ("152.3K", "1.5M"), encoded in memory as OK would write it, off the UI thread, for documents up to 40 MP (4 MP on the web; larger ones read "—"). No Matte or progressive Scans (3–5) | +| JPEG | R/W | yes | yes | 8-bit Gray/RGB/CMYK, ICC/EXIF/XMP; Save keeps the opened file's quality (estimated from its quantisation tables, #2399); File › Save, Save As and Save a Copy ask JPEG Options: Quality 0–12, starting at the opened file's quality or else the Export preference, and Baseline ("Standard") / Baseline Optimized / Progressive; scripts pass `quality`/`jpegFormat`, the CLI `--jpeg-format` (FILE-215-19). Baseline Optimized fits the Huffman tables only at 4:4:4 (Gray, CMYK) or progressive: RGB is written 4:2:0, which zune-jpeg 0.5.15 decodes wrongly with fitted tables, as in Save for Web. The dialog shows the size the file will have ("152.3K", "1.5M"), encoded in memory as OK would write it, on a worker thread in the desktop app and in the frame on the web, for documents up to 40 MP (4 MP on the web; larger ones read "—"). No Matte or progressive Scans (3–5) | | PNG | R/W | yes | yes | 8/16-bit, ICC/text; APNG first frame only; alpha channels on open (#2225); palette PNGs open in Indexed Color mode with their transparent entry (#4804); Save keeps a stereo PNG's `sTER` pairing (#6063) | | GIF | R/W | yes | yes | File › Open keeps the first frame; New Video Layer from File and Video Frames to Layers take every frame, with the timeline rate from the delays (#572). Per-frame delays and keeping the frames in `.pcraft` are not done | | BMP | R/W | yes | yes | 8-bit; palette BMPs open in Indexed Color mode and Indexed Color documents save as palette BMPs (no transparency) | diff --git a/docs/gaps.md b/docs/gaps.md index 50fc10de83..d2aa09219e 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -396,7 +396,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| -| 2026-10-28 | minor | G10: JPEG Options shows the size the file will have ("152.3K"), worked out off the UI thread as Quality and Format Options change; FILE-215-19 stays partial (Matte, progressive Scans); no readiness percentage or estimate changed | +| 2026-10-28 | minor | G10: JPEG Options shows the size the file will have ("152.3K"), worked out as Quality and Format Options change (on a worker thread on desktop, in the frame on the web); FILE-215-19 stays partial (Matte, progressive Scans); no readiness percentage or estimate changed | | 2026-10-27 | minor | G9: 12 of 181 preferences do nothing, the scorecard's count with integration batch 9 (#9775) on main; no estimate changed | | 2026-10-26 | minor | Expand/Contract/Border distance work, mask reads and replacement cropped to nonzero bounds; bounded distances for radii ≤5; 36 MP exact-output comparisons improve 1.54–37.82× across eight cases; percentages, estimates and scorecard budgets unchanged | | 2026-10-25 | minor | G9: the count of preferences that do nothing is the scorecard's again, 12 of 178 (it still said 24 of 171, from 2026-10-15); no estimate changed | diff --git a/docs/scorecard.md b/docs/scorecard.md index 041edd9ee2..3d7934b1fb 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -138,7 +138,7 @@ Corpus floors (`crates/io/tests/it/corpus.rs`): the enforced minimums; the corpu | FILE-215-16 | Advanced Blending (knockout, blend as group, mask hides effects) end to end | partial | [#215](https://github.com/storytold/photocraft/issues/215) | doc advanced.rs + .pcraft + PSD/layered TIFF (knko infx clbl tsly lmgm vmgm), CPU compose advanced.rs (GPU falls back), blendingOptions command + dialog; 7 psd-tools knockout oracles now pass; knockout-isolated-groups (0.45 % AA edges), advanced-blending, blend-and-clipping still differ | | FILE-215-17 | CMYK/RGB documents preview spot inks; spot plates export (TIFF extra samples, PDF) | partial | [#215](https://github.com/storytold/photocraft/issues/215) | TIFF: spot channels written and read as extra samples with DisplayInfo (1077) ink colour and solidity, ink stored 0 = solid (#7415). PSD: spot planes stored and read 0 = solid as Photoshop does, identifiers in 1053; PhotoCraft 0.1.0–0.16.0 PSDs (straight planes) keep their ink. Missing: spot ink preview in RGB/CMYK documents (compose multichannel.rs:1 previews Multichannel only) and PDF Separation | | FILE-215-18 | Save for Web per-slice settings remembered; Export As remembers and offers nearest scaling | done | [#215](https://github.com/storytold/photocraft/issues/215) | Save for Web settings survive a restart through the remembered dialog settings in prefs.dialogs (web_cmds::remember_web_settings stores them on a complete export, never the destination; file_ui.rs open_web seeds from the session's last export, else the preferences). export_dialog seeds Export As the same way (seed_remembered: format, quality, transparency, scale, resample, metadata, TGA bits) and confirm remembers them as used. Export As › Image Size › Resample offers Photoshop's six methods (Bicubic Automatic default, Nearest Neighbor included; export_preset_cmds::RESAMPLE, unknown values are errors) for the exported copy only; also layer.exportAs `resample` and export presets (#5838). Tests: export_dialog nearest_neighbor_doubles_each_pixel_into_an_exact_block (8/16-bit), resample_choices_are_checked, resample_is_remembered_and_a_bad_saved_value_falls_back; layer_menu_cmds export_layer_resamples_with_the_chosen_method; tests/it/export_as_scaling.rs export_as_resample_menu_writes_nearest_neighbor_blocks | -| FILE-215-19 | Save As JPEG opens a JPEG Options dialog (quality 0-12, format options) | partial | [#215](https://github.com/storytold/photocraft/issues/215) | jpeg_options_ui.rs: File > Save, Save As and Save a Copy to a JPEG ask for Quality 0-12 (Low/Medium/High/Maximum) and Baseline / Baseline Optimized / Progressive before writing, starting at an opened JPEG's own quality (file_encoding) or else the Export preference; scripts pass quality/jpegFormat (file.saveAs, file.saveACopy, app.save, CLI --jpeg-format) and recorded actions replay the answer (discard_ui/tests/jpeg_tests.rs, codecs roundtrip jpeg_format_options_round_trip). Baseline Optimized fits the Huffman tables only at 4:4:4 (Gray, CMYK) or progressive: RGB is written 4:2:0, which zune-jpeg 0.5.15 decodes wrongly with fitted tables, as in Save for Web, so RGB Baseline Optimized writes the same file as Baseline. The dialog shows the size the file will have (152.3K, 1.5M), encoded in memory with its settings off the UI thread, up to 40 MP (jpeg_options_ui/size_readout.rs). Missing: Matte and progressive Scans (3-5) | +| FILE-215-19 | Save As JPEG opens a JPEG Options dialog (quality 0-12, format options) | partial | [#215](https://github.com/storytold/photocraft/issues/215) | jpeg_options_ui.rs: File > Save, Save As and Save a Copy to a JPEG ask for Quality 0-12 (Low/Medium/High/Maximum) and Baseline / Baseline Optimized / Progressive before writing, starting at an opened JPEG's own quality (file_encoding) or else the Export preference; scripts pass quality/jpegFormat (file.saveAs, file.saveACopy, app.save, CLI --jpeg-format) and recorded actions replay the answer (discard_ui/tests/jpeg_tests.rs, codecs roundtrip jpeg_format_options_round_trip). Baseline Optimized fits the Huffman tables only at 4:4:4 (Gray, CMYK) or progressive: RGB is written 4:2:0, which zune-jpeg 0.5.15 decodes wrongly with fitted tables, as in Save for Web, so RGB Baseline Optimized writes the same file as Baseline. The dialog shows the size the file will have (152.3K, 1.5M), encoded in memory with its settings on a worker thread (in the frame on the web), up to 40 MP, 4 MP on the web (jpeg_options_ui/size_readout.rs). Missing: Matte and progressive Scans (3-5) | | FILE-215-20 | Artboard tool with handles and + buttons, Duplicate Artboard beside, vector PDF pages | partial | [#215](https://github.com/storytold/photocraft/issues/215) | artboard_tool.rs: Artboard tool draws, selects, moves and resizes boards by their handles (⇧ keeps proportions), its + buttons add a board or (⌥) a copy beside the selected one (#3033); Duplicate Artboard places the copy beside (#1584); raster PDF pages only | | FILE-215-21 | Local libraries of colours, patterns, images and layers, as linked items or copies | missing | [#215](https://github.com/storytold/photocraft/issues/215) | Only preset libraries (preset_store.rs) | | FILE-216-1 | Every corpus fetched and enforced in CI, cached by pin | done | [#216](https://github.com/storytold/photocraft/issues/216) | ci.yml has a corpus job (name: corpus tests) on every PR and on main: the corpora are pinned in xtask/src/corpus_pins.rs, sha256-verified after both a cache restore and a download, and a missing corpus fails the tests. The cache key follows the pins, so it is cached by pin as this row asks (#190) | diff --git a/scorecard/file_compat.toml b/scorecard/file_compat.toml index 1cf8af7cf3..0b5d04a13a 100644 --- a/scorecard/file_compat.toml +++ b/scorecard/file_compat.toml @@ -136,7 +136,7 @@ id = "FILE-215-19" target = "Save As JPEG opens a JPEG Options dialog (quality 0-12, format options)" status = "partial" issue = 215 -note = "jpeg_options_ui.rs: File > Save, Save As and Save a Copy to a JPEG ask for Quality 0-12 (Low/Medium/High/Maximum) and Baseline / Baseline Optimized / Progressive before writing, starting at an opened JPEG's own quality (file_encoding) or else the Export preference; scripts pass quality/jpegFormat (file.saveAs, file.saveACopy, app.save, CLI --jpeg-format) and recorded actions replay the answer (discard_ui/tests/jpeg_tests.rs, codecs roundtrip jpeg_format_options_round_trip). Baseline Optimized fits the Huffman tables only at 4:4:4 (Gray, CMYK) or progressive: RGB is written 4:2:0, which zune-jpeg 0.5.15 decodes wrongly with fitted tables, as in Save for Web, so RGB Baseline Optimized writes the same file as Baseline. The dialog shows the size the file will have (152.3K, 1.5M), encoded in memory with its settings off the UI thread, up to 40 MP (jpeg_options_ui/size_readout.rs). Missing: Matte and progressive Scans (3-5)" +note = "jpeg_options_ui.rs: File > Save, Save As and Save a Copy to a JPEG ask for Quality 0-12 (Low/Medium/High/Maximum) and Baseline / Baseline Optimized / Progressive before writing, starting at an opened JPEG's own quality (file_encoding) or else the Export preference; scripts pass quality/jpegFormat (file.saveAs, file.saveACopy, app.save, CLI --jpeg-format) and recorded actions replay the answer (discard_ui/tests/jpeg_tests.rs, codecs roundtrip jpeg_format_options_round_trip). Baseline Optimized fits the Huffman tables only at 4:4:4 (Gray, CMYK) or progressive: RGB is written 4:2:0, which zune-jpeg 0.5.15 decodes wrongly with fitted tables, as in Save for Web, so RGB Baseline Optimized writes the same file as Baseline. The dialog shows the size the file will have (152.3K, 1.5M), encoded in memory with its settings on a worker thread (in the frame on the web), up to 40 MP, 4 MP on the web (jpeg_options_ui/size_readout.rs). Missing: Matte and progressive Scans (3-5)" [[item]] id = "FILE-215-20" From 7e15585d92b04d57164b78e452052a8b9c292497 Mon Sep 17 00:00:00 2001 From: DuBearte <130190089+DuBearte@users.noreply.github.com> Date: Fri, 30 Oct 2026 20:08:41 -0400 Subject: [PATCH 04/31] feat: Black & White Auto (#9269) An Auto button beside Default in the Black & White editor (Properties panel and Image > Adjustments) sets the colour weights for the image: the image under the adjustment layer, or the layer's pixels. The new read-only command image.adjustments.blackWhite.auto finds them with our own search: from the default mix, each weight steps through 0..150 % to raise the entropy of a 32-level grey histogram, less a pull back to the default, so colours the default turns into one grey are told apart with the mildest change. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/adjust_cmds.rs | 106 ++++++++++++++++++ crates/engine/src/cmd/adjust_cmds/tests.rs | 52 +++++++++ crates/ui-egui/src/dialogs/adjust_dialog.rs | 5 +- crates/ui-egui/src/dialogs/adjust_editors.rs | 5 +- .../src/dialogs/adjust_editors/basic.rs | 20 ++++ .../src/dialogs/adjust_editors/session.rs | 7 +- .../src/dialogs/adjust_editors/ui_tests.rs | 38 +++++++ 7 files changed, 229 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/adjust_cmds.rs b/crates/engine/src/cmd/adjust_cmds.rs index 7f31b8d226..7ebe7dd8dd 100644 --- a/crates/engine/src/cmd/adjust_cmds.rs +++ b/crates/engine/src/cmd/adjust_cmds.rs @@ -607,6 +607,16 @@ pub fn specs() -> Vec { has_doc, hdr_toning ), + CommandSpec { + id: "image.adjustments.blackWhite.auto", + label: "Black & White Auto", + menu: &[], + shortcut: None, + params: r##"{"layer":id?} → {"reds","yellows","greens","cyans","blues","magentas"} (%: the Auto mix for the image a Black & White adjustment layer sees, or for a pixel layer's own pixels; nothing changes)"##, + enabled: has_doc, + run: black_white_auto, + journal: false, + }, CommandSpec { id: "image.adjustments.colorLookup.list", label: "List Color Lookup Looks", @@ -620,5 +630,101 @@ pub fn specs() -> Vec { ] } +/// At most this many pixels are sampled for [`auto_black_white`]. +const AUTO_BW_SAMPLES: usize = 1 << 16; +/// Grey levels [`auto_black_white`] tells apart. +const AUTO_BW_BINS: usize = 32; + +/// Black & White's Auto (#9269): the colour weights (%, in [`crate::adjust_params::HUE_RANGES`] +/// order) that spread the image's greys over the most grey levels. PhotoCraft's own search, not +/// Photoshop's: from the default mix, each weight in turn steps through 0..=150 % to the value +/// that most raises the entropy of the greys' histogram ([`AUTO_BW_BINS`] levels), less a pull +/// back towards the default, until a pass changes nothing. So colours the default mix turns into +/// the same grey are told apart, with the mildest change that does it; colours the image lacks +/// keep their default weight. +pub fn auto_black_white(px: &[[f32; 3]]) -> [f32; 6] { + let defaults = crate::adjust_params::BW_DEFAULTS; + let score = |w: &[f32; 6]| -> f32 { + if px.is_empty() { + return 0.0; + } + let mut bins = [0u32; AUTO_BW_BINS]; + for c in px { + let g = photocraft_compose::adjust::black_white_gray(*c, w).clamp(0.0, 1.0); + if let Some(b) = bins.get_mut(((g * AUTO_BW_BINS as f32) as usize).min(AUTO_BW_BINS - 1)) { + *b += 1; + } + } + let n = px.len() as f32; + let entropy: f32 = bins.iter().filter(|&&k| k > 0).map(|&k| k as f32 / n).map(|q| -q * q.log2()).sum(); + let pull: f32 = w.iter().zip(defaults).map(|(v, d)| (v - d).abs()).sum::() / 100.0; + entropy - 0.05 * pull + }; + let mut w = defaults; + let mut best = score(&w); + for _ in 0..4 { + let mut changed = false; + for k in 0..6 { + for step in 0..=15 { + let mut t = w; + t[k] = step as f32 * 10.0; + let s = score(&t); + if s > best + 1e-6 { + (best, w, changed) = (s, t, true); + } + } + } + if !changed { + break; + } + } + w +} + +/// Hides `id` and everything composited above it. Returns whether `id` is in `layers`. +fn hide_from(layers: &mut [Layer], id: photocraft_doc::LayerId) -> bool { + let mut found = false; + for l in layers.iter_mut() { + if found || l.id == id { + found = true; + l.visible = false; + } else if l.children_mut().is_some_and(|c| hide_from(c, id)) { + found = true; + } + } + found +} + +/// `image.adjustments.blackWhite.auto`: [`auto_black_white`] for `layer` (default the active one): +/// the composite of what lies below a Black & White adjustment layer (what it adjusts), or a pixel +/// layer's own pixels (what Image › Adjustments › Black & White changes). +fn black_white_auto(s: &mut Session, p: &Value) -> Result { + let cmd = "image.adjustments.blackWhite.auto"; + let st = s.active().ok_or(EngineError::NoDocument)?; + let id = match p.get("layer") { + None | Some(Value::Null) => st.active_layer.ok_or_else(|| EngineError::Other("no active layer".into()))?, + Some(v) => photocraft_doc::LayerId(v.as_u64().ok_or_else(|| bad(cmd, "`layer` must be a layer id"))?), + }; + let layer = st.doc.layer(id).ok_or(EngineError::NoLayer(id))?; + let area = st.doc.bounds(); + let rgba: Vec<[f32; 4]> = match &layer.content { + LayerContent::Adjustment(_) => { + let mut below = (*st.doc).clone(); + hide_from(&mut below.layers, id); + photocraft_compose::render(&below, area).into_px() + } + _ => { + let surf = layer.surface().ok_or_else(|| EngineError::Other(format!("layer \"{}\" has no pixels", layer.name)))?; + let fmt = surf.format(); + surf.read_region(area).chunks_exact(fmt.channels()).map(|c| to_rgba(&fmt, c)).collect() + } + }; + let covered = || rgba.iter().filter(|c| c[3] > 0.0); + let stride = covered().count().div_ceil(AUTO_BW_SAMPLES).max(1); + let px: Vec<[f32; 3]> = covered().step_by(stride).map(|c| [c[0], c[1], c[2]]).collect(); + let w = auto_black_white(&px); + Ok(Value::Object(crate::adjust_params::HUE_RANGES.iter().zip(w).map(|(k, v)| ((*k).to_string(), json!(v))).collect())) +} + #[cfg(test)] mod tests; diff --git a/crates/engine/src/cmd/adjust_cmds/tests.rs b/crates/engine/src/cmd/adjust_cmds/tests.rs index 51843ae022..ca31907819 100644 --- a/crates/engine/src/cmd/adjust_cmds/tests.rs +++ b/crates/engine/src/cmd/adjust_cmds/tests.rs @@ -823,3 +823,55 @@ fn folder_luts_lists_lut_files_by_name() { assert!(folder_luts("").is_empty()); assert!(folder_luts(dir.path().join("missing").to_str().unwrap()).is_empty()); } + +/// #9269: Black & White's Auto spreads an image's greys wider than the default mix, leaves the +/// weights of colours the image lacks at their defaults, and changes nothing in the document. +#[test] +fn black_white_auto_spreads_the_greys() { + use crate::adjust_params::{BW_DEFAULTS, HUE_RANGES}; + // A red and a blue the default mix turns into the same grey (0.43). + let (red, blue) = ([0.776, 0.2, 0.2], [0.29, 0.29, 1.0]); + let gray = |c: [f32; 3], w: &[f32; 6]| photocraft_compose::adjust::black_white_gray(c, w); + let w = auto_black_white(&[red, blue]); + let gap = |w: &[f32; 6]| (gray(red, w) - gray(blue, w)).abs(); + assert!(gap(&BW_DEFAULTS) < 0.01); + assert!(gap(&w) > 1.0 / 32.0, "{w:?}: {}", gap(&w)); + assert!(w.iter().all(|v| (0.0..=150.0).contains(v)), "{w:?}"); + // Yellows, greens, cyans and magentas aren't in the image: they keep their defaults. + for k in [1, 2, 3, 5] { + assert_eq!(w[k], BW_DEFAULTS[k], "{}", HUE_RANGES[k]); + } + // A grey image (and no pixels at all) keeps the default mix. + assert_eq!(auto_black_white(&[[0.2; 3], [0.7; 3]]), BW_DEFAULTS); + assert_eq!(auto_black_white(&[]), BW_DEFAULTS); + + // The command: an adjustment layer reads the image under it; the document is unchanged. + let mut s = Session::new(); + s.execute("file.new", json!({"width": 20, "height": 10})).unwrap(); + paint(&mut s, |x, _| if x < 10 { [0.776, 0.2, 0.2, 1.0] } else { [0.29, 0.29, 1.0, 1.0] }); + let id = s.execute("layer.newAdjustmentLayer.blackWhite", json!({})).unwrap()["layer"].as_u64().unwrap(); + let rev = s.active().unwrap().revision; + let r = s.execute("image.adjustments.blackWhite.auto", json!({"layer": id})).unwrap(); + let got: Vec = HUE_RANGES.iter().map(|k| r[*k].as_f64().unwrap() as f32).collect(); + assert_eq!(got, w.to_vec()); + assert_eq!(s.active().unwrap().revision, rev, "nothing changes"); + assert!(s.execute("image.adjustments.blackWhite.auto", json!({"layer": 999})).is_err()); + assert!(s.execute("image.adjustments.blackWhite.auto", json!({"layer": "x"})).is_err()); + + // A layer above the adjustment isn't part of what it adjusts. + s.execute("layer.new.layer", json!({})).unwrap(); + paint(&mut s, |_, _| [0.5, 0.5, 0.5, 1.0]); + assert_eq!(s.execute("image.adjustments.blackWhite.auto", json!({"layer": id})).unwrap(), r); + + // Sampling a big canvas still finds a thin painted stripe. + let mut s = Session::new(); + s.execute("file.new", json!({"width": 512, "height": 512, "background": "transparent"})).unwrap(); + paint(&mut s, |x, y| match (x, y < 256) { + (1, true) => [0.776, 0.2, 0.2, 1.0], + (1, false) => [0.29, 0.29, 1.0, 1.0], + _ => [0.0; 4], + }); + let r = s.execute("image.adjustments.blackWhite.auto", json!({})).unwrap(); + let got: Vec = HUE_RANGES.iter().map(|k| r[*k].as_f64().unwrap() as f32).collect(); + assert_eq!(got, w.to_vec()); +} diff --git a/crates/ui-egui/src/dialogs/adjust_dialog.rs b/crates/ui-egui/src/dialogs/adjust_dialog.rs index ba34906548..b729973d09 100644 --- a/crates/ui-egui/src/dialogs/adjust_dialog.rs +++ b/crates/ui-egui/src/dialogs/adjust_dialog.rs @@ -489,10 +489,13 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, fields: &mut Map Self { Edit { changed, commit: changed, ..Default::default() } diff --git a/crates/ui-egui/src/dialogs/adjust_editors/basic.rs b/crates/ui-egui/src/dialogs/adjust_editors/basic.rs index 6a0e9694b6..3bf3109fb6 100644 --- a/crates/ui-egui/src/dialogs/adjust_editors/basic.rs +++ b/crates/ui-egui/src/dialogs/adjust_editors/basic.rs @@ -181,6 +181,9 @@ pub(super) fn black_white(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Ed v["tintColor"] = json!(adjust_params::hex(c)); } e.add(r); + if widgets::secondary_button(ui, tl!("Auto"), 0.0).on_hover_text(tl!("A grey mix that spreads the image's tones")).clicked() { + e.auto = true; + } if widgets::secondary_button(ui, tl!("Default"), 0.0).clicked() { for (i, key) in HUE_RANGES.iter().enumerate() { v[*key] = json!(adjust_params::BW_DEFAULTS[i]); @@ -191,6 +194,23 @@ pub(super) fn black_white(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Ed e } +/// Black & White's Auto (#9269): the weights `image.adjustments.blackWhite.auto` finds for +/// `layer` (an adjustment layer: the image under it; a pixel layer: its pixels) into `v`, as one +/// discrete edit of `e`. +pub fn apply_auto(app: &mut crate::PhotocraftApp, v: &mut Value, layer: Option, e: &mut Edit) { + let p = layer.map_or(json!({}), |id| json!({"layer": id.0})); + match app.run("image.adjustments.blackWhite.auto", p) { + Ok(Value::Object(w)) => { + for (k, x) in w { + v[k] = x; + } + e.add(Edit::discrete(true)); + } + Ok(_) => {} + Err(err) => app.ui.status = err, + } +} + // --------------------------------------------------------------------------------------------- // Photo Filter diff --git a/crates/ui-egui/src/dialogs/adjust_editors/session.rs b/crates/ui-egui/src/dialogs/adjust_editors/session.rs index d254b49bba..a4d5ba0ddc 100644 --- a/crates/ui-egui/src/dialogs/adjust_editors/session.rs +++ b/crates/ui-egui/src/dialogs/adjust_editors/session.rs @@ -1,4 +1,4 @@ -use super::basic::reset_color_balance; +use super::basic::{apply_auto, reset_color_balance}; use super::gradient_map::{gradient_presets, is_gray, swatches}; use super::{Adjustment, EditorCx, HistSource, LayerId, PhotocraftApp, RichText, Tokens, Value, adjust_params, editor, json, needs_histogram, space_of, tone}; @@ -121,10 +121,13 @@ pub fn layer_editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: LayerId, adj if matches!(kind, "levels" | "curves") { crate::layer_picker::controls(app, ui, id); } - let e = editor(ui, kind, &mut values, &cx); + let mut e = editor(ui, kind, &mut values, &cx); if e.import_gradients { crate::preset_files_ui::import_gradients_dialog(app); } + if e.auto { + apply_auto(app, &mut values, Some(id), &mut e); + } if e.changed { app.live_adjust = Some((id, values.clone())); } diff --git a/crates/ui-egui/src/dialogs/adjust_editors/ui_tests.rs b/crates/ui-egui/src/dialogs/adjust_editors/ui_tests.rs index 88dcbaf062..1595195d4a 100644 --- a/crates/ui-egui/src/dialogs/adjust_editors/ui_tests.rs +++ b/crates/ui-egui/src/dialogs/adjust_editors/ui_tests.rs @@ -1067,3 +1067,41 @@ fn selective_color_properties_method_radios_commit() { assert!(matches!(layer(&h).1, Adjustment::SelectiveColor { relative: true, .. })); assert_eq!(steps(&h), start + 2); } + +/// #9269: Black & White's Auto button sets the layer's weights from the image under it, as one +/// history step. +#[test] +fn black_white_auto_button_sets_the_weights() { + let mut h = app_harness("blackWhite", "rgb", 8); + // A red and a blue the default mix turns into the same grey, under the adjustment layer. + h.state_mut() + .session + .edit("setup", |doc, _| { + let b = doc.bounds(); + let bg = doc.layers.first_mut().and_then(|l| l.surface_mut()).unwrap(); + let fmt = bg.format(); + let mut data = Vec::new(); + for _ in b.y0..b.y1 { + for x in b.x0..b.x1 { + let c = if x < b.x1 / 2 { [0.776, 0.2, 0.2, 1.0] } else { [0.29, 0.29, 1.0, 1.0] }; + data.extend(photocraft_raster::from_rgba(&fmt, c)); + } + } + bg.write_region(b, &data); + Ok(()) + }) + .unwrap(); + h.run_steps(2); + let (_, before) = layer(&h); + let steps = h.state().session.active().unwrap().history.past_len(); + let auto = h.query_all_by_label("Auto").last().map(|n| n.rect().center()).expect("an Auto button"); + click(&mut h, auto); + let (_, after) = layer(&h); + let Adjustment::BlackWhite { weights, .. } = &after else { panic!("{after:?}") }; + let id = layer(&h).0; + let expected = h.state_mut().run("image.adjustments.blackWhite.auto", json!({"layer": id.0})).unwrap(); + let want: Vec = photocraft_engine::adjust_params::HUE_RANGES.iter().map(|k| expected[*k].as_f64().unwrap() as f32).collect(); + assert_eq!(weights.to_vec(), want); + assert_ne!(after, before, "the red and blue are told apart"); + assert_eq!(h.state().session.active().unwrap().history.past_len(), steps + 1); +} From ad0589e0428e6902ad99dffc5569397f8f148f76 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sat, 7 Nov 2026 17:10:09 -0600 Subject: [PATCH 05/31] Perspective Crop: hover cursors say what a press does (#11974) The press's hit-testing moves into perspective_crop_ui::zone, which the hover cursor also reads: with no quad a small crosshair with the tool's icon, inside the quad the arrow, over a corner the hollow arrowhead. Outside the quad, where a press does nothing yet (#11801), the tool's usual crosshair stays. A held drag keeps its zone's cursor, as the Crop tool does. --- crates/ui-egui/src/canvas.rs | 9 ++ .../canvas/perspective_crop_cursor_tests.rs | 152 ++++++++++++++++++ crates/ui-egui/src/canvas/tool_cursor.rs | 21 +++ crates/ui-egui/src/icons.rs | 13 +- .../ui-egui/src/tools/perspective_crop_ui.rs | 59 +++++-- 5 files changed, 237 insertions(+), 17 deletions(-) create mode 100644 crates/ui-egui/src/canvas/perspective_crop_cursor_tests.rs diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index e5b0f62e08..4b71c3672b 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1520,6 +1520,12 @@ fn draw_canvas_tool_cursor(app: &mut PhotocraftApp, ui: &mut egui::Ui, input: &C let toward = xf.to_screen((d[0] + k[0] * 16.0) as f32, (d[1] + k[1] * 16.0) as f32) - p; crate::crop_ui::draw_turn_cursor(ctx, p, toward); } + } else if let Some(p) = response.hover_pos() + && let Some(zone) = + crate::perspective_crop_ui::cursor(app, xf.to_doc(p)).filter(|z| tool == Tool::PerspectiveCrop && *z != crate::perspective_crop_ui::Zone::Outside) + { + // What a press there does (#11974); outside the quad the tool's usual crosshair below. + ui.ctx().set_cursor_icon(crate::tool_cursor::perspective_crop(input.painter, zone, p)); } else if let Some(p) = response.hover_pos() && let Some(c) = selection_cursor(app, tool, xf.to_doc(p), crate::workspace_ui::sticky_mods(app, ui.input(|i| i.modifiers))) { @@ -2746,6 +2752,9 @@ mod cpu_background_tests; #[path = "canvas/blend_viewport_tests.rs"] mod blend_viewport_regression; +#[cfg(test)] +#[path = "canvas/perspective_crop_cursor_tests.rs"] +mod perspective_crop_cursor_tests; #[cfg(test)] #[path = "canvas/pixel_alignment_tests.rs"] mod pixel_alignment_tests; diff --git a/crates/ui-egui/src/canvas/perspective_crop_cursor_tests.rs b/crates/ui-egui/src/canvas/perspective_crop_cursor_tests.rs new file mode 100644 index 0000000000..cca39f37b5 --- /dev/null +++ b/crates/ui-egui/src/canvas/perspective_crop_cursor_tests.rs @@ -0,0 +1,152 @@ +//! #11974: the Perspective Crop's hover cursor says what a press does. With no quad a small +//! crosshair with the tool's icon (hotspot on the crosshair's centre), inside the quad the arrow, +//! over a corner the hollow arrowhead; outside the quad (no press there yet) the usual crosshair. + +use egui::{CursorIcon, pos2}; + +use crate::PhotocraftApp; +use crate::canvas::ToolEvent; +use crate::perspective_crop_ui::{Drag, Zone, cursor, pointer}; +use crate::state::Tool; + +const QUAD: [[f64; 2]; 4] = [[40.0, 30.0], [160.0, 40.0], [150.0, 120.0], [50.0, 110.0]]; + +/// A 200 × 150 document with the Perspective Crop tool. +fn app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", serde_json::json!({"width": 200, "height": 150})).unwrap(); + app.sync_views(); + app.ui.tool = Tool::PerspectiveCrop; + app +} + +/// The drag a press at `p` starts, with the quad left as it was. +fn press(app: &mut PhotocraftApp, p: [f64; 2]) -> Option { + let quad = app.ui.perspective_crop; + assert!(pointer(app, ToolEvent::Down { x: p[0], y: p[1], pressure: 1.0 })); + let drag = app.pcrop.drag.take(); + app.ui.perspective_crop = quad; + drag +} + +/// The zone a drag acts on: the cursor has to show the same. +fn acts_on(drag: Option) -> Zone { + match drag { + Some(Drag::New { .. }) => Zone::Idle, + Some(Drag::Corner { index, .. }) => Zone::Corner(index), + Some(Drag::Move { .. }) => Zone::Inside, + None => Zone::Outside, + } +} + +#[test] +fn every_point_shows_what_a_press_there_does() { + let mut app = app(); + let mut seen = Vec::new(); + for quad in [None, Some(QUAD)] { + app.ui.perspective_crop = quad; + for y in (0..=150).step_by(3) { + for x in (0..=200).step_by(3) { + let p = [f64::from(x), f64::from(y)]; + let shown = cursor(&app, p).expect("a cursor over the canvas"); + assert_eq!(shown, acts_on(press(&mut app, p)), "at {p:?} with {quad:?}"); + if !seen.contains(&shown) { + seen.push(shown); + } + } + } + } + for z in [Zone::Idle, Zone::Inside, Zone::Outside, Zone::Corner(0), Zone::Corner(1), Zone::Corner(2), Zone::Corner(3)] { + assert!(seen.contains(&z), "{z:?} was hovered"); + } +} + +#[test] +fn the_corner_tolerance_is_the_press_tolerance() { + let mut app = app(); + app.ui.perspective_crop = Some(QUAD); + let tol = crate::distort_ui::tolerance(&app); + // Just inside the tolerance, inside the quad: the corner wins, as for a press. + let near = [QUAD[2][0] - tol * 0.6, QUAD[2][1] - tol * 0.6]; + assert_eq!(cursor(&app, near), Some(Zone::Corner(2))); + assert_eq!(acts_on(press(&mut app, near)), Zone::Corner(2)); + // Just past it, inside the quad: the arrow, and a press moves the quad. + let past = [QUAD[2][0] - tol * 1.2, QUAD[2][1] - tol * 1.2]; + assert_eq!(cursor(&app, past), Some(Zone::Inside)); + assert_eq!(acts_on(press(&mut app, past)), Zone::Inside); +} + +#[test] +fn a_held_drag_keeps_its_cursor() { + // Not measured: as the Crop tool, the cursor stays the drag's while the button is held. + let mut app = app(); + app.ui.perspective_crop = Some(QUAD); + assert!(pointer(&mut app, ToolEvent::Down { x: 100.0, y: 75.0, pressure: 1.0 })); + assert!(pointer(&mut app, ToolEvent::Move { x: 190.0, y: 140.0, pressure: 1.0 })); + assert_eq!(cursor(&app, [5.0, 5.0]), Some(Zone::Inside), "moving the quad"); + assert!(pointer(&mut app, ToolEvent::Up { x: 190.0, y: 140.0 })); + let corner = app.ui.perspective_crop.unwrap()[0]; + assert!(pointer(&mut app, ToolEvent::Down { x: corner[0], y: corner[1], pressure: 1.0 })); + assert_eq!(cursor(&app, [100.0, 75.0]), Some(Zone::Corner(0)), "dragging a corner"); +} + +#[test] +fn only_the_perspective_crop_tool_with_a_document() { + let mut app = app(); + app.ui.tool = Tool::Crop; + assert_eq!(cursor(&app, [100.0, 75.0]), None); + app.ui.tool = Tool::PerspectiveCrop; + assert_eq!(cursor(&app, [f64::NAN, 75.0]), None); + app.run("file.close", serde_json::json!({})).unwrap(); + assert_eq!(cursor(&app, [100.0, 75.0]), None); +} + +/// A cursor bitmap's hotspot and size. +type Bitmap = ([u16; 2], [u16; 2]); + +/// The cursor drawn for `zone` at `at`: the OS cursor, the bitmap's hotspot if one is set, and +/// whether anything was painted on top (the badge, the arrowhead). +fn drawn(zone: Zone, at: egui::Pos2) -> (CursorIcon, Option, bool) { + let ctx = egui::Context::default(); + ctx.add_plugin(crate::tool_cursor::CursorLifecycle); + egui_extras::install_image_loaders(&ctx); + let run = || { + let mut out = ctx.run_ui(Default::default(), |ui| { + let icon = crate::tool_cursor::perspective_crop(ui.painter(), zone, at); + ui.ctx().set_cursor_icon(icon); + }); + out.textures_delta.clear(); + out + }; + // egui's first Area pass measures its content and paints nothing; the icon loads meanwhile. + run(); + run(); + let out = run(); + let painted = !out.shapes.is_empty(); + (out.platform_output.cursor_icon, out.platform_output.cursor_image.map(|i| (i.hotspot, i.size)), painted) +} + +#[test] +#[cfg(not(target_arch = "wasm32"))] +fn the_zones_draw_distinct_cursors_with_their_hotspots() { + let at = pos2(120.0, 90.0); + let idle = drawn(Zone::Idle, at); + let inside = drawn(Zone::Inside, at); + let corner = drawn(Zone::Corner(1), at); + // No quad: the small crosshair as the OS bitmap, centred on the point, with the + // tool's icon painted beside it. (`CursorLifecycle` leaves the OS crosshair under a bitmap, + // for a backend that can't show one.) + assert_eq!(idle.0, CursorIcon::Crosshair); + // The bitmap is padded for its outline: the hotspot is its centre, where the arms cross. + let (hot, size) = idle.1.expect("the crosshair bitmap"); + assert_eq!(hot[0], hot[1]); + assert_eq!(hot[0] * 2 + 1, size[0], "the crosshair's centre is the hotspot: {hot:?} in {size:?}"); + assert!(idle.2, "the tool badge"); + // Inside: the solid arrow, its tip the hotspot. + assert_eq!(inside, (CursorIcon::Default, None, false)); + // Corner: the hollow arrowhead, painted with its tip on the point. + assert_eq!(corner.0, CursorIcon::None); + assert_eq!(corner.1, None, "no crosshair bitmap"); + assert!(corner.2, "the arrowhead"); + assert!(idle != inside && inside != corner && idle != corner); +} diff --git a/crates/ui-egui/src/canvas/tool_cursor.rs b/crates/ui-egui/src/canvas/tool_cursor.rs index 84b69b26cf..790bcaced8 100644 --- a/crates/ui-egui/src/canvas/tool_cursor.rs +++ b/crates/ui-egui/src/canvas/tool_cursor.rs @@ -97,6 +97,27 @@ pub(crate) fn crosshair(painter: &Painter, at: Pos2, length: f32, gap: f32) -> C show(painter, at, Shape::Crosshair { length, gap }) } +/// The Perspective Crop's pointer for what a press at `at` does (#11974): with no quad a small +/// crosshair centred on the point with the tool's icon below right; inside the quad the arrow; +/// over a corner the hollow white arrowhead with its tip on the point. +pub(crate) fn perspective_crop(painter: &Painter, zone: crate::perspective_crop_ui::Zone, at: Pos2) -> CursorIcon { + use crate::perspective_crop_ui::Zone; + match zone { + Zone::Idle => { + crate::icons::crosshair_badge(painter.ctx(), crate::icons::tool_icon(Tool::PerspectiveCrop), at); + crosshair(painter, at, 5.0, 0.0) + } + Zone::Inside => CursorIcon::Default, + Zone::Corner(_) => { + // The tip of the arrowhead is at (4, 4) of its 24-unit box. + crate::icons::cursor(painter.ctx(), "mouse-pointer-2", at, vec2(4.0, 4.0) / 24.0, 18.0); + CursorIcon::None + } + // A press there does nothing yet (#11801): the tool's usual crosshair. + Zone::Outside => standard_crosshair(painter, at), + } +} + pub(crate) fn standard_crosshair(painter: &Painter, at: Pos2) -> CursorIcon { show(painter, at, STANDARD_CROSSHAIR.clone()) } diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index c102064a7f..ae8e2eb60f 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -84,17 +84,22 @@ pub fn cursor(ctx: &egui::Context, name: &str, p: egui::Pos2, hot: Vec2, size: f /// cutting the selected pixels). Below-right of the arrow, white with a dark outline. The caller /// keeps the arrow (`CursorIcon::Default`). pub fn cursor_badge(ctx: &egui::Context, name: &str, p: egui::Pos2) { - badge(ctx, name, p, false); + badge(ctx, name, p + egui::vec2(20.0, 19.0), false); +} + +/// A small icon below right of a crosshair centred on `p` (the Perspective Crop's idle pointer). +pub fn crosshair_badge(ctx: &egui::Context, name: &str, p: egui::Pos2) { + badge(ctx, name, p + egui::vec2(13.0, 13.0), false); } /// Photoshop's duplicate badge: a second, dark arrowhead behind a white one. pub fn cursor_copy_badge(ctx: &egui::Context, p: egui::Pos2) { - badge(ctx, "mouse-pointer-2", p, true); + badge(ctx, "mouse-pointer-2", p + egui::vec2(20.0, 19.0), true); } -fn badge(ctx: &egui::Context, name: &str, p: egui::Pos2, doubled: bool) { +fn badge(ctx: &egui::Context, name: &str, centre: egui::Pos2, doubled: bool) { const SIZE: f32 = 12.0; - let rect = Rect::from_center_size(p + egui::vec2(20.0, 19.0), Vec2::splat(SIZE)); + let rect = Rect::from_center_size(centre, Vec2::splat(SIZE)); let area = egui::Area::new(egui::Id::unique("pc-cursor-badge")).order(egui::Order::Tooltip).fixed_pos(rect.min).constrain(false).interactable(false); area.show(ctx, |ui| { let outline = image(name, SIZE, Color32::from_black_alpha(220)); diff --git a/crates/ui-egui/src/tools/perspective_crop_ui.rs b/crates/ui-egui/src/tools/perspective_crop_ui.rs index ed60c90d41..0c6429263f 100644 --- a/crates/ui-egui/src/tools/perspective_crop_ui.rs +++ b/crates/ui-egui/src/tools/perspective_crop_ui.rs @@ -105,6 +105,47 @@ fn inside(q: &[[f64; 2]; 4], p: [f64; 2]) -> bool { }) } +/// What a press at a point does, which the hover cursor shows (#11974). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Zone { + /// No quad yet: a press starts a box or places a corner. + Idle, + /// Within the corner tolerance: a press drags that corner. + Corner(usize), + /// Inside the quad: a press moves it. + Inside, + /// Elsewhere with a quad: a press does nothing yet (edge handles and rotate are #11801). + Outside, +} + +/// The zone at `p` (document px): the hit-testing of a press in [`pointer`]. +pub fn zone(app: &PhotocraftApp, p: [f64; 2]) -> Zone { + let Some(q) = app.ui.perspective_crop else { + return Zone::Idle; + }; + let tol = crate::distort_ui::tolerance(app); + let dist = |i: usize| (q[i][0] - p[0]).hypot(q[i][1] - p[1]); + match (0..4).filter(|&i| dist(i) <= tol).min_by(|&i, &j| dist(i).total_cmp(&dist(j))) { + Some(index) => Zone::Corner(index), + None if inside(&q, p) => Zone::Inside, + None => Zone::Outside, + } +} + +/// The hover cursor's zone at `p`: what a press there does. While a drag is held it stays the +/// zone the drag started in, as the Crop tool keeps its drag's cursor (not measured). +pub fn cursor(app: &PhotocraftApp, p: [f64; 2]) -> Option { + if !active(app) || !p[0].is_finite() || !p[1].is_finite() { + return None; + } + Some(match app.pcrop.drag { + Some(Drag::New { .. }) => Zone::Idle, + Some(Drag::Corner { index, .. }) => Zone::Corner(index), + Some(Drag::Move { .. }) => Zone::Inside, + None => zone(app, p), + }) +} + /// Pointer events (document px) with the tool. True when consumed. pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent) -> bool { if app.ui.tool != Tool::PerspectiveCrop { @@ -118,21 +159,13 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent) -> bool { return true; } cancel_stale(app); - let tol = crate::distort_ui::tolerance(app); match ev { ToolEvent::Down { .. } => { - app.pcrop.drag = match app.ui.perspective_crop { - Some(q) => { - let near = (0..4) - .filter(|&i| (q[i][0] - p[0]).hypot(q[i][1] - p[1]) <= tol) - .min_by(|&i, &j| (q[i][0] - p[0]).hypot(q[i][1] - p[1]).total_cmp(&(q[j][0] - p[0]).hypot(q[j][1] - p[1]))); - match near { - Some(index) => Some(Drag::Corner { index, offset: [p[0] - q[index][0], p[1] - q[index][1]] }), - None if inside(&q, p) => Some(Drag::Move { start: p, quad: q }), - None => None, - } - } - None => Some(Drag::New { start: p, cur: p }), + app.pcrop.drag = match (zone(app, p), app.ui.perspective_crop) { + (Zone::Corner(index), Some(q)) => Some(Drag::Corner { index, offset: [p[0] - q[index][0], p[1] - q[index][1]] }), + (Zone::Inside, Some(q)) => Some(Drag::Move { start: p, quad: q }), + (Zone::Idle, _) => Some(Drag::New { start: p, cur: p }), + _ => None, }; } ToolEvent::Move { .. } => drag_to(app, p), From 4bbf55a970ae9abaad438535dff4f6660960f08f Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 8 Nov 2026 18:30:47 +0100 Subject: [PATCH 06/31] Black & White Auto: vary the sample phase per run (#9301) The sample took every stride-th covered pixel, so a pattern whose period divides the stride (alternating columns on an even-width canvas) was only sampled in one phase and Auto missed one of the colours. Each run of stride pixels now contributes one pixel at a position that varies by run. Co-Authored-By: DuBearte Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/adjust_cmds.rs | 16 ++++++++++++---- crates/engine/src/cmd/adjust_cmds/tests.rs | 10 ++++++++++ 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/adjust_cmds.rs b/crates/engine/src/cmd/adjust_cmds.rs index 4f066cde6b..5774e608f8 100644 --- a/crates/engine/src/cmd/adjust_cmds.rs +++ b/crates/engine/src/cmd/adjust_cmds.rs @@ -708,6 +708,17 @@ pub fn auto_black_white(px: &[[f32; 3]]) -> [f32; 6] { w } +/// At most [`AUTO_BW_SAMPLES`] of the pixels with alpha: one from each run of `stride` of them, at +/// a position that varies from run to run (Fibonacci hashing of the run's index), so a pattern +/// whose period divides `stride` (alternating columns on an even-width canvas) isn't only ever +/// sampled in one phase. +fn auto_bw_sample(rgba: &[[f32; 4]]) -> Vec<[f32; 3]> { + let covered = || rgba.iter().filter(|c| c[3] > 0.0); + let stride = covered().count().div_ceil(AUTO_BW_SAMPLES).max(1); + let pick = |run: usize| ((run as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15) >> 32) as usize % stride; + covered().enumerate().filter(|(i, _)| i % stride == pick(i / stride)).map(|(_, c)| [c[0], c[1], c[2]]).collect() +} + /// Hides `id` and everything composited above it. Returns whether `id` is in `layers`. fn hide_from(layers: &mut [Layer], id: photocraft_doc::LayerId) -> bool { let mut found = false; @@ -746,10 +757,7 @@ fn black_white_auto(s: &mut Session, p: &Value) -> Result { surf.read_region(area).chunks_exact(fmt.channels()).map(|c| to_rgba(&fmt, c)).collect() } }; - let covered = || rgba.iter().filter(|c| c[3] > 0.0); - let stride = covered().count().div_ceil(AUTO_BW_SAMPLES).max(1); - let px: Vec<[f32; 3]> = covered().step_by(stride).map(|c| [c[0], c[1], c[2]]).collect(); - let w = auto_black_white(&px); + let w = auto_black_white(&auto_bw_sample(&rgba)); Ok(Value::Object(crate::adjust_params::HUE_RANGES.iter().zip(w).map(|(k, v)| ((*k).to_string(), json!(v))).collect())) } diff --git a/crates/engine/src/cmd/adjust_cmds/tests.rs b/crates/engine/src/cmd/adjust_cmds/tests.rs index 1d48ace49c..b228f333b9 100644 --- a/crates/engine/src/cmd/adjust_cmds/tests.rs +++ b/crates/engine/src/cmd/adjust_cmds/tests.rs @@ -1113,4 +1113,14 @@ fn black_white_auto_spreads_the_greys() { let r = s.execute("image.adjustments.blackWhite.auto", json!({})).unwrap(); let got: Vec = HUE_RANGES.iter().map(|k| r[*k].as_f64().unwrap() as f32).collect(); assert_eq!(got, w.to_vec()); + + // Columns alternating with a period that divides the sampling stride (4 here): both colours + // are still sampled, not only the even columns. + let mut s = Session::new(); + s.execute("file.new", json!({"width": 512, "height": 512})).unwrap(); + paint(&mut s, |x, _| if x % 2 == 0 { [0.776, 0.2, 0.2, 1.0] } else { [0.29, 0.29, 1.0, 1.0] }); + let r = s.execute("image.adjustments.blackWhite.auto", json!({})).unwrap(); + let got: Vec = HUE_RANGES.iter().map(|k| r[*k].as_f64().unwrap() as f32).collect(); + assert_ne!(got, BW_DEFAULTS.to_vec(), "only one colour was sampled"); + assert!(gap(&got.clone().try_into().unwrap()) > 1.0 / 32.0, "{got:?}"); } From 1a6eca3a647e0195fe16ef8aaf6c99035f817a6a Mon Sep 17 00:00:00 2001 From: ljkx <177671075+ljkx@users.noreply.github.com> Date: Mon, 9 Nov 2026 03:50:59 +0100 Subject: [PATCH 07/31] =?UTF-8?q?Actions:=20map=20File=20=E2=80=BA=20Autom?= =?UTF-8?q?ate=20=E2=80=BA=20Fit=20Image=20(#10723)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The script event 3caa3434-cb67-11d1-bc43-0060b0a13dc4 that the reference application records after a Fit Image (Msge, Hght and Wdth in pixels, limit) loads as file.automate.fitImage and saves back with its recorded descriptor. Other forms stay kept steps; a native step with a missing side or a non-default resampling method refuses export. --- crates/engine/src/cmd/atn_cmds.rs | 4 + .../engine/src/cmd/atn_cmds/automate_tests.rs | 174 ++++++++++++++++++ crates/engine/src/cmd/atn_cmds/events.rs | 9 + .../engine/src/cmd/atn_cmds/steps_automate.rs | 95 ++++++++++ crates/engine/src/cmd/atn_cmds/tests.rs | 1 + docs/atn.md | 13 ++ 6 files changed, 296 insertions(+) create mode 100644 crates/engine/src/cmd/atn_cmds/automate_tests.rs create mode 100644 crates/engine/src/cmd/atn_cmds/steps_automate.rs diff --git a/crates/engine/src/cmd/atn_cmds.rs b/crates/engine/src/cmd/atn_cmds.rs index 0e90e5d3dc..cbfa4d7eac 100644 --- a/crates/engine/src/cmd/atn_cmds.rs +++ b/crates/engine/src/cmd/atn_cmds.rs @@ -35,6 +35,7 @@ mod files; mod menu_item; mod steps_artboard; +mod steps_automate; mod steps_batch; mod steps_calculations; mod steps_channels; @@ -184,6 +185,9 @@ mod tests; #[cfg(test)] mod layer_props_tests; +#[cfg(test)] +mod automate_tests; + #[cfg(test)] mod stylize_tests; diff --git a/crates/engine/src/cmd/atn_cmds/automate_tests.rs b/crates/engine/src/cmd/atn_cmds/automate_tests.rs new file mode 100644 index 0000000000..0b36b78413 --- /dev/null +++ b/crates/engine/src/cmd/atn_cmds/automate_tests.rs @@ -0,0 +1,174 @@ +//! #10723: File › Automate › Fit Image steps, the script event `docs/atn.md` measures ("Recorded +//! with source files"): `Msge`, `Hght` and `Wdth` in pixels, `limit` (Don't Enlarge). + +use super::steps_automate::{FIT_IMAGE, FIT_IMAGE_EVENT}; +use super::*; + +const SOURCE: &str = "_atnFitImageDescriptor"; + +/// The measured descriptor, built here independently of the converter. +fn recorded(message: &str, width: f64, height: f64, dont_enlarge: bool) -> Descriptor { + Descriptor::new("null") + .with("Msge", D::Text(UnicodeString::new_nul(message))) + .with("Hght", D::UnitFloat { unit: *b"#Pxl", value: height }) + .with("Wdth", D::UnitFloat { unit: *b"#Pxl", value: width }) + .with("limit", D::Boolean(dont_enlarge)) +} + +/// A Fit Image step as the reference application records it. +fn fit_item(d: Descriptor) -> AtnItem { + AtnItem { event: AtnEvent::name(FIT_IMAGE_EVENT), dictionary: b"Fit Image".to_vec(), descriptor: Some(d), ..Default::default() } +} + +/// Load `items` as action "A" through `actions.load`. +fn load(items: Vec) -> (Session, Value) { + let file = AtnFile { + name: UnicodeString::new_nul("Automate"), + actions: vec![AtnAction { name: UnicodeString::new_nul("A"), items, ..Default::default() }], + ..Default::default() + }; + let mut s = Session::new(); + let r = s.execute("actions.load", json!({"data": photocraft_paint::tile::b64_encode(&atn::write(&file).unwrap())})).unwrap(); + (s, r) +} + +/// Action "A" written by `actions.save` and parsed back. +fn saved(s: &mut Session) -> Vec { + let r = s.execute("actions.save", json!({"action": "A"})).unwrap(); + assert_eq!(r["skipped"], json!([]), "{r}"); + atn::parse(&photocraft_paint::tile::b64_decode(r["data"].as_str().unwrap()).unwrap()).unwrap().actions.remove(0).items +} + +/// The recorded step loads as `file.automate.fitImage` with its three settings and saves back +/// byte for byte: the message, the descriptor class and the key order are the recording's. +#[test] +fn a_recorded_fit_image_step_loads_and_saves_back_unchanged() { + let mut cjsp = recorded("Recorded message", 1200.0, 900.0, true); + cjsp.class_id = Id::new("cjsp"); + let mut reordered = recorded("", 640.5, 480.0, false); + reordered.items.reverse(); + for (d, width, height, dont_enlarge) in [ + (recorded("Recorded message", 1200.0, 900.0, true), 1200.0, 900.0, true), + (cjsp, 1200.0, 900.0, true), + (reordered, 640.5, 480.0, false), + (recorded("", 300.0, 200.0, false), 300.0, 200.0, false), + ] { + let (mut s, r) = load(vec![fit_item(d.clone())]); + assert_eq!(r["unsupported"], json!([]), "{r}"); + let (id, p) = &s.actions.list[0].steps[0]; + assert_eq!(id, FIT_IMAGE); + assert_eq!((p["width"].as_f64(), p["height"].as_f64(), &p["dontEnlarge"]), (Some(width), Some(height), &json!(dont_enlarge)), "{p}"); + let back = saved(&mut s); + assert_eq!(back.len(), 1); + assert_eq!(back[0].event, AtnEvent::name(FIT_IMAGE_EVENT)); + assert_eq!(back[0].dictionary, b"Fit Image"); + assert_eq!(back[0].descriptor.as_ref().map(Descriptor::to_bytes), Some(d.to_bytes())); + } +} + +/// The empty-message form in the measured key order is what a step recorded in PhotoCraft saves +/// as, so loading it keeps no descriptor of its own. +#[test] +fn the_canonical_form_carries_only_the_settings() { + let (s, _) = load(vec![fit_item(recorded("", 300.0, 200.0, false))]); + assert_eq!(s.actions.list[0].steps[0].1, json!({"width": 300, "height": 200, "dontEnlarge": false})); + let item = export_item(FIT_IMAGE, &json!({"width": 300, "height": 200, "dontEnlarge": false}), "S").unwrap(); + assert_eq!(item.descriptor.map(|d| d.to_bytes()), Some(recorded("", 300.0, 200.0, false).to_bytes())); +} + +/// A loaded step fits the document into the recorded box, and Don't Enlarge keeps a smaller +/// document as it is. +#[test] +fn a_loaded_fit_image_step_plays() { + for (box_w, box_h, dont_enlarge, expected) in [ + (300.0, 300.0, false, (300, 200)), + (300.0, 300.0, true, (300, 200)), + (1200.0, 1200.0, false, (1200, 800)), + (1200.0, 1200.0, true, (600, 400)), + (900.0, 100.0, false, (150, 100)), + ] { + let (mut s, _) = load(vec![fit_item(recorded("Recorded message", box_w, box_h, dont_enlarge))]); + s.execute("file.new", json!({"width": 600, "height": 400})).unwrap(); + let played = s.execute("actions.play", json!({"action": "A"})).unwrap(); + assert_eq!(played["ran"], 1, "{played}"); + let size = s.active().unwrap().doc.size; + assert_eq!((size.width, size.height), expected, "box {box_w}×{box_h}, don't enlarge {dont_enlarge}"); + } +} + +/// Any other form keeps the reference step as it was: a missing, repeated or extra key, a side in +/// another unit or type, a side of zero (the native command reads it as "keep that side"), a +/// non-finite side, a message that isn't text, a limit that isn't a boolean. +#[test] +fn other_forms_keep_the_photoshop_step() { + let with = |key: &str, v: D| { + let mut d = recorded("m", 300.0, 200.0, false); + if let Some(slot) = d.items.iter_mut().find(|(k, _)| k.is(key)) { + slot.1 = v; + } + d + }; + let mut missing = recorded("m", 300.0, 200.0, false); + missing.items.retain(|(k, _)| !k.is("limit")); + let mut repeated = recorded("m", 300.0, 200.0, false); + repeated.items[3] = (Id::new("Hght"), D::UnitFloat { unit: *b"#Pxl", value: 200.0 }); + let forms = [ + missing, + repeated, + recorded("m", 300.0, 200.0, false).with("Rslt", D::UnitFloat { unit: *b"#Rsl", value: 72.0 }), + with("Wdth", D::UnitFloat { unit: *b"#Prc", value: 50.0 }), + with("Hght", D::UnitFloat { unit: *b"#Rlt", value: 200.0 }), + with("Wdth", D::Double(300.0)), + with("Hght", D::Integer(200)), + with("Wdth", D::UnitFloat { unit: *b"#Pxl", value: 0.0 }), + with("Hght", D::UnitFloat { unit: *b"#Pxl", value: -5.0 }), + with("Wdth", D::UnitFloat { unit: *b"#Pxl", value: f64::INFINITY }), + with("Msge", D::Integer(1)), + with("limit", D::Integer(1)), + Descriptor::new("null"), + ]; + for d in forms { + let (mut s, r) = load(vec![fit_item(d.clone())]); + assert_eq!(r["unsupported"].as_array().map(Vec::len), Some(1), "{d:?}"); + assert_eq!(s.actions.list[0].steps[0].0, PHOTOSHOP_STEP, "{d:?}"); + let back = saved(&mut s); + assert_eq!((&back[0].event, back[0].descriptor.as_ref().map(Descriptor::to_bytes)), (&AtnEvent::name(FIT_IMAGE_EVENT), Some(d.to_bytes()))); + } +} + +/// A native step saves only when the event holds all of it: both sides set, Don't Enlarge as a +/// boolean, and the default resampling, since the event has no key for another method. +#[test] +fn settings_the_event_cannot_hold_are_refused() { + let saves = |p: Value| export_item(FIT_IMAGE, &p, "S").and_then(|item| item.descriptor); + assert_eq!(saves(json!({"width": 800, "height": 600})), Some(recorded("", 800.0, 600.0, false))); + assert_eq!(saves(json!({"width": 800, "height": 600, "dontEnlarge": true, "resample": "bicubic"})), Some(recorded("", 800.0, 600.0, true))); + assert_eq!(saves(json!({"width": 800, "height": 600, "dontEnlarge": null, "resample": null})), Some(recorded("", 800.0, 600.0, false))); + for p in [ + json!({"width": 800, "height": 600, "resample": "nearest"}), + json!({"width": 800, "height": 600, "resample": "bicubicAutomatic"}), + json!({"width": 800}), + json!({"width": 800, "height": 0}), + json!({"width": null, "height": 600}), + json!({"width": "800", "height": 600}), + json!({"width": 800, "height": 600, "dontEnlarge": "true"}), + json!({"width": 800, "height": 600, "scaleStyles": true}), + ] { + assert_eq!(saves(p.clone()), None, "{p}"); + } +} + +/// A loaded step whose settings were changed saves the new settings with the recorded message, +/// not the stale recorded descriptor; an unchanged one saves the recording. +#[test] +fn changed_settings_save_with_the_recorded_message() { + let mut original = recorded("Recorded message", 1200.0, 900.0, true); + original.class_id = Id::new("cjsp"); + let (s, _) = load(vec![fit_item(original.clone())]); + let mut p = s.actions.list[0].steps[0].1.clone(); + assert!(p.get(SOURCE).is_some(), "{p}"); + assert_eq!(export_item(FIT_IMAGE, &p, "S").and_then(|item| item.descriptor), Some(original)); + p["width"] = json!(500); + p["dontEnlarge"] = json!(false); + assert_eq!(export_item(FIT_IMAGE, &p, "S").and_then(|item| item.descriptor), Some(recorded("Recorded message", 500.0, 900.0, false))); +} diff --git a/crates/engine/src/cmd/atn_cmds/events.rs b/crates/engine/src/cmd/atn_cmds/events.rs index 162bf22f8a..84d99361a1 100644 --- a/crates/engine/src/cmd/atn_cmds/events.rs +++ b/crates/engine/src/cmd/atn_cmds/events.rs @@ -1200,6 +1200,15 @@ pub(super) const EVENTS: &[Event] = &[ from: super::steps_batch::batch_from, to: super::steps_batch::batch_to, }, + // File › Automate › Fit Image: the script event recorded after the Image Size it runs (#10723). + Event { + code: "", + name: super::steps_automate::FIT_IMAGE_EVENT, + label: "Fit Image", + ids: &[super::steps_automate::FIT_IMAGE], + from: super::steps_automate::fit_image_from, + to: super::steps_automate::fit_image_to, + }, // Create Droplet (#10710): Photoshop has no string ID for it. Event { code: "CrtD", diff --git a/crates/engine/src/cmd/atn_cmds/steps_automate.rs b/crates/engine/src/cmd/atn_cmds/steps_automate.rs new file mode 100644 index 0000000000..d891dcb272 --- /dev/null +++ b/crates/engine/src/cmd/atn_cmds/steps_automate.rs @@ -0,0 +1,95 @@ +//! File › Automate › Fit Image, recorded as the script event `3caa3434-cb67-11d1-bc43-0060b0a13dc4` +//! after the Image Size it runs, as `docs/atn.md` ("Recorded with source files") measures it: +//! `Msge` text, `Hght` and `Wdth` in pixels, `limit` (Don't Enlarge), in that order (#10723). + +use super::descriptors::{PX, desc, jstr, px, text}; +use super::fields::{Mapped, step}; +use super::{D, Descriptor, Value, json}; + +/// The script event's string ID. +pub(super) const FIT_IMAGE_EVENT: &str = "3caa3434-cb67-11d1-bc43-0060b0a13dc4"; +/// The native command the event plays as. +pub(super) const FIT_IMAGE: &str = "file.automate.fitImage"; +/// The recorded descriptor, kept when it differs from the one [`fit_image_to`] would write +/// (its message, class, key order or ID encoding), so a load and save keeps the step unchanged. +const SOURCE: &str = "_atnFitImageDescriptor"; + +/// The Fit Image settings the event holds. +#[derive(Debug, PartialEq)] +struct Fit { + width: f64, + height: f64, + dont_enlarge: bool, +} + +/// A recorded box side: a pixel unit float the native command reads as set (positive, finite). +fn side(v: &D) -> Option { + match v { + D::UnitFloat { unit, value } if *unit == PX && value.is_finite() && *value > 0.0 => Some(*value), + _ => None, + } +} + +/// The measured form only: each of the four keys exactly once and nothing else. Another unit, +/// a box side of zero (which the native command reads as "keep that side") or an extra key +/// keeps the whole step as recorded. +fn recorded(d: &Descriptor) -> Option { + if d.items.len() != 4 || ["Msge", "Hght", "Wdth", "limit"].iter().any(|key| d.items.iter().filter(|(k, _)| k.is(key)).count() != 1) { + return None; + } + let D::Text(_) = d.get("Msge")? else { return None }; + let D::Boolean(dont_enlarge) = d.get("limit")? else { return None }; + Some(Fit { width: side(d.get("Wdth")?)?, height: side(d.get("Hght")?)?, dont_enlarge: *dont_enlarge }) +} + +/// The event's descriptor for `fit` with `message` as its `Msge`. +fn descriptor(fit: &Fit, message: D) -> Descriptor { + desc("null").with("Msge", message).with("Hght", px(fit.height)).with("Wdth", px(fit.width)).with("limit", D::Boolean(fit.dont_enlarge)) +} + +/// The descriptor a step recorded in PhotoCraft saves as. The message is empty: it is the +/// script's own text, not a setting, and no text of the reference application is copied. +fn canonical(fit: &Fit) -> Descriptor { + descriptor(fit, text("")) +} + +/// Map the measured form; any other stays a complete kept step. +pub(super) fn fit_image_from(d: &Descriptor) -> Mapped { + let Some(fit) = recorded(d) else { return Mapped::Unsupported { harmless: false } }; + let mut p = json!({"width": fit.width, "height": fit.height, "dontEnlarge": fit.dont_enlarge}); + if *d != canonical(&fit) { + p[SOURCE] = photocraft_paint::tile::b64_encode(&d.to_bytes()).into(); + } + step(FIT_IMAGE, p) +} + +/// The settings of a native step, when the event can hold all of them: both box sides set, and +/// the default resampling, since the event has no key for another method. A missing, zero or +/// `null` side plays as the document's own size, which the event cannot carry either. +fn native(p: &Value) -> Option { + let m = p.as_object()?; + if m.keys().any(|key| !["width", "height", "dontEnlarge", "resample", SOURCE].contains(&key.as_str())) { + return None; + } + if !matches!(m.get("resample"), None | Some(Value::Null)) && jstr(p, "resample") != Some("bicubic") { + return None; + } + let side = |key: &str| m.get(key).and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0); + let dont_enlarge = match m.get("dontEnlarge") { + None | Some(Value::Null) => false, + Some(v) => v.as_bool()?, + }; + Some(Fit { width: side("width")?, height: side("height")?, dont_enlarge }) +} + +/// Save the recorded descriptor while the step still holds its settings; once they were +/// changed, write the new settings with the recorded message. +pub(super) fn fit_image_to(_: &str, p: &Value) -> Option { + let fit = native(p)?; + let Some(source) = jstr(p, SOURCE) else { return Some(canonical(&fit)) }; + let source = Descriptor::from_bytes(&photocraft_paint::tile::b64_decode(source)?).ok()?; + if recorded(&source)? == fit { + return Some(source); + } + Some(descriptor(&fit, source.get("Msge")?.clone())) +} diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index 27b6f6500b..aaf294fb48 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -863,6 +863,7 @@ fn every_mapped_command_exports_and_imports_back() { ("layer.videoLayers.deleteFrame", json!({})), ("file.automate.cropAndStraightenPhotos", json!({})), ("file.automate.cropAndStraightenPhotos", json!({"_atnNoTarget": true})), + ("file.automate.fitImage", json!({"width": 800, "height": 600, "dontEnlarge": true})), ("layer.new.artboardFromGroup", json!({})), ("layer.new.artboardFromGroup", json!({"background":"white","color":[1,1,1],"preset":"","guideIDs":[],"changeBackground":1})), ( diff --git a/docs/atn.md b/docs/atn.md index be7eca3787..38c9783b1d 100644 --- a/docs/atn.md +++ b/docs/atn.md @@ -239,6 +239,7 @@ row; the action still adds only one Undo entry. | (string ID only) | textSaveDefaultTypeStyles | Save Default Type Styles | `type.saveDefaultTypeStyles` | | (string ID only) | recordMeasurements | Record Measurements | `image.analysis.recordMeasurements` | | (string ID only) | CropPhotosAuto0001 | Crop and Straighten Photos | `file.automate.cropAndStraightenPhotos` | +| (string ID only) | 3caa3434-cb67-11d1-bc43-0060b0a13dc4 | Fit Image (script event; pixel box sides) | `file.automate.fitImage` | | (string ID only) | placedLayerExportContents | Export Contents | `layer.smartObjects.exportContents` | | (string ID only) | placedLayerConvertToLinked | Convert to Linked | `layer.smartObjects.convertToLinked` | | (string ID only) | placedLayerRelinkToFile | Relink to File | `layer.smartObjects.relinkToFile` | @@ -315,6 +316,18 @@ The separate `CropPhotos0001` helper remains a complete kept Photoshop step. Playback stops at that enabled helper before subsequent duplicate/crop steps can run. This covers the parent event, not complete recordings with generated helpers. +Fit Image is the script event `3caa3434-cb67-11d1-bc43-0060b0a13dc4` that follows +the Image Size step it runs, which stays a step of its own. `Wdth` and +`Hght` (pixel unit floats above 0) and `limit` (Don't Enlarge) play as +`file.automate.fitImage`, which leaves a document that already fits unchanged. +`Msge` is the script's own text, not a setting: a loaded step saves back with its +recorded descriptor (message, class, key order), and once its settings change it +saves them with the recorded message. A step recorded in PhotoCraft saves an empty +`Msge` and class `null`. Another unit, a side of 0, a missing or extra key stays a +kept step. A native step with a missing or 0 side (the document's own size) or a +resampling method other than the default refuses export, because the event has no +key for it. + ### Perspective Warp recordings `perspectiveWarpTransform` maps Layout and Warp to one `edit.perspectiveWarp` From 18f2a81b87af8641f65da7dd3f9f591e03854398 Mon Sep 17 00:00:00 2001 From: ljkx <177671075+ljkx@users.noreply.github.com> Date: Mon, 9 Nov 2026 04:49:43 +0100 Subject: [PATCH 08/31] Fit Image: a damaged recorded descriptor no longer stops the step saving A step whose settings the event holds now saves even when its _atnFitImageDescriptor is missing a decodable measured form: it saves as a step recorded in PhotoCraft would (CodeRabbit review). --- .../engine/src/cmd/atn_cmds/automate_tests.rs | 11 ++++++++++ .../engine/src/cmd/atn_cmds/steps_automate.rs | 21 ++++++++++++------- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/cmd/atn_cmds/automate_tests.rs b/crates/engine/src/cmd/atn_cmds/automate_tests.rs index 0b36b78413..95d9524e98 100644 --- a/crates/engine/src/cmd/atn_cmds/automate_tests.rs +++ b/crates/engine/src/cmd/atn_cmds/automate_tests.rs @@ -172,3 +172,14 @@ fn changed_settings_save_with_the_recorded_message() { p["dontEnlarge"] = json!(false); assert_eq!(export_item(FIT_IMAGE, &p, "S").and_then(|item| item.descriptor), Some(recorded("Recorded message", 500.0, 900.0, false))); } + +/// A damaged recorded descriptor doesn't stop a step with valid settings from saving: it saves +/// as a step recorded in PhotoCraft would (CodeRabbit review on steps_automate.rs:87-95). +#[test] +fn a_damaged_recorded_descriptor_saves_the_settings() { + let other_event = photocraft_paint::tile::b64_encode(&desc("null").with("Rds ", px(2.0)).to_bytes()); + for damaged in [json!("not base64!"), json!(photocraft_paint::tile::b64_encode(b"not a descriptor")), json!(other_event), json!(7), json!(null)] { + let p = json!({"width": 800, "height": 600, "dontEnlarge": true, SOURCE: damaged}); + assert_eq!(export_item(FIT_IMAGE, &p, "S").and_then(|item| item.descriptor), Some(recorded("", 800.0, 600.0, true)), "{p}"); + } +} diff --git a/crates/engine/src/cmd/atn_cmds/steps_automate.rs b/crates/engine/src/cmd/atn_cmds/steps_automate.rs index d891dcb272..ed08ff4fd8 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_automate.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_automate.rs @@ -82,14 +82,21 @@ fn native(p: &Value) -> Option { Some(Fit { width: side("width")?, height: side("height")?, dont_enlarge }) } +/// The recorded descriptor carried in `SOURCE`, when it decodes to the measured form. +fn source(p: &Value) -> Option<(Descriptor, Fit)> { + let d = Descriptor::from_bytes(&photocraft_paint::tile::b64_decode(jstr(p, SOURCE)?)?).ok()?; + let fit = recorded(&d)?; + Some((d, fit)) +} + /// Save the recorded descriptor while the step still holds its settings; once they were -/// changed, write the new settings with the recorded message. +/// changed, write the new settings with the recorded message. The settings decide whether the +/// step saves: a recorded descriptor that is missing or damaged only loses its message. pub(super) fn fit_image_to(_: &str, p: &Value) -> Option { let fit = native(p)?; - let Some(source) = jstr(p, SOURCE) else { return Some(canonical(&fit)) }; - let source = Descriptor::from_bytes(&photocraft_paint::tile::b64_decode(source)?).ok()?; - if recorded(&source)? == fit { - return Some(source); - } - Some(descriptor(&fit, source.get("Msge")?.clone())) + Some(match source(p) { + Some((d, recorded)) if recorded == fit => d, + Some((d, _)) => descriptor(&fit, d.get("Msge")?.clone()), + None => canonical(&fit), + }) } From 7324560bdf401300967936f855bbe7b61e368cbf Mon Sep 17 00:00:00 2001 From: ljkx <177671075+ljkx@users.noreply.github.com> Date: Mon, 9 Nov 2026 06:12:16 +0100 Subject: [PATCH 09/31] Fit Image test: a damaged recorded descriptor with a message keeps neither Covers a recorded descriptor that still has its Msge but lacks limit: the step saves the canonical form with an empty message (CodeRabbit nitpick). --- crates/engine/src/cmd/atn_cmds/automate_tests.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/atn_cmds/automate_tests.rs b/crates/engine/src/cmd/atn_cmds/automate_tests.rs index 95d9524e98..4cc2e49501 100644 --- a/crates/engine/src/cmd/atn_cmds/automate_tests.rs +++ b/crates/engine/src/cmd/atn_cmds/automate_tests.rs @@ -178,7 +178,13 @@ fn changed_settings_save_with_the_recorded_message() { #[test] fn a_damaged_recorded_descriptor_saves_the_settings() { let other_event = photocraft_paint::tile::b64_encode(&desc("null").with("Rds ", px(2.0)).to_bytes()); - for damaged in [json!("not base64!"), json!(photocraft_paint::tile::b64_encode(b"not a descriptor")), json!(other_event), json!(7), json!(null)] { + // A message beside a broken Fit Image field: neither the descriptor nor its message is kept. + let mut missing_limit = recorded("Recorded message", 800.0, 600.0, true); + missing_limit.items.retain(|(k, _)| !k.is("limit")); + let missing_limit = photocraft_paint::tile::b64_encode(&missing_limit.to_bytes()); + for damaged in + [json!("not base64!"), json!(photocraft_paint::tile::b64_encode(b"not a descriptor")), json!(other_event), json!(missing_limit), json!(7), json!(null)] + { let p = json!({"width": 800, "height": 600, "dontEnlarge": true, SOURCE: damaged}); assert_eq!(export_item(FIT_IMAGE, &p, "S").and_then(|item| item.descriptor), Some(recorded("", 800.0, 600.0, true)), "{p}"); } From 6da1eedcc5be8b3c58e4ca5133e50149b1bee10b Mon Sep 17 00:00:00 2001 From: ljkx <177671075+ljkx@users.noreply.github.com> Date: Mon, 9 Nov 2026 15:08:27 +0100 Subject: [PATCH 10/31] Fit Image: class cjsp and an optional Msge, as measured (#10723) davidmann4 measured the event in the reference application: the descriptor's class is cjsp with an empty class name, and the step plays the same with Msge recorded, empty, unrelated or removed. - A step recorded in PhotoCraft now writes class cjsp (it wrote null). - The three-key form without Msge now maps instead of staying kept. - A loaded step whose settings changed saves them into its recorded descriptor, so its class, its message or lack of one and its key order stay. --- .../engine/src/cmd/atn_cmds/automate_tests.rs | 94 +++++++++++++++---- .../engine/src/cmd/atn_cmds/steps_automate.rs | 51 ++++++---- docs/atn.md | 17 ++-- 3 files changed, 121 insertions(+), 41 deletions(-) diff --git a/crates/engine/src/cmd/atn_cmds/automate_tests.rs b/crates/engine/src/cmd/atn_cmds/automate_tests.rs index 4cc2e49501..dc778e1142 100644 --- a/crates/engine/src/cmd/atn_cmds/automate_tests.rs +++ b/crates/engine/src/cmd/atn_cmds/automate_tests.rs @@ -1,5 +1,5 @@ -//! #10723: File › Automate › Fit Image steps, the script event `docs/atn.md` measures ("Recorded -//! with source files"): `Msge`, `Hght` and `Wdth` in pixels, `limit` (Don't Enlarge). +//! #10723: File › Automate › Fit Image steps, the script event measured in the reference +//! application: class `cjsp`, then `Msge`, `Hght` and `Wdth` in pixels, `limit` (Don't Enlarge). use super::steps_automate::{FIT_IMAGE, FIT_IMAGE_EVENT}; use super::*; @@ -8,7 +8,7 @@ const SOURCE: &str = "_atnFitImageDescriptor"; /// The measured descriptor, built here independently of the converter. fn recorded(message: &str, width: f64, height: f64, dont_enlarge: bool) -> Descriptor { - Descriptor::new("null") + Descriptor::new("cjsp") .with("Msge", D::Text(UnicodeString::new_nul(message))) .with("Hght", D::UnitFloat { unit: *b"#Pxl", value: height }) .with("Wdth", D::UnitFloat { unit: *b"#Pxl", value: width }) @@ -43,13 +43,13 @@ fn saved(s: &mut Session) -> Vec { /// byte for byte: the message, the descriptor class and the key order are the recording's. #[test] fn a_recorded_fit_image_step_loads_and_saves_back_unchanged() { - let mut cjsp = recorded("Recorded message", 1200.0, 900.0, true); - cjsp.class_id = Id::new("cjsp"); + let mut other_class = recorded("Recorded message", 1200.0, 900.0, true); + other_class.class_id = Id::new("null"); let mut reordered = recorded("", 640.5, 480.0, false); reordered.items.reverse(); for (d, width, height, dont_enlarge) in [ (recorded("Recorded message", 1200.0, 900.0, true), 1200.0, 900.0, true), - (cjsp, 1200.0, 900.0, true), + (other_class, 1200.0, 900.0, true), (reordered, 640.5, 480.0, false), (recorded("", 300.0, 200.0, false), 300.0, 200.0, false), ] { @@ -76,6 +76,49 @@ fn the_canonical_form_carries_only_the_settings() { assert_eq!(item.descriptor.map(|d| d.to_bytes()), Some(recorded("", 300.0, 200.0, false).to_bytes())); } +/// A step recorded in PhotoCraft writes the measured descriptor byte for byte: an empty class name, +/// class `cjsp`, then `Msge` TEXT, `Hght` and `Wdth` UntF `#Pxl`, `limit` bool under a long key; the +/// step is enabled, without its dialog, with a descriptor (flag -1). +#[test] +fn a_step_recorded_in_photocraft_writes_the_measured_descriptor() { + let item = export_item(FIT_IMAGE, &json!({"width": 250, "height": 180, "dontEnlarge": true}), "S").unwrap(); + assert_eq!((item.enabled, item.with_dialog, &item.event), (true, false, &AtnEvent::name(FIT_IMAGE_EVENT))); + let mut expected = Vec::new(); + expected.extend_from_slice(&0u32.to_be_bytes()); // class name: no UTF-16 units + expected.extend_from_slice(b"\0\0\0\0cjsp"); // class id as a 4-character code + expected.extend_from_slice(&4u32.to_be_bytes()); + expected.extend_from_slice(b"\0\0\0\0MsgeTEXT\0\0\0\x01\0\0"); // one UTF-16 unit: the NUL + expected.extend_from_slice(b"\0\0\0\0HghtUntF#Pxl"); + expected.extend_from_slice(&180f64.to_be_bytes()); + expected.extend_from_slice(b"\0\0\0\0WdthUntF#Pxl"); + expected.extend_from_slice(&250f64.to_be_bytes()); + expected.extend_from_slice(b"\0\0\0\x05limitbool\x01"); + assert_eq!(item.descriptor.as_ref().map(Descriptor::to_bytes), Some(expected)); +} + +/// The reference plays the step alike whatever its `Msge` holds, or without one (measured on a new +/// 400 x 300 document with a 250 x 180 box and Don't Enlarge: 240 x 180 every time). Each of those +/// forms loads as the native step, plays to that size and saves back byte for byte. +#[test] +fn every_measured_message_form_maps_and_plays_alike() { + let mut removed = recorded("", 250.0, 180.0, true); + removed.items.retain(|(k, _)| !k.is("Msge")); + for d in + [recorded("Fit Image action settings", 250.0, 180.0, true), recorded("", 250.0, 180.0, true), removed, recorded("unrelated text", 250.0, 180.0, true)] + { + let (mut s, r) = load(vec![fit_item(d.clone())]); + assert_eq!(r["unsupported"], json!([]), "{d:?}"); + let (id, p) = &s.actions.list[0].steps[0]; + assert_eq!((id.as_str(), p["width"].as_f64(), p["height"].as_f64(), &p["dontEnlarge"]), (FIT_IMAGE, Some(250.0), Some(180.0), &json!(true)), "{d:?}"); + s.execute("file.new", json!({"width": 400, "height": 300})).unwrap(); + let played = s.execute("actions.play", json!({"action": "A"})).unwrap(); + assert_eq!(played["ran"], 1, "{played}"); + let size = s.active().unwrap().doc.size; + assert_eq!((size.width, size.height), (240, 180), "{d:?}"); + assert_eq!(saved(&mut s)[0].descriptor.as_ref().map(Descriptor::to_bytes), Some(d.to_bytes())); + } +} + /// A loaded step fits the document into the recorded box, and Don't Enlarge keeps a smaller /// document as it is. #[test] @@ -125,7 +168,9 @@ fn other_forms_keep_the_photoshop_step() { with("Wdth", D::UnitFloat { unit: *b"#Pxl", value: f64::INFINITY }), with("Msge", D::Integer(1)), with("limit", D::Integer(1)), - Descriptor::new("null"), + recorded("m", 300.0, 200.0, false).with("Msge", D::Text(UnicodeString::new_nul("again"))), + Descriptor::new("cjsp").with("Hght", D::UnitFloat { unit: *b"#Pxl", value: 200.0 }).with("Wdth", D::UnitFloat { unit: *b"#Pxl", value: 300.0 }), + Descriptor::new("cjsp"), ]; for d in forms { let (mut s, r) = load(vec![fit_item(d.clone())]); @@ -158,19 +203,32 @@ fn settings_the_event_cannot_hold_are_refused() { } } -/// A loaded step whose settings were changed saves the new settings with the recorded message, -/// not the stale recorded descriptor; an unchanged one saves the recording. +/// A loaded step whose settings were changed saves the new settings in the recorded descriptor +/// (its message or lack of one, class and key order), not the stale recorded settings; an +/// unchanged one saves the recording. #[test] fn changed_settings_save_with_the_recorded_message() { - let mut original = recorded("Recorded message", 1200.0, 900.0, true); - original.class_id = Id::new("cjsp"); - let (s, _) = load(vec![fit_item(original.clone())]); - let mut p = s.actions.list[0].steps[0].1.clone(); - assert!(p.get(SOURCE).is_some(), "{p}"); - assert_eq!(export_item(FIT_IMAGE, &p, "S").and_then(|item| item.descriptor), Some(original)); - p["width"] = json!(500); - p["dontEnlarge"] = json!(false); - assert_eq!(export_item(FIT_IMAGE, &p, "S").and_then(|item| item.descriptor), Some(recorded("Recorded message", 500.0, 900.0, false))); + let without_message = |mut d: Descriptor| { + d.items.retain(|(k, _)| !k.is("Msge")); + d + }; + let mut other_class = recorded("Recorded message", 1200.0, 900.0, true); + other_class.class_id = Id::new("null"); + let mut changed_other_class = recorded("Recorded message", 500.0, 900.0, false); + changed_other_class.class_id = Id::new("null"); + for (original, changed) in [ + (recorded("Recorded message", 1200.0, 900.0, true), recorded("Recorded message", 500.0, 900.0, false)), + (without_message(recorded("", 1200.0, 900.0, true)), without_message(recorded("", 500.0, 900.0, false))), + (other_class, changed_other_class), + ] { + let (s, _) = load(vec![fit_item(original.clone())]); + let mut p = s.actions.list[0].steps[0].1.clone(); + assert!(p.get(SOURCE).is_some(), "{p}"); + assert_eq!(export_item(FIT_IMAGE, &p, "S").and_then(|item| item.descriptor), Some(original)); + p["width"] = json!(500); + p["dontEnlarge"] = json!(false); + assert_eq!(export_item(FIT_IMAGE, &p, "S").and_then(|item| item.descriptor), Some(changed)); + } } /// A damaged recorded descriptor doesn't stop a step with valid settings from saving: it saves diff --git a/crates/engine/src/cmd/atn_cmds/steps_automate.rs b/crates/engine/src/cmd/atn_cmds/steps_automate.rs index ed08ff4fd8..0dacb7d6aa 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_automate.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_automate.rs @@ -1,6 +1,8 @@ //! File › Automate › Fit Image, recorded as the script event `3caa3434-cb67-11d1-bc43-0060b0a13dc4` -//! after the Image Size it runs, as `docs/atn.md` ("Recorded with source files") measures it: -//! `Msge` text, `Hght` and `Wdth` in pixels, `limit` (Don't Enlarge), in that order (#10723). +//! after the Image Size it runs, as measured in the reference application (#10723): class `cjsp` +//! with an empty class name, then `Msge` text, `Hght` and `Wdth` in pixels and `limit` +//! (Don't Enlarge, a long key), in that order. `Msge` isn't needed to play the step: the +//! reference plays it the same with the key removed. use super::descriptors::{PX, desc, jstr, px, text}; use super::fields::{Mapped, step}; @@ -10,6 +12,8 @@ use super::{D, Descriptor, Value, json}; pub(super) const FIT_IMAGE_EVENT: &str = "3caa3434-cb67-11d1-bc43-0060b0a13dc4"; /// The native command the event plays as. pub(super) const FIT_IMAGE: &str = "file.automate.fitImage"; +/// The class the reference application records the event's descriptor with. +const CLASS: &str = "cjsp"; /// The recorded descriptor, kept when it differs from the one [`fit_image_to`] would write /// (its message, class, key order or ID encoding), so a load and save keeps the step unchanged. const SOURCE: &str = "_atnFitImageDescriptor"; @@ -30,27 +34,42 @@ fn side(v: &D) -> Option { } } -/// The measured form only: each of the four keys exactly once and nothing else. Another unit, -/// a box side of zero (which the native command reads as "keep that side") or an extra key -/// keeps the whole step as recorded. +/// The measured form only: `Hght`, `Wdth` and `limit` exactly once, `Msge` at most once (the +/// reference plays a step without it), and nothing else. Another unit, a box side of zero (which +/// the native command reads as "keep that side") or an extra key keeps the whole step as recorded. fn recorded(d: &Descriptor) -> Option { - if d.items.len() != 4 || ["Msge", "Hght", "Wdth", "limit"].iter().any(|key| d.items.iter().filter(|(k, _)| k.is(key)).count() != 1) { + let count = |key: &str| d.items.iter().filter(|(k, _)| k.is(key)).count(); + let messages = count("Msge"); + if messages > 1 || d.items.len() != 3 + messages || ["Hght", "Wdth", "limit"].iter().any(|key| count(key) != 1) { + return None; + } + if !matches!(d.get("Msge"), None | Some(D::Text(_))) { return None; } - let D::Text(_) = d.get("Msge")? else { return None }; let D::Boolean(dont_enlarge) = d.get("limit")? else { return None }; Some(Fit { width: side(d.get("Wdth")?)?, height: side(d.get("Hght")?)?, dont_enlarge: *dont_enlarge }) } -/// The event's descriptor for `fit` with `message` as its `Msge`. -fn descriptor(fit: &Fit, message: D) -> Descriptor { - desc("null").with("Msge", message).with("Hght", px(fit.height)).with("Wdth", px(fit.width)).with("limit", D::Boolean(fit.dont_enlarge)) +/// The descriptor a step recorded in PhotoCraft saves as: the measured class and key order. The +/// message is empty: it is the script's own text, not a setting, and no text of the reference +/// application is copied. +fn canonical(fit: &Fit) -> Descriptor { + desc(CLASS).with("Msge", text("")).with("Hght", px(fit.height)).with("Wdth", px(fit.width)).with("limit", D::Boolean(fit.dont_enlarge)) } -/// The descriptor a step recorded in PhotoCraft saves as. The message is empty: it is the -/// script's own text, not a setting, and no text of the reference application is copied. -fn canonical(fit: &Fit) -> Descriptor { - descriptor(fit, text("")) +/// The recorded descriptor `d` holding `fit`'s settings instead of its own. Everything else stays +/// the recording's: its class, its message or the lack of one, and its key order and encoding. +fn with_settings(mut d: Descriptor, fit: &Fit) -> Descriptor { + for (key, value) in &mut d.items { + if key.is("Hght") { + *value = px(fit.height); + } else if key.is("Wdth") { + *value = px(fit.width); + } else if key.is("limit") { + *value = D::Boolean(fit.dont_enlarge); + } + } + d } /// Map the measured form; any other stays a complete kept step. @@ -90,13 +109,13 @@ fn source(p: &Value) -> Option<(Descriptor, Fit)> { } /// Save the recorded descriptor while the step still holds its settings; once they were -/// changed, write the new settings with the recorded message. The settings decide whether the +/// changed, write the new settings into the recorded descriptor. The settings decide whether the /// step saves: a recorded descriptor that is missing or damaged only loses its message. pub(super) fn fit_image_to(_: &str, p: &Value) -> Option { let fit = native(p)?; Some(match source(p) { Some((d, recorded)) if recorded == fit => d, - Some((d, _)) => descriptor(&fit, d.get("Msge")?.clone()), + Some((d, _)) => with_settings(d, &fit), None => canonical(&fit), }) } diff --git a/docs/atn.md b/docs/atn.md index 38c9783b1d..1d42e8f5c2 100644 --- a/docs/atn.md +++ b/docs/atn.md @@ -320,13 +320,16 @@ Fit Image is the script event `3caa3434-cb67-11d1-bc43-0060b0a13dc4` that follow the Image Size step it runs, which stays a step of its own. `Wdth` and `Hght` (pixel unit floats above 0) and `limit` (Don't Enlarge) play as `file.automate.fitImage`, which leaves a document that already fits unchanged. -`Msge` is the script's own text, not a setting: a loaded step saves back with its -recorded descriptor (message, class, key order), and once its settings change it -saves them with the recorded message. A step recorded in PhotoCraft saves an empty -`Msge` and class `null`. Another unit, a side of 0, a missing or extra key stays a -kept step. A native step with a missing or 0 side (the document's own size) or a -resampling method other than the default refuses export, because the event has no -key for it. +The descriptor's class is `cjsp` with an empty class name (measured, in two UI +languages). `Msge` is the script's own text, not a setting: the reference plays the +step the same with the recorded text, an empty one, unrelated text or the key +removed, so the three-key form without `Msge` maps too. A loaded step saves back +with its recorded descriptor (message or none, class, key order), and once its +settings change it saves them into that descriptor. A step recorded in PhotoCraft +saves class `cjsp` and an empty `Msge`. Another unit, a side of 0, a repeated `Msge`, +a missing `Wdth`, `Hght` or `limit`, or an extra key stays a kept step. A native +step with a missing or 0 side (the document's own size) or a resampling method +other than the default refuses export, because the event has no key for it. ### Perspective Warp recordings From 7e40b73f3bc73d7af07803e368f7bf8f176a5507 Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 9 Nov 2026 19:38:11 +0100 Subject: [PATCH 11/31] Black & White Auto: read a smart filter's input, refuse oversized canvases (#9301) Reopening a Black & White smart filter ran Auto on the smart object's filtered cache, which that filter has already turned grey, so Auto always answered the default mix. The dialog now passes the filter index and the command reads the pixels below that filter, as the reference application does. A canvas past the render limit rendered nothing and also gave the default mix; it is now an error. Selection, the adjustment's mask and its clip base stay out of the analysis: measured in the reference application, none of them changes its Auto result. Co-Authored-By: DuBearte Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/adjust_cmds.rs | 40 ++++++++--- crates/engine/src/cmd/adjust_cmds/tests.rs | 69 +++++++++++++++++++ crates/ui-egui/src/dialogs/adjust_dialog.rs | 4 +- .../src/dialogs/adjust_editors/basic.rs | 12 ++-- .../src/dialogs/adjust_editors/session.rs | 2 +- 5 files changed, 111 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/cmd/adjust_cmds.rs b/crates/engine/src/cmd/adjust_cmds.rs index 5774e608f8..b9a144075c 100644 --- a/crates/engine/src/cmd/adjust_cmds.rs +++ b/crates/engine/src/cmd/adjust_cmds.rs @@ -639,7 +639,7 @@ pub fn specs() -> Vec { label: "Black & White Auto", menu: &[], shortcut: None, - params: r##"{"layer":id?} → {"reds","yellows","greens","cyans","blues","magentas"} (%: the Auto mix for the image a Black & White adjustment layer sees, or for a pixel layer's own pixels; nothing changes)"##, + params: r##"{"layer":id?,"filter":index?} → {"reds","yellows","greens","cyans","blues","magentas"} (%: the Auto mix for the image a Black & White adjustment layer sees, a smart object's pixels below its smart filter `filter`, or a pixel layer's own pixels; nothing changes)"##, enabled: has_doc, run: black_white_auto, journal: false, @@ -734,8 +734,10 @@ fn hide_from(layers: &mut [Layer], id: photocraft_doc::LayerId) -> bool { } /// `image.adjustments.blackWhite.auto`: [`auto_black_white`] for `layer` (default the active one): -/// the composite of what lies below a Black & White adjustment layer (what it adjusts), or a pixel -/// layer's own pixels (what Image › Adjustments › Black & White changes). +/// the composite of what lies below a Black & White adjustment layer (what it adjusts), a smart +/// object's pixels below smart filter `filter` (what that filter edits), or a pixel layer's own +/// pixels (what Image › Adjustments › Black & White changes). As in the reference application, +/// the whole canvas counts: a selection, the adjustment's mask and its clip base don't narrow it. fn black_white_auto(s: &mut Session, p: &Value) -> Result { let cmd = "image.adjustments.blackWhite.auto"; let st = s.active().ok_or(EngineError::NoDocument)?; @@ -743,19 +745,37 @@ fn black_white_auto(s: &mut Session, p: &Value) -> Result { None | Some(Value::Null) => st.active_layer.ok_or_else(|| EngineError::Other("no active layer".into()))?, Some(v) => photocraft_doc::LayerId(v.as_u64().ok_or_else(|| bad(cmd, "`layer` must be a layer id"))?), }; + let filter = match p.get("filter") { + None | Some(Value::Null) => None, + Some(v) => Some(v.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad(cmd, "`filter` must be a smart filter index"))?), + }; let layer = st.doc.layer(id).ok_or(EngineError::NoLayer(id))?; let area = st.doc.bounds(); - let rgba: Vec<[f32; 4]> = match &layer.content { - LayerContent::Adjustment(_) => { + // Rendering or reading a canvas past the render limit gives nothing to sample: say so rather + // than answer with the default mix. + if u64::from(area.width()) * u64::from(area.height()) > photocraft_compose::MAX_RENDER_PIXELS { + return Err(bad(cmd, "the canvas is too large to analyse")); + } + let read = |surf: &Surface| -> Vec<[f32; 4]> { + let fmt = surf.format(); + surf.read_region(area).chunks_exact(fmt.channels()).map(|c| to_rgba(&fmt, c)).collect() + }; + let rgba: Vec<[f32; 4]> = match (&layer.content, filter) { + (LayerContent::Smart(sm), Some(i)) => { + if i >= sm.smart_filters.len() { + return Err(bad(cmd, format!("layer \"{}\" has no smart filter {i}", layer.name))); + } + let below = crate::smart_cmds::render_below_filter(&st.doc, sm, i)? + .ok_or_else(|| EngineError::Other("the smart object's contents are unavailable".into()))?; + read(&below) + } + (_, Some(_)) => return Err(bad(cmd, "`filter` needs a smart object layer")), + (LayerContent::Adjustment(_), None) => { let mut below = (*st.doc).clone(); hide_from(&mut below.layers, id); photocraft_compose::render(&below, area).into_px() } - _ => { - let surf = layer.surface().ok_or_else(|| EngineError::Other(format!("layer \"{}\" has no pixels", layer.name)))?; - let fmt = surf.format(); - surf.read_region(area).chunks_exact(fmt.channels()).map(|c| to_rgba(&fmt, c)).collect() - } + (_, None) => read(layer.surface().ok_or_else(|| EngineError::Other(format!("layer \"{}\" has no pixels", layer.name)))?), }; let w = auto_black_white(&auto_bw_sample(&rgba)); Ok(Value::Object(crate::adjust_params::HUE_RANGES.iter().zip(w).map(|(k, v)| ((*k).to_string(), json!(v))).collect())) diff --git a/crates/engine/src/cmd/adjust_cmds/tests.rs b/crates/engine/src/cmd/adjust_cmds/tests.rs index b228f333b9..0efa30e149 100644 --- a/crates/engine/src/cmd/adjust_cmds/tests.rs +++ b/crates/engine/src/cmd/adjust_cmds/tests.rs @@ -1124,3 +1124,72 @@ fn black_white_auto_spreads_the_greys() { assert_ne!(got, BW_DEFAULTS.to_vec(), "only one colour was sampled"); assert!(gap(&got.clone().try_into().unwrap()) > 1.0 / 32.0, "{got:?}"); } + +/// #9301 review: as in the reference application, Auto reads the whole canvas whatever the +/// selection, the adjustment's mask or its clip base; a smart filter's Auto reads the pixels below +/// that filter, not the filtered result; a canvas too large to analyse is an error, not the +/// default mix. +#[test] +fn black_white_auto_reads_the_whole_input() { + use crate::adjust_params::{BW_DEFAULTS, HUE_RANGES}; + let (red, blue) = ([0.776, 0.2, 0.2, 1.0], [0.29, 0.29, 1.0, 1.0]); + let mix = auto_black_white(&[[red[0], red[1], red[2]], [blue[0], blue[1], blue[2]]]); + assert_ne!(mix, BW_DEFAULTS); + let auto = |s: &mut Session, p: Value| -> Vec { + let r = s.execute("image.adjustments.blackWhite.auto", p).unwrap(); + HUE_RANGES.iter().map(|k| r[*k].as_f64().unwrap() as f32).collect() + }; + let split = |x: i32, _| if x < 10 { red } else { blue }; + + // A selection over the red half doesn't narrow Image › Adjustments' Auto. + let mut s = Session::new(); + s.execute("file.new", json!({"width": 20, "height": 10})).unwrap(); + paint(&mut s, split); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + assert_eq!(auto(&mut s, json!({})), mix.to_vec()); + + // Nor does an adjustment layer's mask hiding the blue half. + s.execute("select.deselect", json!({})).unwrap(); + let id = s.execute("layer.newAdjustmentLayer.blackWhite", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.edit("mask", |doc, _| { + let b = doc.bounds(); + let mut m = photocraft_doc::LayerMask::reveal_all(); + m.surface.write_region(Rect::new(10, 0, b.x1, b.y1), &vec![0.0; 10 * 10]); + doc.layer_mut(photocraft_doc::LayerId(id)).unwrap().mask = Some(m); + Ok(()) + }) + .unwrap(); + assert_eq!(auto(&mut s, json!({"layer": id})), mix.to_vec()); + + // Nor clipping it to a layer that only covers the red half. + let mut s = Session::new(); + s.execute("file.new", json!({"width": 20, "height": 10})).unwrap(); + paint(&mut s, |_, _| blue); + s.execute("layer.new.layer", json!({})).unwrap(); + paint(&mut s, |x, _| if x < 10 { red } else { [0.0; 4] }); + let id = s.execute("layer.newAdjustmentLayer.blackWhite", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.createClippingMask", json!({})).unwrap(); + assert!(s.active().unwrap().doc.layer(photocraft_doc::LayerId(id)).unwrap().clipped); + assert_eq!(auto(&mut s, json!({"layer": id})), mix.to_vec()); + + // A Black & White smart filter: its own result is grey, its input is the colours. + let mut s = Session::new(); + s.execute("file.new", json!({"width": 20, "height": 10})).unwrap(); + paint(&mut s, split); + s.execute("filter.convertForSmartFilters", json!({})).unwrap(); + s.execute("image.adjustments.blackWhite", json!({})).unwrap(); + let smart = s.active().unwrap().active_layer.unwrap().0; + assert_eq!(auto(&mut s, json!({"layer": smart})), BW_DEFAULTS.to_vec(), "the filtered result is grey"); + assert_eq!(auto(&mut s, json!({"layer": smart, "filter": 0})), mix.to_vec()); + for bad in [json!({"layer": smart, "filter": 1}), json!({"layer": smart, "filter": "x"}), json!({"layer": smart, "filter": -1})] { + assert!(s.execute("image.adjustments.blackWhite.auto", bad).is_err()); + } + s.execute("file.new", json!({"width": 20, "height": 10})).unwrap(); + assert!(s.execute("image.adjustments.blackWhite.auto", json!({"filter": 0})).is_err(), "not a smart object"); + + // Past the render limit there is nothing to sample. + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40000, "height": 30000, "background": "transparent"})).unwrap(); + let id = s.execute("layer.newAdjustmentLayer.blackWhite", json!({})).unwrap()["layer"].as_u64().unwrap(); + assert!(s.execute("image.adjustments.blackWhite.auto", json!({"layer": id})).is_err()); +} diff --git a/crates/ui-egui/src/dialogs/adjust_dialog.rs b/crates/ui-egui/src/dialogs/adjust_dialog.rs index ddc0de5908..f226200235 100644 --- a/crates/ui-egui/src/dialogs/adjust_dialog.rs +++ b/crates/ui-egui/src/dialogs/adjust_dialog.rs @@ -543,7 +543,9 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, fields: &mut Map Ed } /// Black & White's Auto (#9269): the weights `image.adjustments.blackWhite.auto` finds for -/// `layer` (an adjustment layer: the image under it; a pixel layer: its pixels) into `v`, as one -/// discrete edit of `e`. -pub fn apply_auto(app: &mut crate::PhotocraftApp, v: &mut Value, layer: Option, e: &mut Edit) { - let p = layer.map_or(json!({}), |id| json!({"layer": id.0})); +/// `layer` (an adjustment layer: the image under it; a pixel layer: its pixels; with `filter`, the +/// smart object's pixels below that smart filter) into `v`, as +/// one discrete edit of `e`. +pub fn apply_auto(app: &mut crate::PhotocraftApp, v: &mut Value, layer: Option, filter: Option, e: &mut Edit) { + let mut p = layer.map_or(json!({}), |id| json!({"layer": id.0})); + if let Some(i) = filter { + p["filter"] = json!(i); + } match app.run("image.adjustments.blackWhite.auto", p) { Ok(Value::Object(w)) => { for (k, x) in w { diff --git a/crates/ui-egui/src/dialogs/adjust_editors/session.rs b/crates/ui-egui/src/dialogs/adjust_editors/session.rs index a4d5ba0ddc..4ac5181246 100644 --- a/crates/ui-egui/src/dialogs/adjust_editors/session.rs +++ b/crates/ui-egui/src/dialogs/adjust_editors/session.rs @@ -126,7 +126,7 @@ pub fn layer_editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: LayerId, adj crate::preset_files_ui::import_gradients_dialog(app); } if e.auto { - apply_auto(app, &mut values, Some(id), &mut e); + apply_auto(app, &mut values, Some(id), None, &mut e); } if e.changed { app.live_adjust = Some((id, values.clone())); From 4b1aa728d4e83ceb6629e831d83876a6ed7c7a84 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Mon, 9 Nov 2026 23:57:59 -0600 Subject: [PATCH 12/31] Perspective Crop: draw the solid black arrow inside the quad (davidmann4 review on tool_cursor.rs, Zone::Inside) Zone::Inside returned CursorIcon::Default, so the look came from the OS: a white arrow on Windows, theme-dependent on Linux. The reference application shows a solid black arrow with its hotspot at the tip on every system (measured for #11974). The zone now paints its own arrow, as the corner arrowhead is painted: filled black with a thin light outline, tip on the point, CursorIcon::None underneath. The Area does not fade in, as a fresh id every frame would leave it pale. the_zones_draw_distinct_cursors_with_their_hotspots checks that the inside zone hides the OS cursor, paints an opaque black fill whose top-left is the point, has a thin light outline through the tip, and that the corner arrowhead stays hollow (no opaque black). --- .../canvas/perspective_crop_cursor_tests.rs | 93 +++++++++++++++---- crates/ui-egui/src/canvas/tool_cursor.rs | 11 ++- crates/ui-egui/src/icons.rs | 19 ++++ 3 files changed, 103 insertions(+), 20 deletions(-) diff --git a/crates/ui-egui/src/canvas/perspective_crop_cursor_tests.rs b/crates/ui-egui/src/canvas/perspective_crop_cursor_tests.rs index cca39f37b5..719cfc9f27 100644 --- a/crates/ui-egui/src/canvas/perspective_crop_cursor_tests.rs +++ b/crates/ui-egui/src/canvas/perspective_crop_cursor_tests.rs @@ -1,8 +1,10 @@ //! #11974: the Perspective Crop's hover cursor says what a press does. With no quad a small -//! crosshair with the tool's icon (hotspot on the crosshair's centre), inside the quad the arrow, -//! over a corner the hollow arrowhead; outside the quad (no press there yet) the usual crosshair. +//! crosshair with the tool's icon (hotspot on the crosshair's centre), our own solid black +//! arrow inside the quad, over a corner the hollow arrowhead; outside the quad (no press there +//! yet) the usual crosshair. -use egui::{CursorIcon, pos2}; +use egui::epaint::ColorMode; +use egui::{Color32, CursorIcon, Pos2, Shape, pos2}; use crate::PhotocraftApp; use crate::canvas::ToolEvent; @@ -104,9 +106,51 @@ fn only_the_perspective_crop_tool_with_a_document() { /// A cursor bitmap's hotspot and size. type Bitmap = ([u16; 2], [u16; 2]); -/// The cursor drawn for `zone` at `at`: the OS cursor, the bitmap's hotspot if one is set, and -/// whether anything was painted on top (the badge, the arrowhead). -fn drawn(zone: Zone, at: egui::Pos2) -> (CursorIcon, Option, bool) { +/// What the cursor for a zone is made of. +struct Drawn { + /// The OS cursor. + icon: CursorIcon, + /// The OS cursor bitmap's hotspot and size, if one is set. + bitmap: Option, + /// Everything painted on top (the badge, the arrowhead, the arrow), flattened. + shapes: Vec, +} + +impl Drawn { + /// The positions of the fully opaque black vertices: a solid black fill (the arrowhead's dark + /// outline is translucent, its fill white). + fn black_fill(&self) -> Vec { + let mut at = Vec::new(); + for shape in &self.shapes { + if let Shape::Mesh(mesh) = shape { + at.extend(mesh.vertices.iter().filter(|v| v.color == Color32::BLACK).map(|v| v.pos)); + } + } + at + } + + /// The closed light outlines. + fn light_outlines(&self) -> Vec<&egui::epaint::PathShape> { + let light = |c: Color32| c.r() > 200 && c.g() > 200 && c.b() > 200 && c.a() > 128; + self.shapes + .iter() + .filter_map(|s| match s { + Shape::Path(p) if p.closed && p.stroke.width > 0.0 && matches!(p.stroke.color, ColorMode::Solid(c) if light(c)) => Some(p), + _ => None, + }) + .collect() + } +} + +fn flatten(shape: Shape, out: &mut Vec) { + match shape { + Shape::Vec(inner) => inner.into_iter().for_each(|s| flatten(s, out)), + s => out.push(s), + } +} + +/// The cursor drawn for `zone` at `at`. +fn drawn(zone: Zone, at: Pos2) -> Drawn { let ctx = egui::Context::default(); ctx.add_plugin(crate::tool_cursor::CursorLifecycle); egui_extras::install_image_loaders(&ctx); @@ -122,8 +166,11 @@ fn drawn(zone: Zone, at: egui::Pos2) -> (CursorIcon, Option, bool) { run(); run(); let out = run(); - let painted = !out.shapes.is_empty(); - (out.platform_output.cursor_icon, out.platform_output.cursor_image.map(|i| (i.hotspot, i.size)), painted) + let mut shapes = Vec::new(); + for clipped in out.shapes { + flatten(clipped.shape, &mut shapes); + } + Drawn { icon: out.platform_output.cursor_icon, bitmap: out.platform_output.cursor_image.map(|i| (i.hotspot, i.size)), shapes } } #[test] @@ -136,17 +183,29 @@ fn the_zones_draw_distinct_cursors_with_their_hotspots() { // No quad: the small crosshair as the OS bitmap, centred on the point, with the // tool's icon painted beside it. (`CursorLifecycle` leaves the OS crosshair under a bitmap, // for a backend that can't show one.) - assert_eq!(idle.0, CursorIcon::Crosshair); + assert_eq!(idle.icon, CursorIcon::Crosshair); // The bitmap is padded for its outline: the hotspot is its centre, where the arms cross. - let (hot, size) = idle.1.expect("the crosshair bitmap"); + let (hot, size) = idle.bitmap.expect("the crosshair bitmap"); assert_eq!(hot[0], hot[1]); assert_eq!(hot[0] * 2 + 1, size[0], "the crosshair's centre is the hotspot: {hot:?} in {size:?}"); - assert!(idle.2, "the tool badge"); - // Inside: the solid arrow, its tip the hotspot. - assert_eq!(inside, (CursorIcon::Default, None, false)); + assert!(!idle.shapes.is_empty(), "the tool badge"); + // Inside: our own solid black arrow, not the OS one (which is white on Windows and follows the + // theme on Linux), with the OS cursor hidden under it. + assert_eq!(inside.icon, CursorIcon::None, "the OS arrow is not used"); + assert_eq!(inside.bitmap, None, "no crosshair bitmap"); + let fill = inside.black_fill(); + assert!(!fill.is_empty(), "the arrow is filled solid black"); + let (min, max) = fill.iter().fold((pos2(f32::MAX, f32::MAX), pos2(f32::MIN, f32::MIN)), |(lo, hi), p| (lo.min(*p), hi.max(*p))); + assert_eq!(min, at, "the arrow's tip is the hotspot, on the point"); + assert!(max.x - min.x >= 9.0 && max.y - min.y >= 15.0, "an arrow of the usual size, not a dot: {min:?} to {max:?}"); + let outlines = inside.light_outlines(); + assert!(!outlines.is_empty(), "a thin light outline so it reads on a dark image"); + assert!(outlines.iter().all(|o| o.stroke.width <= 2.0), "thin"); + assert!(outlines.iter().any(|o| o.points.contains(&at)), "the outline passes through the tip too"); // Corner: the hollow arrowhead, painted with its tip on the point. - assert_eq!(corner.0, CursorIcon::None); - assert_eq!(corner.1, None, "no crosshair bitmap"); - assert!(corner.2, "the arrowhead"); - assert!(idle != inside && inside != corner && idle != corner); + assert_eq!(corner.icon, CursorIcon::None); + assert_eq!(corner.bitmap, None, "no crosshair bitmap"); + assert!(!corner.shapes.is_empty(), "the arrowhead"); + assert!(corner.black_fill().is_empty(), "the corner arrowhead is hollow white, not the solid arrow"); + assert!(idle.black_fill().is_empty()); } diff --git a/crates/ui-egui/src/canvas/tool_cursor.rs b/crates/ui-egui/src/canvas/tool_cursor.rs index 790bcaced8..229c9f972d 100644 --- a/crates/ui-egui/src/canvas/tool_cursor.rs +++ b/crates/ui-egui/src/canvas/tool_cursor.rs @@ -98,8 +98,9 @@ pub(crate) fn crosshair(painter: &Painter, at: Pos2, length: f32, gap: f32) -> C } /// The Perspective Crop's pointer for what a press at `at` does (#11974): with no quad a small -/// crosshair centred on the point with the tool's icon below right; inside the quad the arrow; -/// over a corner the hollow white arrowhead with its tip on the point. +/// crosshair centred on the point with the tool's icon below right; inside the quad the solid +/// black arrow; over a corner the hollow white arrowhead with its tip on the point. Both are drawn +/// by us (the OS arrow is white on Windows and follows the theme on Linux). pub(crate) fn perspective_crop(painter: &Painter, zone: crate::perspective_crop_ui::Zone, at: Pos2) -> CursorIcon { use crate::perspective_crop_ui::Zone; match zone { @@ -107,7 +108,11 @@ pub(crate) fn perspective_crop(painter: &Painter, zone: crate::perspective_crop_ crate::icons::crosshair_badge(painter.ctx(), crate::icons::tool_icon(Tool::PerspectiveCrop), at); crosshair(painter, at, 5.0, 0.0) } - Zone::Inside => CursorIcon::Default, + Zone::Inside => { + // Our own solid black arrow, the same on every system; the OS cursor is hidden. + crate::icons::cursor_arrow(painter.ctx(), at); + CursorIcon::None + } Zone::Corner(_) => { // The tip of the arrowhead is at (4, 4) of its 24-unit box. crate::icons::cursor(painter.ctx(), "mouse-pointer-2", at, vec2(4.0, 4.0) / 24.0, 18.0); diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index ae8e2eb60f..f0ee10c648 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -80,6 +80,25 @@ pub fn cursor(ctx: &egui::Context, name: &str, p: egui::Pos2, hot: Vec2, size: f }); } +/// A solid black arrow as the pointer, above every window, its tip on `p` (the reference +/// application's arrow, hotspot at its tip): filled black with a thin light outline so it reads on +/// any image, and the same on every system. The caller hides the OS cursor (`CursorIcon::None`). +pub fn cursor_arrow(ctx: &egui::Context, p: egui::Pos2) { + // The outline of the arrow in pixels from its tip, and its split into triangles. + const OUTLINE: [(f32, f32); 7] = [(0.0, 0.0), (0.0, 16.0), (3.6, 12.6), (6.8, 19.0), (9.6, 17.8), (6.6, 11.6), (11.5, 11.5)]; + const TRIANGLES: [[u32; 3]; 5] = [[0, 1, 2], [0, 2, 5], [0, 5, 6], [2, 3, 4], [2, 4, 5]]; + let points: Vec = OUTLINE.iter().map(|&(x, y)| p + egui::vec2(x, y)).collect(); + // A new id every frame would restart the Area's fade-in each time and leave the arrow pale. + let area = + egui::Area::new(egui::Id::unique("pc-arrow-cursor")).order(egui::Order::Tooltip).fixed_pos(p).constrain(false).interactable(false).fade_in(false); + area.show(ctx, |ui| { + let vertices = points.iter().map(|&pos| egui::epaint::Vertex { pos, uv: egui::epaint::WHITE_UV, color: Color32::BLACK }).collect(); + let fill = egui::Mesh { indices: TRIANGLES.iter().flatten().copied().collect(), vertices, ..Default::default() }; + ui.painter().add(fill); + ui.painter().add(egui::Shape::closed_line(points, egui::Stroke::new(1.0, Color32::from_white_alpha(235)))); + }); +} + /// A small icon beside the OS arrow pointer at `p` (Photoshop's cursor badges: scissors for /// cutting the selected pixels). Below-right of the arrow, white with a dark outline. The caller /// keeps the arrow (`CursorIcon::Default`). From 04703e9a750a8e30a817e2a2c3d934d223391b14 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Tue, 10 Nov 2026 19:54:52 -0500 Subject: [PATCH 13/31] Delete Anchor Point: dragging a segment moves it, as Direct Selection does (#12767) A press on a straight segment of the targeted path with the Delete Anchor Point tool now starts Direct Selection's segment drag (Edit::Segment, path.bendSegment, one History step "Drag Segment"). A click without movement records nothing; an anchor click still deletes, and Add Anchor Point is unchanged. Not measured, so as today: a curved segment, Shift, a path whose anchors are all selected, the Pen path being drawn. Co-Authored-By: Claude Sonnet 5.5 Model-Effort: Opus 5.5 drove; Opus 5.5 briefed; Sonnet 5.5 wrote at medium --- crates/ui-egui/src/tools/anchor_tools.rs | 32 ++++++++ .../delete_anchor_drag_segment_tests.rs | 81 +++++++++++++++++++ 2 files changed, 113 insertions(+) create mode 100644 crates/ui-egui/src/tools/anchor_tools/delete_anchor_drag_segment_tests.rs diff --git a/crates/ui-egui/src/tools/anchor_tools.rs b/crates/ui-egui/src/tools/anchor_tools.rs index 1fa08a7c9d..5a16c96c6c 100644 --- a/crates/ui-egui/src/tools/anchor_tools.rs +++ b/crates/ui-egui/src/tools/anchor_tools.rs @@ -98,11 +98,41 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool } let Some(tool) = effective(app.ui.tool, mods) else { return false }; if let ToolEvent::Down { x, y, .. } = ev { + if tool == Tool::DeleteAnchorPoint && start_segment_drag(app, [x, y], mods) { + return true; + } click(app, tool, [x, y]); } true } +/// Delete Anchor Point (#12767): a press on a straight segment of the targeted path starts Direct +/// Selection's segment drag (`Edit::Segment`, `path.bendSegment`, **Drag Segment**); the drag's +/// Move and Up reach `direct_select::pointer` first. A click without movement records nothing. +/// Not measured, so as today (a press on a segment does nothing): a curved segment, ⇧, a segment of +/// a path whose anchors are all selected, and the Pen path being drawn. False when the press is not +/// on such a segment (an anchor press goes on to delete). +fn start_segment_drag(app: &mut PhotocraftApp, p: [f64; 2], mods: Modifiers) -> bool { + if mods.shift { + return false; + } + let Some((r, path, Hit::Segment(k, t))) = crate::direct_select::hit_active(app, p) else { return false }; + let Some(sp) = path.subpaths.get(k[0]) else { return false }; + let end = if k[1] + 1 < sp.knots.len() { k[1] + 1 } else { 0 }; + let straight = sp.knots.get(k[1]).zip(sp.knots.get(end)).is_some_and(|(a, b)| a.out_ctrl == a.anchor && b.in_ctrl == b.anchor); + let ds = &app.ui.direct_selection; + let all_selected = ds.target == Some(r) && (0..path.subpaths.len()).all(|s| edit::subpath_anchors(&path, s).iter().all(|a| ds.anchors.contains(a))); + if r == PathRef::Pen || all_selected || !straight { + return false; + } + let owner = crate::direct_select::Owner::current(app); + let ds = &mut app.ui.direct_selection; + *ds = DirectSelection { target: Some(r), segment: Some(k), owner, ..Default::default() }; + ds.drag = + Some(crate::direct_select::Drag::Edit { target: r, base: path, edit: crate::direct_select::Edit::Segment(k, t), start: p, end: p, preview: None }); + true +} + fn click(app: &mut PhotocraftApp, tool: Tool, p: [f64; 2]) { let Some((r, path, hit)) = target(app, tool, p) else { return }; if r == PathRef::Pen { @@ -189,6 +219,8 @@ fn draw_hit(painter: &egui::Painter, to_scr: &dyn Fn([f64; 2]) -> Pos2, accent: #[cfg(test)] mod add_anchor_targeted_path_tests; #[cfg(test)] +mod delete_anchor_drag_segment_tests; +#[cfg(test)] mod delete_anchor_targeted_path_tests; #[cfg(test)] mod tests; diff --git a/crates/ui-egui/src/tools/anchor_tools/delete_anchor_drag_segment_tests.rs b/crates/ui-egui/src/tools/anchor_tools/delete_anchor_drag_segment_tests.rs new file mode 100644 index 0000000000..55d2fb5ff6 --- /dev/null +++ b/crates/ui-egui/src/tools/anchor_tools/delete_anchor_drag_segment_tests.rs @@ -0,0 +1,81 @@ +//! #12767: with Delete Anchor Point, a drag that starts on a segment moves it as a temporary +//! Direct Selection does (history step **Drag Segment**); an anchor click still deletes. + +use egui::Modifiers; +use serde_json::{Value, json}; + +use super::delete_anchor_targeted_path_tests::{click, setup, steps}; +use crate::PhotocraftApp; +use crate::canvas::{ToolEvent, tool_event}; +use crate::state::Tool; + +/// The item's test document: a saved path "Path 1", selected in the Paths panel, with a closed +/// corner square, an open straight line and an open S-curve. +fn document(tool: Tool) -> PhotocraftApp { + let (mut app, _) = setup(tool); + let path = json!({"subpaths": [ + {"closed": true, "knots": [[100, 300], [300, 300], [300, 500], [100, 500]]}, + {"closed": false, "knots": [[400, 300], [700, 300]]}, + {"closed": false, "knots": [{"anchor": [400, 450], "out": [500, 350]}, {"anchor": [700, 450], "in": [600, 550]}]}, + ]}); + app.run("path.set", json!({"name": "Path 1", "path": path})).unwrap(); + app.ui.selected_path = Some("Path 1".into()); + app +} + +fn knots(app: &mut PhotocraftApp) -> Vec { + app.run("path.info", json!({"name": "Path 1"})).unwrap()["path"]["subpaths"][0]["knots"].as_array().unwrap().clone() +} + +fn drag(app: &mut PhotocraftApp, from: [f64; 2], to: [f64; 2]) { + tool_event(app, ToolEvent::Down { x: from[0], y: from[1], pressure: 1.0 }, Modifiers::NONE); + tool_event(app, ToolEvent::Move { x: to[0], y: to[1], pressure: 1.0 }, Modifiers::NONE); + tool_event(app, ToolEvent::Up { x: to[0], y: to[1] }, Modifiers::NONE); +} + +fn anchors(app: &mut PhotocraftApp) -> Vec<[f64; 2]> { + let anchor = |k: &Value| { + let a = k.get("anchor").unwrap_or(k); + [a[0].as_f64().unwrap(), a[1].as_f64().unwrap()] + }; + knots(app).iter().map(anchor).collect() +} + +#[test] +fn dragging_the_bottom_edge_moves_both_its_anchors_in_one_drag_segment_step() { + let mut app = document(Tool::DeleteAnchorPoint); + let n = steps(&app); + drag(&mut app, [200.0, 500.0], [200.0, 540.0]); + assert_eq!(anchors(&mut app), vec![[100.0, 300.0], [300.0, 300.0], [300.0, 540.0], [100.0, 540.0]]); + assert_eq!(steps(&app), n + 1); + assert_eq!(app.session.active().unwrap().history.undo_label(), Some("Drag Segment")); +} + +#[test] +fn a_click_on_a_segment_without_movement_does_nothing() { + let mut app = document(Tool::DeleteAnchorPoint); + let (before, n) = (knots(&mut app), steps(&app)); + click(&mut app, [200.0, 500.0]); + assert_eq!(knots(&mut app), before); + assert_eq!(steps(&app), n); +} + +#[test] +fn a_click_on_an_anchor_still_deletes_it_in_one_step() { + let mut app = document(Tool::DeleteAnchorPoint); + let n = steps(&app); + click(&mut app, [300.0, 500.0]); + assert_eq!(knots(&mut app).len(), 3); + assert_eq!(steps(&app), n + 1); + assert_eq!(app.session.active().unwrap().history.undo_label(), Some("Delete Anchor Point")); +} + +#[test] +fn add_anchor_point_clicks_keep_adding_an_anchor() { + let mut app = document(Tool::AddAnchorPoint); + let n = steps(&app); + click(&mut app, [200.0, 500.0]); + assert_eq!(knots(&mut app).len(), 5); + assert_eq!(steps(&app), n + 1); + assert_eq!(app.session.active().unwrap().history.undo_label(), Some("Add Anchor Point")); +} From 87a23ab8940d3fda1a9ddf08839165fc849b70b5 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Tue, 10 Nov 2026 22:40:54 -0500 Subject: [PATCH 14/31] Delete Anchor Point: a segment straight within the edit tolerance drags too (#12767) The segment-drag check compared each facing handle with its anchor exactly, while the vector edit's `straight` (and so `bend_segment`) treats handles within 1e-9 as retracted. A segment whose handles sat a hair off couldn't be dragged with Delete Anchor Point though Direct Selection moves it. The check now uses the same 1e-9 tolerance. Co-Authored-By: Claude Opus 5.5 Model-Effort: Opus 5.5 drove and wrote --- crates/ui-egui/src/tools/anchor_tools.rs | 5 ++++- .../delete_anchor_drag_segment_tests.rs | 16 ++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/tools/anchor_tools.rs b/crates/ui-egui/src/tools/anchor_tools.rs index 5a16c96c6c..5e986d01bd 100644 --- a/crates/ui-egui/src/tools/anchor_tools.rs +++ b/crates/ui-egui/src/tools/anchor_tools.rs @@ -119,7 +119,10 @@ fn start_segment_drag(app: &mut PhotocraftApp, p: [f64; 2], mods: Modifiers) -> let Some((r, path, Hit::Segment(k, t))) = crate::direct_select::hit_active(app, p) else { return false }; let Some(sp) = path.subpaths.get(k[0]) else { return false }; let end = if k[1] + 1 < sp.knots.len() { k[1] + 1 } else { 0 }; - let straight = sp.knots.get(k[1]).zip(sp.knots.get(end)).is_some_and(|(a, b)| a.out_ctrl == a.anchor && b.in_ctrl == b.anchor); + // Straight as `edit::straight` (and so `bend_segment`) has it: both facing handles within 1e-9 + // of their anchors. + let retracted = |h: Point, a: Point| (h.x - a.x).hypot(h.y - a.y) < 1e-9; + let straight = sp.knots.get(k[1]).zip(sp.knots.get(end)).is_some_and(|(a, b)| retracted(a.out_ctrl, a.anchor) && retracted(b.in_ctrl, b.anchor)); let ds = &app.ui.direct_selection; let all_selected = ds.target == Some(r) && (0..path.subpaths.len()).all(|s| edit::subpath_anchors(&path, s).iter().all(|a| ds.anchors.contains(a))); if r == PathRef::Pen || all_selected || !straight { diff --git a/crates/ui-egui/src/tools/anchor_tools/delete_anchor_drag_segment_tests.rs b/crates/ui-egui/src/tools/anchor_tools/delete_anchor_drag_segment_tests.rs index 55d2fb5ff6..ed1adfc51b 100644 --- a/crates/ui-egui/src/tools/anchor_tools/delete_anchor_drag_segment_tests.rs +++ b/crates/ui-egui/src/tools/anchor_tools/delete_anchor_drag_segment_tests.rs @@ -51,6 +51,22 @@ fn dragging_the_bottom_edge_moves_both_its_anchors_in_one_drag_segment_step() { assert_eq!(app.session.active().unwrap().history.undo_label(), Some("Drag Segment")); } +/// A segment whose facing handles sit within the vector edit's straightness tolerance (1e-9) of +/// their anchors counts as straight, as Direct Selection's `bend_segment` treats it. +#[test] +fn a_segment_with_handles_a_hair_off_its_anchors_still_drags() { + let mut app = document(Tool::DeleteAnchorPoint); + let path = json!({"subpaths": [{"closed": true, "knots": [ + [100, 300], [300, 300], + {"anchor": [300, 500], "out": [300.000_000_000_000_1, 500]}, + {"anchor": [100, 500], "in": [100.000_000_000_000_1, 500]}, + ]}]}); + app.run("path.set", json!({"name": "Path 1", "path": path})).unwrap(); + drag(&mut app, [200.0, 500.0], [200.0, 540.0]); + let moved = anchors(&mut app); + assert_eq!((moved[2], moved[3]), ([300.0, 540.0], [100.0, 540.0]), "{moved:?}"); +} + #[test] fn a_click_on_a_segment_without_movement_does_nothing() { let mut app = document(Tool::DeleteAnchorPoint); From 20433fb6a0630c13542b5b9289d6380bc5c7ffb3 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 10 Nov 2026 22:11:21 -0600 Subject: [PATCH 15/31] Perspective Crop: the press helper in the cursor tests passes the Modifiers argument to pointer (CodeRabbit review on crates/ui-egui/src/canvas/perspective_crop_cursor_tests.rs:28) The merge of main gave pointer a third argument, so the test module no longer compiled. --- crates/ui-egui/src/canvas/perspective_crop_cursor_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/canvas/perspective_crop_cursor_tests.rs b/crates/ui-egui/src/canvas/perspective_crop_cursor_tests.rs index 76e9f17ecc..f21b29ca49 100644 --- a/crates/ui-egui/src/canvas/perspective_crop_cursor_tests.rs +++ b/crates/ui-egui/src/canvas/perspective_crop_cursor_tests.rs @@ -25,7 +25,7 @@ fn app() -> PhotocraftApp { /// The drag a press at `p` starts, with the quad left as it was. fn press(app: &mut PhotocraftApp, p: [f64; 2]) -> Option { let quad = app.ui.perspective_crop; - assert!(pointer(app, ToolEvent::Down { x: p[0], y: p[1], pressure: 1.0 })); + assert!(pointer(app, ToolEvent::Down { x: p[0], y: p[1], pressure: 1.0 }, egui::Modifiers::NONE)); let drag = app.pcrop.drag.take(); app.ui.perspective_crop = quad; drag From 6fb9baedd24105445e74542fe39a549de3a0d490 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Wed, 11 Nov 2026 07:11:03 -0500 Subject: [PATCH 16/31] Delete Anchor Point and Convert Point: right-click opens the path menu with the point's entry on top (#12771, #12744) path_menu now also opens for the Delete Anchor Point and Convert Point tools. When the menu opens, the anchor or segment under the pointer is found with direct_select::hit_active and remembered on the menu: Delete Anchor Point on an anchor, Add Anchor Point on a segment, nothing on empty canvas. That entry heads the menu above a separator and runs path.deleteAnchor or path.addAnchor (one history step). The rest is PEN_MENU unchanged. Opening the menu adds no history step and changes no selection or point. Co-Authored-By: Claude Sonnet 5.5 Model-Effort: Opus 5.5 drove; Opus 5.5 briefed; Sonnet 5.5 wrote at medium --- crates/ui-egui/src/canvas/canvas_tool_menu.rs | 76 ++++++++- .../convert_point_menu_tests.rs | 39 +++++ .../delete_anchor_menu_tests.rs | 150 ++++++++++++++++++ .../src/canvas/canvas_tool_menu/tests.rs | 2 +- crates/ui-egui/src/transform_type_tests.rs | 1 + 5 files changed, 264 insertions(+), 4 deletions(-) create mode 100644 crates/ui-egui/src/canvas/canvas_tool_menu/convert_point_menu_tests.rs create mode 100644 crates/ui-egui/src/canvas/canvas_tool_menu/delete_anchor_menu_tests.rs diff --git a/crates/ui-egui/src/canvas/canvas_tool_menu.rs b/crates/ui-egui/src/canvas/canvas_tool_menu.rs index 4751e2b1a2..3511f2877b 100644 --- a/crates/ui-egui/src/canvas/canvas_tool_menu.rs +++ b/crates/ui-egui/src/canvas/canvas_tool_menu.rs @@ -8,7 +8,9 @@ use photocraft_engine::prefs::{self, CanvasColor}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; +use crate::direct_select::PathRef; use crate::{PhotocraftApp, state::Tool}; +use photocraft_vector::edit::Hit; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct CanvasToolMenu { @@ -66,6 +68,11 @@ pub struct CanvasToolMenu { /// Opened with the Content-Aware Move tool inside the active selection (#12194). #[serde(default)] pub inside_selection: bool, + /// Opened with the Delete Anchor Point or Convert Point tool over an anchor or a segment of the + /// targeted path (#12771, #12744): the engine command that entry runs and its params. It heads + /// the path menu, above a separator. + #[serde(default)] + pub point: Option<(String, Value)>, } /// A misspelled word in the type layer being edited (`edit.checkSpelling`'s `list` entry). @@ -328,6 +335,7 @@ pub fn open_crop(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: [f32; sampler_at: None, color: None, inside_selection: false, + point: None, }); crate::canvas_popup::mark_open(ctx, menu_id()); true @@ -635,7 +643,46 @@ pub fn magnetic_drawing(app: &PhotocraftApp) -> bool { /// Selection and Direct Selection tools, whose Photoshop menu offers the same whole-path actions /// for the selected path (#1056). pub fn path_menu(tool: Tool) -> bool { - matches!(tool, Tool::Pen | Tool::PathSelection | Tool::DirectSelection) + matches!(tool, Tool::Pen | Tool::PathSelection | Tool::DirectSelection) || point_menu(tool) +} + +/// The anchor tools whose path menu also heads with the entry for the point under the right-click +/// (#12771, #12744). Add Anchor Point (#12710) joins here. +fn point_menu(tool: Tool) -> bool { + matches!(tool, Tool::DeleteAnchorPoint | Tool::ConvertPoint) +} + +/// The anchor or segment of the targeted path under document point `doc`, as the anchor tools hit +/// it (`direct_select::hit_active`): Delete Anchor Point on an anchor, Add Anchor Point on a +/// segment, at the clicked point. `None` on empty canvas, a handle, or the Pen draft, which the +/// engine doesn't edit. +fn point_at(app: &PhotocraftApp, doc: [f64; 2]) -> Option<(String, Value)> { + let (target, _, hit) = crate::direct_select::hit_active(app, doc)?; + let (command, mut params) = match hit { + Hit::Anchor([subpath, knot]) => (POINT_DELETE, json!({"subpath": subpath, "knot": knot})), + Hit::Segment([subpath, knot], t) => (POINT_ADD, json!({"subpath": subpath, "knot": knot, "t": t})), + Hit::Handle(..) => return None, + }; + if target == PathRef::Pen { + return None; + } + if let (Some(o), Some(t)) = (params.as_object_mut(), target.params(app).as_object()) { + o.extend(t.clone()); + } + Some((command.to_string(), params)) +} + +const POINT_DELETE: &str = "path.deleteAnchor"; +const POINT_ADD: &str = "path.addAnchor"; + +/// The label of a menu's top point entry, spelled out so `cargo xtask i18n-coverage` lists it. +fn point_label(command: &str) -> String { + if command == POINT_DELETE { tl!("Delete Anchor Point").to_string() } else { tl!("Add Anchor Point").to_string() } +} + +/// Is `command` the open menu's top point entry? +fn is_point(menu: &CanvasToolMenu, command: &str) -> bool { + menu.point.as_ref().is_some_and(|(c, _)| c == command) } /// Tools whose plain canvas right-click offers the view menu. @@ -680,7 +727,9 @@ pub fn rows(menu: &CanvasToolMenu) -> &'static [Row] { /// Is `command` one of the open menu's rows, and enabled? pub fn available(app: &PhotocraftApp, menu: &CanvasToolMenu, command: &str) -> bool { - (rows(menu).iter().flatten().any(|(_, id)| *id == command) || is_spelling(menu, command) || is_crop(menu, command)) && entry_enabled(app, menu, command) + is_point(menu, command) + || ((rows(menu).iter().flatten().any(|(_, id)| *id == command) || is_spelling(menu, command) || is_crop(menu, command)) + && entry_enabled(app, menu, command)) } /// Is `command` the transform box's current mode (checked in the transform menu)? @@ -820,6 +869,7 @@ pub fn open_transform(app: &mut PhotocraftApp, ctx: &Context, pos: [f32; 2]) -> sampler_at: None, color: None, inside_selection: false, + point: None, }); crate::canvas_popup::mark_open(ctx, menu_id()); true @@ -906,6 +956,7 @@ pub fn open_pasteboard(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: sampler_at: None, color: None, inside_selection: false, + point: None, }); crate::canvas_popup::mark_open(ctx, menu_id()); true @@ -942,6 +993,7 @@ pub fn open_guide(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, mods: egui sampler_at: None, color: None, inside_selection: false, + point: None, }); crate::canvas_popup::mark_open(ctx, menu_id()); true @@ -1083,6 +1135,7 @@ pub fn open_at(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: [f32; 2] && s.doc.selection.as_ref().is_some_and(|sel| sel.sample_channel(d[0].floor() as i32, d[1].floor() as i32, 0) >= 0.5) }) }); + let point = if point_menu(tool) && has_path { doc.filter(|d| d.iter().all(|v| v.is_finite())).and_then(|d| point_at(app, d)) } else { None }; let sampler = if tool == Tool::ColorSampler { doc.and_then(|d| crate::analysis_ui::sampler_at(app, d)) } else { None }; let sampler_at = sampler.and_then(|i| Some((app.session.active_index()?, *app.session.active()?.color_samplers.get(i)?))); // The Eyedropper reads the colour now, under the right-click: all layers, with the Sample @@ -1107,6 +1160,7 @@ pub fn open_at(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: [f32; 2] sampler_at, color, inside_selection, + point, }); crate::canvas_popup::mark_open(ctx, menu_id()); true @@ -1140,6 +1194,7 @@ pub fn open_view(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: [f32; sampler_at: None, color: None, inside_selection: false, + point: None, }); crate::canvas_popup::mark_open(ctx, menu_id()); true @@ -1182,7 +1237,9 @@ pub fn choose(app: &mut PhotocraftApp, ctx: &Context, command: &str) { if !available(app, &menu, command) { return; } - let result = if menu.crop.is_some() { + let result = if is_point(&menu, command) { + menu.point.as_ref().map_or(Ok(()), |(id, params)| app.run(id, params.clone()).map(|_| ())) + } else if menu.crop.is_some() { choose_crop(app, &menu, command) } else if menu.guide { choose_guide(app, ctx, command) @@ -1514,6 +1571,13 @@ pub fn show(app: &mut PhotocraftApp, ctx: &Context) { selected = Some(command); } } + if let Some((command, _)) = &menu.point { + let item = egui::Button::new(point_label(command)).min_size(egui::vec2(200.0, 20.0)); + if ui.add(item).clicked() { + selected = Some(command.clone()); + } + ui.separator(); + } for row in rows(&menu) { let Some((label, command)) = *row else { ui.separator(); @@ -1568,3 +1632,9 @@ mod pen_free_transform_label_tests; #[cfg(test)] mod make_selection_dialog_tests; + +#[cfg(test)] +mod delete_anchor_menu_tests; + +#[cfg(test)] +mod convert_point_menu_tests; diff --git a/crates/ui-egui/src/canvas/canvas_tool_menu/convert_point_menu_tests.rs b/crates/ui-egui/src/canvas/canvas_tool_menu/convert_point_menu_tests.rs new file mode 100644 index 0000000000..726b2cb442 --- /dev/null +++ b/crates/ui-egui/src/canvas/canvas_tool_menu/convert_point_menu_tests.rs @@ -0,0 +1,39 @@ +//! #12744: the Convert Point tool's right-click opens the same path menu as the Delete Anchor +//! Point tool's (#12771), with no Convert Point entry. The shared steps live in +//! `delete_anchor_menu_tests`. + +use egui_kittest::kittest::Queryable; + +use super::delete_anchor_menu_tests::{add_on_the_edge, delete_the_corner, empty_canvas, opening_changes_nothing, right_clicked, saved, setup}; +use crate::state::Tool; + +#[test] +fn right_click_on_an_anchor_offers_delete_anchor_point_on_top_and_it_deletes() { + delete_the_corner(Tool::ConvertPoint); +} + +#[test] +fn right_click_on_a_segment_offers_add_anchor_point_on_top() { + add_on_the_edge(Tool::ConvertPoint); +} + +#[test] +fn right_click_on_empty_canvas_opens_the_path_menu_without_a_top_entry() { + empty_canvas(Tool::ConvertPoint); +} + +#[test] +fn the_menu_has_no_convert_point_entry_and_converts_nothing() { + let before = saved(&setup(Tool::ConvertPoint)); + for at in [[300.0, 300.0], [200.0, 300.0], [500.0, 100.0]] { + let h = right_clicked(Tool::ConvertPoint, at); + assert!(h.state().ui.canvas_tool_menu.is_some(), "the menu opened at {at:?}"); + assert!(h.query_by_label("Convert Point").is_none(), "no Convert Point entry at {at:?}"); + assert_eq!(saved(h.state()), before, "no point converted at {at:?}"); + } +} + +#[test] +fn opening_the_menu_adds_no_history_step_and_converts_no_point() { + opening_changes_nothing(Tool::ConvertPoint); +} diff --git a/crates/ui-egui/src/canvas/canvas_tool_menu/delete_anchor_menu_tests.rs b/crates/ui-egui/src/canvas/canvas_tool_menu/delete_anchor_menu_tests.rs new file mode 100644 index 0000000000..319b48d3f9 --- /dev/null +++ b/crates/ui-egui/src/canvas/canvas_tool_menu/delete_anchor_menu_tests.rs @@ -0,0 +1,150 @@ +//! #12771: with the Delete Anchor Point tool a right-click on the canvas opens the Pen's path menu +//! with one entry for what is under the pointer on top: Delete Anchor Point on an anchor, Add +//! Anchor Point on a segment, nothing on empty canvas. Opening it records no history step and +//! leaves the anchor selection alone. The helpers are shared with the Convert Point tests (#12744). + +use egui::Modifiers; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use photocraft_doc::vector::Path; +use serde_json::json; + +use super::tests::{harness, right_click, screen_of}; +use super::{PEN_MENU, rows}; +use crate::PhotocraftApp; +use crate::canvas::{ToolEvent, tool_event}; +use crate::state::Tool; + +/// 800 x 600 with a saved, targeted **Path 1**: a closed square (100,300)(300,300)(300,500) +/// (100,500), an open line (400,300)->(700,300) and an open S-curve (400,450)->(700,450). +pub(super) fn setup(tool: Tool) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 800, "height": 600})).unwrap(); + let path = json!({"subpaths": [ + {"closed": true, "knots": [[100, 300], [300, 300], [300, 500], [100, 500]]}, + {"closed": false, "knots": [[400, 300], [700, 300]]}, + {"closed": false, "knots": [ + {"anchor": [400, 450], "in": [400, 450], "out": [500, 350]}, + {"anchor": [700, 450], "in": [600, 550], "out": [700, 450]}, + ]}, + ]}); + app.run("path.set", json!({"name": "Path 1", "path": path})).unwrap(); + app.ui.selected_path = Some("Path 1".into()); + app.ui.tool = tool; + app +} + +/// A harness over [`setup`] with the right-click at document point `at` done. +pub(super) fn right_clicked(tool: Tool, at: [f32; 2]) -> Harness<'static, PhotocraftApp> { + let mut h = harness(setup(tool)); + let p = screen_of(h.state(), at); + right_click(&mut h, p, Modifiers::NONE); + h +} + +pub(super) fn saved(app: &PhotocraftApp) -> Path { + app.session.active().unwrap().doc.paths.iter().find(|p| p.name == "Path 1").unwrap().path.clone() +} + +pub(super) fn steps(app: &PhotocraftApp) -> usize { + app.session.active().unwrap().history.entries().len() +} + +/// The path menu is open and its rows are the Pen's, in the Pen's order. +fn assert_path_menu(h: &Harness<'static, PhotocraftApp>, tool: Tool) { + assert!(h.state().ui.canvas_tool_menu.as_ref().is_some_and(|m| m.tool == tool && rows(m) == PEN_MENU), "{tool:?}: the path menu is open"); +} + +/// The first entry of the open menu is `label`, above Create Vector Mask, and is the only one of +/// the two Add / Delete Anchor Point entries shown. +pub(super) fn assert_top_entry(h: &Harness<'static, PhotocraftApp>, tool: Tool, label: &str) { + assert_path_menu(h, tool); + let other = if label == "Delete Anchor Point" { "Add Anchor Point" } else { "Delete Anchor Point" }; + assert!(h.query_by_label(other).is_none(), "{other} is not offered"); + assert!(h.get_by_label(label).rect().top() < h.get_by_label("Create Vector Mask").rect().top(), "{label} heads the menu"); +} + +/// Right-click the corner (300,300), choose Delete Anchor Point: the square stays closed with +/// three anchors and the history gains one step by that name. +pub(super) fn delete_the_corner(tool: Tool) { + let mut h = right_clicked(tool, [300.0, 300.0]); + assert_top_entry(&h, tool, "Delete Anchor Point"); + let before = steps(h.state()); + h.get_by_label("Delete Anchor Point").click(); + h.run_steps(2); + let path = saved(h.state()); + assert!(path.subpaths[0].closed, "the square stays closed"); + assert_eq!(path.subpaths[0].knots.len(), 3); + assert!(path.subpaths[0].knots.iter().all(|k| (k.anchor.x, k.anchor.y) != (300.0, 300.0)), "(300,300) is gone"); + assert_eq!(steps(h.state()), before + 1); + assert_eq!(h.state().session.active().unwrap().history.undo_label(), Some("Delete Anchor Point")); +} + +/// Right-click the top edge (200,300): Add Anchor Point heads the menu and adds one there. +pub(super) fn add_on_the_edge(tool: Tool) { + let mut h = right_clicked(tool, [200.0, 300.0]); + assert_top_entry(&h, tool, "Add Anchor Point"); + let before = steps(h.state()); + h.get_by_label("Add Anchor Point").click(); + h.run_steps(2); + let path = saved(h.state()); + assert_eq!(path.subpaths[0].knots.len(), 5); + let added = &path.subpaths[0].knots[1].anchor; + assert!((added.x - 200.0).abs() < 3.0 && (added.y - 300.0).abs() < 3.0, "the anchor lands where it was clicked: {added:?}"); + assert_eq!(steps(h.state()), before + 1); + assert_eq!(h.state().session.active().unwrap().history.undo_label(), Some("Add Anchor Point")); +} + +/// Right-click empty canvas with the path targeted: the path menu with no top entry. +pub(super) fn empty_canvas(tool: Tool) { + let h = right_clicked(tool, [500.0, 100.0]); + assert_path_menu(&h, tool); + assert!(h.query_by_label("Delete Anchor Point").is_none() && h.query_by_label("Add Anchor Point").is_none(), "no top entry"); + assert!(h.query_by_label("Create Vector Mask").is_some()); +} + +/// Opening the menu on an anchor and on a segment adds no history step, changes no path and +/// leaves the Direct Selection state as it was (here: one anchor selected). +pub(super) fn opening_changes_nothing(tool: Tool) { + for at in [[300.0, 300.0], [200.0, 300.0]] { + let mut app = setup(tool); + // Direct Selection picks the anchor at (100,300); the tool under test takes over. + app.ui.tool = Tool::DirectSelection; + tool_event(&mut app, ToolEvent::Down { x: 100.0, y: 300.0, pressure: 1.0 }, Modifiers::NONE); + tool_event(&mut app, ToolEvent::Up { x: 100.0, y: 300.0 }, Modifiers::NONE); + assert_eq!(app.ui.direct_selection.anchors, vec![[0, 0]], "an anchor is selected"); + app.ui.tool = tool; + let (path, n) = (saved(&app), steps(&app)); + let mut h = harness(app); + // The selection once the first frames have run: Convert Point keeps Direct Selection's, + // Delete Anchor Point already drops it today, menu or not. + let selection = h.state().ui.direct_selection.clone(); + assert_eq!(!selection.anchors.is_empty(), tool == Tool::ConvertPoint, "{tool:?}: selection before the right-click"); + let p = screen_of(h.state(), at); + right_click(&mut h, p, Modifiers::NONE); + assert!(h.state().ui.canvas_tool_menu.is_some(), "the menu opened at {at:?}"); + assert_eq!(steps(h.state()), n, "no history step at {at:?}"); + assert_eq!(h.state().ui.direct_selection, selection, "the anchor selection is as it was at {at:?}"); + assert_eq!(saved(h.state()), path, "no path changed at {at:?}"); + } +} + +#[test] +fn right_click_on_an_anchor_offers_delete_anchor_point_on_top_and_it_deletes() { + delete_the_corner(Tool::DeleteAnchorPoint); +} + +#[test] +fn right_click_on_a_segment_offers_add_anchor_point_on_top() { + add_on_the_edge(Tool::DeleteAnchorPoint); +} + +#[test] +fn right_click_on_empty_canvas_opens_the_path_menu_without_a_top_entry() { + empty_canvas(Tool::DeleteAnchorPoint); +} + +#[test] +fn opening_the_menu_adds_no_history_step_and_keeps_the_anchor_selection() { + opening_changes_nothing(Tool::DeleteAnchorPoint); +} diff --git a/crates/ui-egui/src/canvas/canvas_tool_menu/tests.rs b/crates/ui-egui/src/canvas/canvas_tool_menu/tests.rs index 008186bfda..8fe14d5254 100644 --- a/crates/ui-egui/src/canvas/canvas_tool_menu/tests.rs +++ b/crates/ui-egui/src/canvas/canvas_tool_menu/tests.rs @@ -524,7 +524,7 @@ fn zoom_app(w: u32, h: u32) -> PhotocraftApp { app } -fn screen_of(app: &PhotocraftApp, doc: [f32; 2]) -> Pos2 { +pub(super) fn screen_of(app: &PhotocraftApp, doc: [f32; 2]) -> Pos2 { crate::canvas::ViewXform::active(app).unwrap().to_screen(doc[0], doc[1]) } diff --git a/crates/ui-egui/src/transform_type_tests.rs b/crates/ui-egui/src/transform_type_tests.rs index 510db2203f..8699d2aefd 100644 --- a/crates/ui-egui/src/transform_type_tests.rs +++ b/crates/ui-egui/src/transform_type_tests.rs @@ -61,6 +61,7 @@ fn type_layers_offer_free_transform_but_not_distort_or_perspective() { sampler_at: None, color: None, inside_selection: false, + point: None, }; assert!(crate::canvas_tool_menu::available(&app, &menu, "edit.freeTransform")); assert!(!crate::canvas_tool_menu::available(&app, &menu, "edit.transform.distort")); From 4434695f7210c408da5d7fd963e390268c0b0876 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Wed, 11 Nov 2026 07:21:53 -0500 Subject: [PATCH 17/31] Path menu: no point entry on a shape the anchor tools ignore (#12771) The anchor tools ignore a click on a shape with only its own position lock set (#12730, #12825). The menu now reuses that predicate (anchor_tools::ignores, visibility change only) so its Delete or Add Anchor Point entry cannot bypass the rule; the rest of the menu is as before. Co-Authored-By: Claude Sonnet 5.5 Model-Effort: Opus 5.5 drove; Opus 5.5 briefed; Sonnet 5.5 wrote at medium --- crates/ui-egui/src/canvas/canvas_tool_menu.rs | 2 +- .../delete_anchor_menu_tests.rs | 17 +++++++++++++++ crates/ui-egui/src/tools/anchor_tools.rs | 21 ++++++++++++------- 3 files changed, 32 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/canvas/canvas_tool_menu.rs b/crates/ui-egui/src/canvas/canvas_tool_menu.rs index 3511f2877b..0b35679f4d 100644 --- a/crates/ui-egui/src/canvas/canvas_tool_menu.rs +++ b/crates/ui-egui/src/canvas/canvas_tool_menu.rs @@ -663,7 +663,7 @@ fn point_at(app: &PhotocraftApp, doc: [f64; 2]) -> Option<(String, Value)> { Hit::Segment([subpath, knot], t) => (POINT_ADD, json!({"subpath": subpath, "knot": knot, "t": t})), Hit::Handle(..) => return None, }; - if target == PathRef::Pen { + if target == PathRef::Pen || crate::anchor_tools::ignores(app, &target) { return None; } if let (Some(o), Some(t)) = (params.as_object_mut(), target.params(app).as_object()) { diff --git a/crates/ui-egui/src/canvas/canvas_tool_menu/delete_anchor_menu_tests.rs b/crates/ui-egui/src/canvas/canvas_tool_menu/delete_anchor_menu_tests.rs index 319b48d3f9..ceacb850cc 100644 --- a/crates/ui-egui/src/canvas/canvas_tool_menu/delete_anchor_menu_tests.rs +++ b/crates/ui-egui/src/canvas/canvas_tool_menu/delete_anchor_menu_tests.rs @@ -148,3 +148,20 @@ fn right_click_on_empty_canvas_opens_the_path_menu_without_a_top_entry() { fn opening_the_menu_adds_no_history_step_and_keeps_the_anchor_selection() { opening_changes_nothing(Tool::DeleteAnchorPoint); } + +/// A shape with only its own position lock is ignored by the anchor tools' click (#12730, #12825), +/// so the menu offers no point entry on its corner either: the rest of the menu is as before. +#[test] +fn a_shape_with_only_its_position_lock_gets_no_point_entry() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 800, "height": 600})).unwrap(); + let shape = app.run("shape.create", json!({"kind": "rect", "rect": [450, 100, 200, 150], "fill": "#3070c0"})).unwrap()["layer"].as_u64().unwrap(); + app.run("layer.select", json!({"layer": shape})).unwrap(); + app.run("layer.lockLayers", json!({"position": true})).unwrap(); + app.ui.tool = Tool::DeleteAnchorPoint; + let mut h = harness(app); + let p = screen_of(h.state(), [650.0, 250.0]); + right_click(&mut h, p, Modifiers::NONE); + assert!(h.state().ui.canvas_tool_menu.is_some(), "the menu opens"); + assert!(h.query_by_label("Delete Anchor Point").is_none() && h.query_by_label("Add Anchor Point").is_none(), "no point entry on the locked shape"); +} diff --git a/crates/ui-egui/src/tools/anchor_tools.rs b/crates/ui-egui/src/tools/anchor_tools.rs index bb4c82d3e3..6241156d31 100644 --- a/crates/ui-egui/src/tools/anchor_tools.rs +++ b/crates/ui-egui/src/tools/anchor_tools.rs @@ -38,13 +38,9 @@ pub fn effective(tool: Tool, mods: Modifiers) -> Option { } } -/// What a click at `p` would edit with `tool`: the frontmost path under the pointer and the -/// segment (Add) or anchor (Delete) hit on it. An anchor hit with Add, or a segment with Delete, -/// edits nothing. -fn target(app: &PhotocraftApp, tool: Tool, p: [f64; 2]) -> Option<(PathRef, Path, Hit)> { - let (r, path, hit) = crate::direct_select::hit_active(app, p)?; - // The anchor tools ignore a click on a shape with only its own position lock set (#12730, #12825). - if let PathRef::Layer(id) = r +/// The anchor tools ignore a click on a shape with only its own position lock set (#12730, #12825). +pub(crate) fn ignores(app: &PhotocraftApp, r: &PathRef) -> bool { + if let PathRef::Layer(id) = *r && let Some(st) = app.session.active() && let Some(layer) = st.doc.layer(LayerId(id)) && matches!(layer.content, LayerContent::Shape(_)) @@ -52,6 +48,17 @@ fn target(app: &PhotocraftApp, tool: Tool, p: [f64; 2]) -> Option<(PathRef, Path && let Some(at) = st.doc.path_of(layer.id) && at.split_last().is_some_and(|(_, ancestors)| !st.doc.locks_at(ancestors).any()) { + return true; + } + false +} + +/// What a click at `p` would edit with `tool`: the frontmost path under the pointer and the +/// segment (Add) or anchor (Delete) hit on it. An anchor hit with Add, or a segment with Delete, +/// edits nothing. +fn target(app: &PhotocraftApp, tool: Tool, p: [f64; 2]) -> Option<(PathRef, Path, Hit)> { + let (r, path, hit) = crate::direct_select::hit_active(app, p)?; + if ignores(app, &r) { return None; } match (tool, hit) { From 9f1690f98e59f42fa979d2b2ac80a085e62150ba Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Wed, 11 Nov 2026 08:15:31 -0500 Subject: [PATCH 18/31] Path menu: the point entry resets the shifted anchor selection (#12744) The menu's Delete or Add Anchor Point now takes the same reset as an anchor tool click (anchor_tools::reset_shifted_selection, factored out of apply), carrying the edited path in the menu state. Test: with every anchor selected, Delete from the menu leaves no stale anchor index. Co-Authored-By: Claude Sonnet 5.5 Model-Effort: Opus 5.5 drove; Opus 5.5 briefed; Sonnet 5.5 wrote at medium --- crates/ui-egui/src/canvas/canvas_tool_menu.rs | 16 ++++++---- .../convert_point_menu_tests.rs | 29 +++++++++++++++++++ crates/ui-egui/src/tools/anchor_tools.rs | 11 +++++-- 3 files changed, 47 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/canvas/canvas_tool_menu.rs b/crates/ui-egui/src/canvas/canvas_tool_menu.rs index 0b35679f4d..9f97402a73 100644 --- a/crates/ui-egui/src/canvas/canvas_tool_menu.rs +++ b/crates/ui-egui/src/canvas/canvas_tool_menu.rs @@ -72,7 +72,7 @@ pub struct CanvasToolMenu { /// targeted path (#12771, #12744): the engine command that entry runs and its params. It heads /// the path menu, above a separator. #[serde(default)] - pub point: Option<(String, Value)>, + pub point: Option<(String, Value, PathRef)>, } /// A misspelled word in the type layer being edited (`edit.checkSpelling`'s `list` entry). @@ -656,7 +656,7 @@ fn point_menu(tool: Tool) -> bool { /// it (`direct_select::hit_active`): Delete Anchor Point on an anchor, Add Anchor Point on a /// segment, at the clicked point. `None` on empty canvas, a handle, or the Pen draft, which the /// engine doesn't edit. -fn point_at(app: &PhotocraftApp, doc: [f64; 2]) -> Option<(String, Value)> { +fn point_at(app: &PhotocraftApp, doc: [f64; 2]) -> Option<(String, Value, PathRef)> { let (target, _, hit) = crate::direct_select::hit_active(app, doc)?; let (command, mut params) = match hit { Hit::Anchor([subpath, knot]) => (POINT_DELETE, json!({"subpath": subpath, "knot": knot})), @@ -669,7 +669,7 @@ fn point_at(app: &PhotocraftApp, doc: [f64; 2]) -> Option<(String, Value)> { if let (Some(o), Some(t)) = (params.as_object_mut(), target.params(app).as_object()) { o.extend(t.clone()); } - Some((command.to_string(), params)) + Some((command.to_string(), params, target)) } const POINT_DELETE: &str = "path.deleteAnchor"; @@ -682,7 +682,7 @@ fn point_label(command: &str) -> String { /// Is `command` the open menu's top point entry? fn is_point(menu: &CanvasToolMenu, command: &str) -> bool { - menu.point.as_ref().is_some_and(|(c, _)| c == command) + menu.point.as_ref().is_some_and(|(c, ..)| c == command) } /// Tools whose plain canvas right-click offers the view menu. @@ -1238,7 +1238,11 @@ pub fn choose(app: &mut PhotocraftApp, ctx: &Context, command: &str) { return; } let result = if is_point(&menu, command) { - menu.point.as_ref().map_or(Ok(()), |(id, params)| app.run(id, params.clone()).map(|_| ())) + menu.point.as_ref().map_or(Ok(()), |(id, params, target)| { + app.run(id, params.clone())?; + crate::anchor_tools::reset_shifted_selection(app, *target); + Ok(()) + }) } else if menu.crop.is_some() { choose_crop(app, &menu, command) } else if menu.guide { @@ -1571,7 +1575,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &Context) { selected = Some(command); } } - if let Some((command, _)) = &menu.point { + if let Some((command, ..)) = &menu.point { let item = egui::Button::new(point_label(command)).min_size(egui::vec2(200.0, 20.0)); if ui.add(item).clicked() { selected = Some(command.clone()); diff --git a/crates/ui-egui/src/canvas/canvas_tool_menu/convert_point_menu_tests.rs b/crates/ui-egui/src/canvas/canvas_tool_menu/convert_point_menu_tests.rs index 726b2cb442..5fd407796d 100644 --- a/crates/ui-egui/src/canvas/canvas_tool_menu/convert_point_menu_tests.rs +++ b/crates/ui-egui/src/canvas/canvas_tool_menu/convert_point_menu_tests.rs @@ -2,9 +2,13 @@ //! Point tool's (#12771), with no Convert Point entry. The shared steps live in //! `delete_anchor_menu_tests`. +use egui::Modifiers; use egui_kittest::kittest::Queryable; use super::delete_anchor_menu_tests::{add_on_the_edge, delete_the_corner, empty_canvas, opening_changes_nothing, right_clicked, saved, setup}; +use super::tests::{harness, right_click, screen_of}; +use crate::canvas::{ToolEvent, tool_event}; +use crate::direct_select::PathRef; use crate::state::Tool; #[test] @@ -37,3 +41,28 @@ fn the_menu_has_no_convert_point_entry_and_converts_nothing() { fn opening_the_menu_adds_no_history_step_and_converts_no_point() { opening_changes_nothing(Tool::ConvertPoint); } + +/// Every anchor selected (a Direct Selection marquee), then Delete Anchor Point from the menu on +/// (300,300): the anchors shift, so no old anchor index stays selected, as after a Delete Anchor +/// Point click. (`app.run` already drops a selection whose revision moved on; the menu then resets +/// one that survives, with the same helper the click uses.) +#[test] +fn deleting_from_the_menu_resets_the_shifted_anchor_selection() { + let mut app = setup(Tool::DirectSelection); + tool_event(&mut app, ToolEvent::Down { x: 50.0, y: 250.0, pressure: 1.0 }, Modifiers::NONE); + tool_event(&mut app, ToolEvent::Move { x: 750.0, y: 550.0, pressure: 1.0 }, Modifiers::NONE); + tool_event(&mut app, ToolEvent::Up { x: 750.0, y: 550.0 }, Modifiers::NONE); + assert!(app.ui.direct_selection.anchors.len() > 3, "every anchor is selected"); + app.ui.tool = Tool::ConvertPoint; + let mut h = harness(app); + assert!(!h.state().ui.direct_selection.anchors.is_empty(), "Convert Point keeps the selection"); + let p = screen_of(h.state(), [300.0, 300.0]); + right_click(&mut h, p, Modifiers::NONE); + // Chosen without a frame after it, which would drop a selection with no owner, as for a click. + let ctx = h.ctx.clone(); + super::choose(h.state_mut(), &ctx, "path.deleteAnchor"); + assert_eq!(saved(h.state()).subpaths[0].knots.len(), 3, "the anchor is gone"); + let ds = &h.state().ui.direct_selection; + assert!(ds.target.is_none_or(|t| t == PathRef::Saved(0)), "the selection is on no other path"); + assert!(ds.anchors.is_empty(), "no stale anchor index: {:?}", ds.anchors); +} diff --git a/crates/ui-egui/src/tools/anchor_tools.rs b/crates/ui-egui/src/tools/anchor_tools.rs index 6241156d31..c19686085f 100644 --- a/crates/ui-egui/src/tools/anchor_tools.rs +++ b/crates/ui-egui/src/tools/anchor_tools.rs @@ -229,9 +229,7 @@ fn apply(app: &mut PhotocraftApp, r: PathRef, hit: Hit, p: [f64; 2]) { _ => app.ui.direct_selection = DirectSelection { target: Some(r), ..Default::default() }, } } - // The anchors shift: a Direct Selection on this path would point at the wrong ones. - Ok(_) if app.ui.direct_selection.target.as_ref() == Some(&r) => app.ui.direct_selection = DirectSelection { target: Some(r), ..Default::default() }, - Ok(_) => {} + Ok(_) => reset_shifted_selection(app, r), Err(e) => { app.ui.status = e; app.ui.status_error = true; @@ -239,6 +237,13 @@ fn apply(app: &mut PhotocraftApp, r: PathRef, hit: Hit, p: [f64; 2]) { } } +/// The anchors of `r` shifted: a Direct Selection on this path would point at the wrong ones. +pub(crate) fn reset_shifted_selection(app: &mut PhotocraftApp, r: PathRef) { + if app.ui.direct_selection.target.as_ref() == Some(&r) { + app.ui.direct_selection = DirectSelection { target: Some(r), ..Default::default() }; + } +} + /// The hover feedback over the Direct Selection overlay: Add highlights the segment under the /// pointer and shows the anchor a click would add; Delete highlights the anchor it would remove. pub fn draw_hover(app: &PhotocraftApp, painter: &egui::Painter, to_scr: &dyn Fn([f64; 2]) -> Pos2, accent: Color32, mods: Modifiers) { From 9f3c268417c280cd7a19c610fc6b0385a38b2da2 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Wed, 11 Nov 2026 08:26:29 -0500 Subject: [PATCH 19/31] Path menu: drop the redundant selection reset (#12744) app.run already drops a Direct Selection whose revision moved on (direct_select::cancel_stale), so after the menu's Delete Anchor Point no shifted anchor index can stay selected, as after a click. The reset helper added in 9f1690f98 did nothing on top of that: anchor_tools.rs and canvas_tool_menu.rs are back to 4434695f7. The test stays, renamed, as a guard. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas/canvas_tool_menu.rs | 16 ++++++---------- .../canvas_tool_menu/convert_point_menu_tests.rs | 5 ++--- crates/ui-egui/src/tools/anchor_tools.rs | 11 +++-------- 3 files changed, 11 insertions(+), 21 deletions(-) diff --git a/crates/ui-egui/src/canvas/canvas_tool_menu.rs b/crates/ui-egui/src/canvas/canvas_tool_menu.rs index 9f97402a73..0b35679f4d 100644 --- a/crates/ui-egui/src/canvas/canvas_tool_menu.rs +++ b/crates/ui-egui/src/canvas/canvas_tool_menu.rs @@ -72,7 +72,7 @@ pub struct CanvasToolMenu { /// targeted path (#12771, #12744): the engine command that entry runs and its params. It heads /// the path menu, above a separator. #[serde(default)] - pub point: Option<(String, Value, PathRef)>, + pub point: Option<(String, Value)>, } /// A misspelled word in the type layer being edited (`edit.checkSpelling`'s `list` entry). @@ -656,7 +656,7 @@ fn point_menu(tool: Tool) -> bool { /// it (`direct_select::hit_active`): Delete Anchor Point on an anchor, Add Anchor Point on a /// segment, at the clicked point. `None` on empty canvas, a handle, or the Pen draft, which the /// engine doesn't edit. -fn point_at(app: &PhotocraftApp, doc: [f64; 2]) -> Option<(String, Value, PathRef)> { +fn point_at(app: &PhotocraftApp, doc: [f64; 2]) -> Option<(String, Value)> { let (target, _, hit) = crate::direct_select::hit_active(app, doc)?; let (command, mut params) = match hit { Hit::Anchor([subpath, knot]) => (POINT_DELETE, json!({"subpath": subpath, "knot": knot})), @@ -669,7 +669,7 @@ fn point_at(app: &PhotocraftApp, doc: [f64; 2]) -> Option<(String, Value, PathRe if let (Some(o), Some(t)) = (params.as_object_mut(), target.params(app).as_object()) { o.extend(t.clone()); } - Some((command.to_string(), params, target)) + Some((command.to_string(), params)) } const POINT_DELETE: &str = "path.deleteAnchor"; @@ -682,7 +682,7 @@ fn point_label(command: &str) -> String { /// Is `command` the open menu's top point entry? fn is_point(menu: &CanvasToolMenu, command: &str) -> bool { - menu.point.as_ref().is_some_and(|(c, ..)| c == command) + menu.point.as_ref().is_some_and(|(c, _)| c == command) } /// Tools whose plain canvas right-click offers the view menu. @@ -1238,11 +1238,7 @@ pub fn choose(app: &mut PhotocraftApp, ctx: &Context, command: &str) { return; } let result = if is_point(&menu, command) { - menu.point.as_ref().map_or(Ok(()), |(id, params, target)| { - app.run(id, params.clone())?; - crate::anchor_tools::reset_shifted_selection(app, *target); - Ok(()) - }) + menu.point.as_ref().map_or(Ok(()), |(id, params)| app.run(id, params.clone()).map(|_| ())) } else if menu.crop.is_some() { choose_crop(app, &menu, command) } else if menu.guide { @@ -1575,7 +1571,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &Context) { selected = Some(command); } } - if let Some((command, ..)) = &menu.point { + if let Some((command, _)) = &menu.point { let item = egui::Button::new(point_label(command)).min_size(egui::vec2(200.0, 20.0)); if ui.add(item).clicked() { selected = Some(command.clone()); diff --git a/crates/ui-egui/src/canvas/canvas_tool_menu/convert_point_menu_tests.rs b/crates/ui-egui/src/canvas/canvas_tool_menu/convert_point_menu_tests.rs index 5fd407796d..d8d8592784 100644 --- a/crates/ui-egui/src/canvas/canvas_tool_menu/convert_point_menu_tests.rs +++ b/crates/ui-egui/src/canvas/canvas_tool_menu/convert_point_menu_tests.rs @@ -44,10 +44,9 @@ fn opening_the_menu_adds_no_history_step_and_converts_no_point() { /// Every anchor selected (a Direct Selection marquee), then Delete Anchor Point from the menu on /// (300,300): the anchors shift, so no old anchor index stays selected, as after a Delete Anchor -/// Point click. (`app.run` already drops a selection whose revision moved on; the menu then resets -/// one that survives, with the same helper the click uses.) +/// Point click: `app.run` drops a Direct Selection whose revision moved on (`cancel_stale`). #[test] -fn deleting_from_the_menu_resets_the_shifted_anchor_selection() { +fn deleting_from_the_menu_leaves_no_shifted_anchor_selected() { let mut app = setup(Tool::DirectSelection); tool_event(&mut app, ToolEvent::Down { x: 50.0, y: 250.0, pressure: 1.0 }, Modifiers::NONE); tool_event(&mut app, ToolEvent::Move { x: 750.0, y: 550.0, pressure: 1.0 }, Modifiers::NONE); diff --git a/crates/ui-egui/src/tools/anchor_tools.rs b/crates/ui-egui/src/tools/anchor_tools.rs index c19686085f..6241156d31 100644 --- a/crates/ui-egui/src/tools/anchor_tools.rs +++ b/crates/ui-egui/src/tools/anchor_tools.rs @@ -229,7 +229,9 @@ fn apply(app: &mut PhotocraftApp, r: PathRef, hit: Hit, p: [f64; 2]) { _ => app.ui.direct_selection = DirectSelection { target: Some(r), ..Default::default() }, } } - Ok(_) => reset_shifted_selection(app, r), + // The anchors shift: a Direct Selection on this path would point at the wrong ones. + Ok(_) if app.ui.direct_selection.target.as_ref() == Some(&r) => app.ui.direct_selection = DirectSelection { target: Some(r), ..Default::default() }, + Ok(_) => {} Err(e) => { app.ui.status = e; app.ui.status_error = true; @@ -237,13 +239,6 @@ fn apply(app: &mut PhotocraftApp, r: PathRef, hit: Hit, p: [f64; 2]) { } } -/// The anchors of `r` shifted: a Direct Selection on this path would point at the wrong ones. -pub(crate) fn reset_shifted_selection(app: &mut PhotocraftApp, r: PathRef) { - if app.ui.direct_selection.target.as_ref() == Some(&r) { - app.ui.direct_selection = DirectSelection { target: Some(r), ..Default::default() }; - } -} - /// The hover feedback over the Direct Selection overlay: Add highlights the segment under the /// pointer and shows the anchor a click would add; Delete highlights the anchor it would remove. pub fn draw_hover(app: &PhotocraftApp, painter: &egui::Painter, to_scr: &dyn Fn([f64; 2]) -> Pos2, accent: Color32, mods: Modifiers) { From e6bda4b1afc8ec9b1fee6edf6c1ce288ca3d04f9 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Wed, 11 Nov 2026 08:55:46 -0500 Subject: [PATCH 20/31] Delete Anchor Point: a segment drag ignores a shape the anchor tools ignore (#12767) A shape with only its own position lock is left alone by an anchor click (#12730, #12825); the segment drag started on it anyway and moved its edge. The rule moves into anchor_tools::ignores (the same extraction as #13179), which both target and start_segment_drag call. Test: a_position_locked_shape_s_segment_does_not_drag (red before: the bottom edge went from y 250 to 290). Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/tools/anchor_tools.rs | 24 ++++++++++++------- .../delete_anchor_drag_segment_tests.rs | 15 +++++++++++- 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/tools/anchor_tools.rs b/crates/ui-egui/src/tools/anchor_tools.rs index 05c2421f62..064e596254 100644 --- a/crates/ui-egui/src/tools/anchor_tools.rs +++ b/crates/ui-egui/src/tools/anchor_tools.rs @@ -38,13 +38,9 @@ pub fn effective(tool: Tool, mods: Modifiers) -> Option { } } -/// What a click at `p` would edit with `tool`: the frontmost path under the pointer and the -/// segment (Add) or anchor (Delete) hit on it. An anchor hit with Add, or a segment with Delete, -/// edits nothing. -fn target(app: &PhotocraftApp, tool: Tool, p: [f64; 2]) -> Option<(PathRef, Path, Hit)> { - let (r, path, hit) = crate::direct_select::hit_active(app, p)?; - // The anchor tools ignore a click on a shape with only its own position lock set (#12730, #12825). - if let PathRef::Layer(id) = r +/// The anchor tools ignore a click on a shape with only its own position lock set (#12730, #12825). +pub(crate) fn ignores(app: &PhotocraftApp, r: &PathRef) -> bool { + if let PathRef::Layer(id) = *r && let Some(st) = app.session.active() && let Some(layer) = st.doc.layer(LayerId(id)) && matches!(layer.content, LayerContent::Shape(_)) @@ -52,6 +48,17 @@ fn target(app: &PhotocraftApp, tool: Tool, p: [f64; 2]) -> Option<(PathRef, Path && let Some(at) = st.doc.path_of(layer.id) && at.split_last().is_some_and(|(_, ancestors)| !st.doc.locks_at(ancestors).any()) { + return true; + } + false +} + +/// What a click at `p` would edit with `tool`: the frontmost path under the pointer and the +/// segment (Add) or anchor (Delete) hit on it. An anchor hit with Add, or a segment with Delete, +/// edits nothing. +fn target(app: &PhotocraftApp, tool: Tool, p: [f64; 2]) -> Option<(PathRef, Path, Hit)> { + let (r, path, hit) = crate::direct_select::hit_active(app, p)?; + if ignores(app, &r) { return None; } match (tool, hit) { @@ -186,7 +193,8 @@ fn start_segment_drag(app: &mut PhotocraftApp, p: [f64; 2], mods: Modifiers) -> let straight = sp.knots.get(k[1]).zip(sp.knots.get(end)).is_some_and(|(a, b)| retracted(a.out_ctrl, a.anchor) && retracted(b.in_ctrl, b.anchor)); let ds = &app.ui.direct_selection; let all_selected = ds.target == Some(r) && (0..path.subpaths.len()).all(|s| edit::subpath_anchors(&path, s).iter().all(|a| ds.anchors.contains(a))); - if r == PathRef::Pen || all_selected || !straight { + // A shape with only its own position lock is ignored, as a click on it is (#12730, #12825). + if r == PathRef::Pen || all_selected || !straight || ignores(app, &r) { return false; } let owner = crate::direct_select::Owner::current(app); diff --git a/crates/ui-egui/src/tools/anchor_tools/delete_anchor_drag_segment_tests.rs b/crates/ui-egui/src/tools/anchor_tools/delete_anchor_drag_segment_tests.rs index ed1adfc51b..e43ae68e34 100644 --- a/crates/ui-egui/src/tools/anchor_tools/delete_anchor_drag_segment_tests.rs +++ b/crates/ui-egui/src/tools/anchor_tools/delete_anchor_drag_segment_tests.rs @@ -4,7 +4,7 @@ use egui::Modifiers; use serde_json::{Value, json}; -use super::delete_anchor_targeted_path_tests::{click, setup, steps}; +use super::delete_anchor_targeted_path_tests::{click, select_layer, setup, shape_path, steps}; use crate::PhotocraftApp; use crate::canvas::{ToolEvent, tool_event}; use crate::state::Tool; @@ -95,3 +95,16 @@ fn add_anchor_point_clicks_keep_adding_an_anchor() { assert_eq!(steps(&app), n + 1); assert_eq!(app.session.active().unwrap().history.undo_label(), Some("Add Anchor Point")); } + +/// A shape with only its own position lock is left alone by a segment drag, as by an anchor click +/// (#12730, #12825): its bottom edge doesn't move and no history step is added. +#[test] +fn a_position_locked_shape_s_segment_does_not_drag() { + let (mut app, shape) = setup(Tool::DeleteAnchorPoint); + select_layer(&mut app, shape); + app.run("layer.lockLayers", json!({"position": true})).unwrap(); + let (before, n) = (shape_path(&mut app, shape), steps(&app)); + drag(&mut app, [550.0, 250.0], [550.0, 290.0]); + assert_eq!(shape_path(&mut app, shape), before); + assert_eq!(steps(&app), n); +} From 384a68d94ce25f396bbcf77131e2110bbd958139 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Wed, 11 Nov 2026 14:51:50 -0500 Subject: [PATCH 21/31] Path menu: a point entry expires once the path changes; undo/redo checked in the menu tests (#12771, #12744) The top point entry now carries the document id and revision it was read from (as the #13150 prompt does); choose and available run nothing once either has moved on, so an edit while the menu is open can no longer delete a shifted anchor. The shared delete and add helpers now undo and redo the menu edit and compare the whole path. Co-Authored-By: Claude Sonnet 5.5 Model-Effort: Opus 5.5 drove; Opus 5.5 briefed; Sonnet 5.5 wrote at medium --- crates/ui-egui/src/canvas/canvas_tool_menu.rs | 25 +++++++++++----- .../delete_anchor_menu_tests.rs | 29 +++++++++++++++++++ 2 files changed, 46 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/canvas/canvas_tool_menu.rs b/crates/ui-egui/src/canvas/canvas_tool_menu.rs index 8fd8bc13f6..40c8e96c02 100644 --- a/crates/ui-egui/src/canvas/canvas_tool_menu.rs +++ b/crates/ui-egui/src/canvas/canvas_tool_menu.rs @@ -74,9 +74,10 @@ pub struct CanvasToolMenu { pub inside_selection: bool, /// Opened with the Delete Anchor Point or Convert Point tool over an anchor or a segment of the /// targeted path (#12771, #12744): the engine command that entry runs and its params. It heads - /// the path menu, above a separator. + /// the path menu, above a separator. The third part is the document id and revision it was read + /// from: the knot index means something only there, so the entry expires once either moves on. #[serde(default)] - pub point: Option<(String, Value)>, + pub point: Option<(String, Value, (u64, u64))>, } /// A misspelled word in the type layer being edited (`edit.checkSpelling`'s `list` entry). @@ -735,7 +736,7 @@ fn point_menu(tool: Tool) -> bool { /// it (`direct_select::hit_active`): Delete Anchor Point on an anchor, Add Anchor Point on a /// segment, at the clicked point. `None` on empty canvas, a handle, or the Pen draft, which the /// engine doesn't edit. -fn point_at(app: &PhotocraftApp, doc: [f64; 2]) -> Option<(String, Value)> { +fn point_at(app: &PhotocraftApp, doc: [f64; 2]) -> Option<(String, Value, (u64, u64))> { let (target, _, hit) = crate::direct_select::hit_active(app, doc)?; let (command, mut params) = match hit { Hit::Anchor([subpath, knot]) => (POINT_DELETE, json!({"subpath": subpath, "knot": knot})), @@ -748,7 +749,8 @@ fn point_at(app: &PhotocraftApp, doc: [f64; 2]) -> Option<(String, Value)> { if let (Some(o), Some(t)) = (params.as_object_mut(), target.params(app).as_object()) { o.extend(t.clone()); } - Some((command.to_string(), params)) + let stamp = app.session.active().map(|st| (st.doc.id.0, st.revision))?; + Some((command.to_string(), params, stamp)) } const POINT_DELETE: &str = "path.deleteAnchor"; @@ -761,7 +763,14 @@ fn point_label(command: &str) -> String { /// Is `command` the open menu's top point entry? fn is_point(menu: &CanvasToolMenu, command: &str) -> bool { - menu.point.as_ref().is_some_and(|(c, _)| c == command) + menu.point.as_ref().is_some_and(|(c, ..)| c == command) +} + +/// Is the open menu's point entry still about the path as it is? The document and its revision are +/// the ones the entry was read from; an edit meanwhile (say a control-channel `path.addAnchor`) +/// shifts the knot indexes, so a stale entry runs nothing. +fn point_current(app: &PhotocraftApp, menu: &CanvasToolMenu) -> bool { + menu.point.as_ref().is_some_and(|(.., stamp)| app.session.active().map(|st| (st.doc.id.0, st.revision)) == Some(*stamp)) } /// Tools whose plain canvas right-click offers the view menu. @@ -808,7 +817,7 @@ pub fn rows(menu: &CanvasToolMenu) -> &'static [Row] { /// Is `command` one of the open menu's rows, and enabled? pub fn available(app: &PhotocraftApp, menu: &CanvasToolMenu, command: &str) -> bool { - is_point(menu, command) + (is_point(menu, command) && point_current(app, menu)) || ((rows(menu).iter().flatten().any(|(_, id)| *id == command) || is_spelling(menu, command) || is_crop(menu, command)) && entry_enabled(app, menu, command)) } @@ -1367,7 +1376,7 @@ pub fn choose(app: &mut PhotocraftApp, ctx: &Context, command: &str) { return; } let result = if is_point(&menu, command) { - menu.point.as_ref().map_or(Ok(()), |(id, params)| app.run(id, params.clone()).map(|_| ())) + menu.point.as_ref().map_or(Ok(()), |(id, params, _)| app.run(id, params.clone()).map(|_| ())) } else if menu.crop.is_some() { choose_crop(app, &menu, command) } else if menu.guide { @@ -1716,7 +1725,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &Context) { selected = Some(command); } } - if let Some((command, _)) = &menu.point { + if let Some((command, ..)) = &menu.point { let item = egui::Button::new(point_label(command)).min_size(egui::vec2(200.0, 20.0)); if ui.add(item).clicked() { selected = Some(command.clone()); diff --git a/crates/ui-egui/src/canvas/canvas_tool_menu/delete_anchor_menu_tests.rs b/crates/ui-egui/src/canvas/canvas_tool_menu/delete_anchor_menu_tests.rs index ceacb850cc..0d6f1923c4 100644 --- a/crates/ui-egui/src/canvas/canvas_tool_menu/delete_anchor_menu_tests.rs +++ b/crates/ui-egui/src/canvas/canvas_tool_menu/delete_anchor_menu_tests.rs @@ -64,12 +64,22 @@ pub(super) fn assert_top_entry(h: &Harness<'static, PhotocraftApp>, tool: Tool, assert!(h.get_by_label(label).rect().top() < h.get_by_label("Create Vector Mask").rect().top(), "{label} heads the menu"); } +/// The context-menu contract after a menu edit: undo restores the whole original path and redo +/// replays the edit (the path as it was right after the menu choice, `edited`). +fn assert_undo_redo(h: &mut Harness<'static, PhotocraftApp>, original: &Path, edited: &Path) { + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(&saved(h.state()), original, "undo restores the whole path"); + h.state_mut().run("edit.redo", json!({})).unwrap(); + assert_eq!(&saved(h.state()), edited, "redo replays the menu edit"); +} + /// Right-click the corner (300,300), choose Delete Anchor Point: the square stays closed with /// three anchors and the history gains one step by that name. pub(super) fn delete_the_corner(tool: Tool) { let mut h = right_clicked(tool, [300.0, 300.0]); assert_top_entry(&h, tool, "Delete Anchor Point"); let before = steps(h.state()); + let original = saved(h.state()); h.get_by_label("Delete Anchor Point").click(); h.run_steps(2); let path = saved(h.state()); @@ -78,6 +88,7 @@ pub(super) fn delete_the_corner(tool: Tool) { assert!(path.subpaths[0].knots.iter().all(|k| (k.anchor.x, k.anchor.y) != (300.0, 300.0)), "(300,300) is gone"); assert_eq!(steps(h.state()), before + 1); assert_eq!(h.state().session.active().unwrap().history.undo_label(), Some("Delete Anchor Point")); + assert_undo_redo(&mut h, &original, &path); } /// Right-click the top edge (200,300): Add Anchor Point heads the menu and adds one there. @@ -85,6 +96,7 @@ pub(super) fn add_on_the_edge(tool: Tool) { let mut h = right_clicked(tool, [200.0, 300.0]); assert_top_entry(&h, tool, "Add Anchor Point"); let before = steps(h.state()); + let original = saved(h.state()); h.get_by_label("Add Anchor Point").click(); h.run_steps(2); let path = saved(h.state()); @@ -93,6 +105,7 @@ pub(super) fn add_on_the_edge(tool: Tool) { assert!((added.x - 200.0).abs() < 3.0 && (added.y - 300.0).abs() < 3.0, "the anchor lands where it was clicked: {added:?}"); assert_eq!(steps(h.state()), before + 1); assert_eq!(h.state().session.active().unwrap().history.undo_label(), Some("Add Anchor Point")); + assert_undo_redo(&mut h, &original, &path); } /// Right-click empty canvas with the path targeted: the path menu with no top entry. @@ -149,6 +162,22 @@ fn opening_the_menu_adds_no_history_step_and_keeps_the_anchor_selection() { opening_changes_nothing(Tool::DeleteAnchorPoint); } +/// The entry is about the path as it was when the menu opened: an edit meanwhile (a control call +/// inserting an anchor before the corner) shifts the knots, so choosing it deletes nothing. +#[test] +fn a_point_entry_expires_once_the_path_is_edited() { + let mut h = right_clicked(Tool::DeleteAnchorPoint, [300.0, 300.0]); + assert_top_entry(&h, Tool::DeleteAnchorPoint, "Delete Anchor Point"); + h.state_mut().run("path.addAnchor", json!({"name": "Path 1", "subpath": 0, "knot": 0, "t": 0.5})).unwrap(); + let after_add = saved(h.state()); + assert_eq!(after_add.subpaths[0].knots.len(), 5, "the anchor went in"); + let n = steps(h.state()); + let ctx = h.ctx.clone(); + super::choose(h.state_mut(), &ctx, "path.deleteAnchor"); + assert_eq!(saved(h.state()), after_add, "the stale entry deleted nothing"); + assert_eq!(steps(h.state()), n, "and added no history step"); +} + /// A shape with only its own position lock is ignored by the anchor tools' click (#12730, #12825), /// so the menu offers no point entry on its corner either: the rest of the menu is as before. #[test] From d4cb006e7fbcd0187b0c0bcc4c0e63a955f9ea89 Mon Sep 17 00:00:00 2001 From: RickiNano <81099017+RickiNano@users.noreply.github.com> Date: Wed, 11 Nov 2026 21:06:47 +0100 Subject: [PATCH 22/31] fix(ui): Rectangle tool Radius takes 0-150000 px and alerts past it (#13336) The options-bar Radius field clamped silently to 0..10000. It now takes 0..150000 px with decimals, and a value typed past either end becomes the closest end and shows the reference's "Closest value inserted." alert once the field is left. A decimal twin of value_field_whole_alerting shares its body. Path/Direct Selection's Radius and the Create Rectangle dialog keep today's behaviour. --- .../src/tools/rectangle_radius_range_tests.rs | 95 +++++++++++++++++++ crates/ui-egui/src/tools/shape_options.rs | 31 +++++- crates/ui-egui/src/widgets.rs | 2 +- crates/ui-egui/src/widgets/fields.rs | 28 +++++- 4 files changed, 152 insertions(+), 4 deletions(-) create mode 100644 crates/ui-egui/src/tools/rectangle_radius_range_tests.rs diff --git a/crates/ui-egui/src/tools/rectangle_radius_range_tests.rs b/crates/ui-egui/src/tools/rectangle_radius_range_tests.rs new file mode 100644 index 0000000000..2a85f87aa9 --- /dev/null +++ b/crates/ui-egui/src/tools/rectangle_radius_range_tests.rs @@ -0,0 +1,95 @@ +//! #13336: the Rectangle tool's Radius takes 0..150000 px, decimals included; a value typed past +//! the range becomes the closest end and, once the field is left, the reference's "Closest value +//! inserted" alert with OK says so. The Path Selection / Direct Selection Radius is not this field. + +use egui::accesskit::Role; +use egui::vec2; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use serde_json::json; + +use super::RADIUS_RANGE_ALERT; +use crate::PhotocraftApp; +use crate::state::{DialogKind, Tool}; + +/// The options bar with `tool` active. +fn bar_with(app: PhotocraftApp, tool: Tool) -> Harness<'static, PhotocraftApp> { + let mut app = app; + app.ui.tool = tool; + let mut h = Harness::builder().with_size(vec2(1800.0, 300.0)).build_ui_state(|ui, app| crate::panels::options_bar(app, ui), app); + PhotocraftApp::setup_context(&h.ctx, Default::default()); + h.run_steps(4); + h +} + +fn new_app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 80, "height": 80})).unwrap(); + app +} + +/// The options bar with the Rectangle tool active and a pixel layer targeted. +fn bar() -> Harness<'static, PhotocraftApp> { + bar_with(new_app(), Tool::Rectangle) +} + +/// Types `text` into Radius, then Tabs out of it. +fn type_radius(h: &mut Harness<'static, PhotocraftApp>, text: &str) { + h.query_all_by_role(Role::SpinButton).find(|n| n.accesskit_node().label().as_deref() == Some("Radius:")).expect("the Radius field").click(); + h.run_steps(1); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.event(egui::Event::Text(text.into())); + h.run_steps(1); + h.key_press(egui::Key::Tab); + h.run_steps(3); +} + +fn alerts(h: &Harness<'static, PhotocraftApp>) -> Vec { + h.state().ui.dialogs.iter().filter(|d| d.kind == DialogKind::Error).filter_map(|d| d.fields.get("message")?.as_str().map(str::to_owned)).collect() +} + +/// OK on the alert, as `ui.dialog.confirm` presses it (this harness draws only the bar). +fn ok(h: &mut Harness<'static, PhotocraftApp>) { + let id = h.state().ui.dialogs.iter().find(|d| d.kind == DialogKind::Error).map(|d| d.id).expect("an alert"); + crate::dialogs::confirm(h.state_mut(), id).unwrap(); + h.run_steps(3); +} + +fn radius(h: &Harness<'static, PhotocraftApp>) -> f32 { + h.state().ui.tool_options.corner_radius +} + +#[test] +fn radius_in_range_is_stored_without_an_alert() { + for (typed, want) in [("0", 0.0), ("12.5", 12.5), ("5000", 5000.0), ("150000", 150_000.0)] { + let mut h = bar(); + type_radius(&mut h, typed); + assert_eq!(radius(&h), want, "{typed}"); + assert!(alerts(&h).is_empty(), "{typed} is in range"); + } +} + +#[test] +fn radius_past_the_range_takes_the_closest_value_and_alerts() { + for (typed, want) in [("-5", 0.0), ("200000", 150_000.0)] { + let mut h = bar(); + type_radius(&mut h, typed); + assert_eq!(radius(&h), want, "{typed}"); + assert_eq!(alerts(&h), vec![RADIUS_RANGE_ALERT.to_string()], "{typed}: one alert"); + ok(&mut h); + assert!(alerts(&h).is_empty(), "{typed}: OK closes the alert"); + assert_eq!(radius(&h), want, "{typed}: the closest value is kept"); + } +} + +#[test] +fn path_selection_radius_keeps_its_own_range_without_an_alert() { + for tool in [Tool::PathSelection, Tool::DirectSelection] { + let mut app = new_app(); + app.run("shape.create", json!({"kind": "rect", "rect": [10, 10, 40, 20], "fill": "#ff0000"})).unwrap(); + let mut h = bar_with(app, tool); + type_radius(&mut h, "20000"); + assert_eq!(radius(&h), 10_000.0, "{tool:?}: today's 0..10000 clamp"); + assert!(alerts(&h).is_empty(), "{tool:?}: no alert"); + } +} diff --git a/crates/ui-egui/src/tools/shape_options.rs b/crates/ui-egui/src/tools/shape_options.rs index 8aabe45922..a4a9425068 100644 --- a/crates/ui-egui/src/tools/shape_options.rs +++ b/crates/ui-egui/src/tools/shape_options.rs @@ -101,15 +101,19 @@ pub(crate) fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool o.stroke_width = enabled_stroke_width(o.stroke_width); } } + let mut radius_alert = false; match tool { Tool::Rectangle => { crate::widgets::vline(ui, 22.0); - lbl(ui, tl!("Radius:")); + let label = tl!("Radius:"); + lbl(ui, label); let mut r = match target.as_ref().and_then(|s| s.live.as_ref()) { Some(photocraft_doc::LiveShape::Rect { radii, .. }) => radii[0] as f32, _ => o.corner_radius, }; - if crate::widgets::value_field(ui, &mut r, 0.0..=10000.0, "px", 62.0).changed() { + let (resp, out_of_range) = crate::widgets::value_field_alerting(ui, label, &mut r, RADIUS_RANGE, "px", 62.0); + radius_alert = out_of_range; + if resp.changed() { o.corner_radius = r; let rf = r as f64; edit = Some(json!({"radii": [rf, rf, rf, rf], "coalesce": key("radius")})); @@ -196,8 +200,31 @@ pub(crate) fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool if let Some(rgb) = recent { crate::paint_picker::remember(app, rgb); } + radius_range_alert(app, radius_alert); } +/// The Rectangle tool's corner radius in px, as the reference takes it (#13336). +const RADIUS_RANGE: std::ops::RangeInclusive = 0.0..=150_000.0; + +/// The reference's alert for a Rectangle tool Radius typed past [`RADIUS_RANGE`] (#13336). +pub(crate) const RADIUS_RANGE_ALERT: &str = "A value between 0.000 pixel and 150000.000 Pixels is required. Closest value inserted."; + +/// Shows [`RADIUS_RANGE_ALERT`] as a modal with OK when `owed`, once even if more than one pass asks. +fn radius_range_alert(app: &mut PhotocraftApp, owed: bool) { + if !owed { + return; + } + let message = tl!(RADIUS_RANGE_ALERT).to_owned(); + let shown = app.ui.dialogs.iter().any(|d| d.kind == crate::state::DialogKind::Error && d.fields.get("message").and_then(|m| m.as_str()) == Some(&message)); + if !shown { + app.ui.open_dialog(crate::state::DialogKind::Error, json!({ "message": message }).as_object().cloned().unwrap_or_default()); + } +} + +#[cfg(test)] +#[path = "rectangle_radius_range_tests.rs"] +mod rectangle_radius_range_tests; + /// Tool-bar paint changes affect the next shape; Properties edits the selected shape instead. pub(crate) fn set_tool_paint(app: &mut PhotocraftApp, stroke: bool, change: PaintChange) -> Result<(), String> { // A colour takes the place of a gradient; No Color keeps it for when the paint comes back. diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 9e8d890259..07980681ac 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -54,7 +54,7 @@ pub(crate) use fields::compact_wrapping_value_field; use fields::{FieldOpts, NUMERIC_INPUT_LIMIT, value_field_in}; pub use fields::{ POPUP_ARROW_W, PopupFieldResponse, VALUE_FIELD_H, compact_value_field, elided_label, elided_label_sensed, popup_value_field, typed_text_parses, - value_field, value_field_named, value_field_whole_alerting, value_field_with_trailing, + value_field, value_field_alerting, value_field_named, value_field_whole_alerting, value_field_with_trailing, }; pub use label_column::{LABEL_COLUMN_GAP, column_label, column_label_padded, label_column_width}; pub(crate) use panel::body_margin; diff --git a/crates/ui-egui/src/widgets/fields.rs b/crates/ui-egui/src/widgets/fields.rs index 078d4ef003..d2708ae061 100644 --- a/crates/ui-egui/src/widgets/fields.rs +++ b/crates/ui-egui/src/widgets/fields.rs @@ -24,9 +24,35 @@ pub fn value_field_whole_alerting( range: std::ops::RangeInclusive, suffix: &str, width: f32, +) -> (Response, bool) { + value_field_alerting_in(ui, name, value, range, suffix, width, true) +} + +/// [`value_field_whole_alerting`] for a field that takes decimals (a corner radius, #13336): a +/// typed value past `range` becomes the closest end, unrounded. +pub fn value_field_alerting(ui: &mut Ui, name: &str, value: &mut f32, range: std::ops::RangeInclusive, suffix: &str, width: f32) -> (Response, bool) { + value_field_alerting_in(ui, name, value, range, suffix, width, false) +} + +fn value_field_alerting_in( + ui: &mut Ui, + name: &str, + value: &mut f32, + range: std::ops::RangeInclusive, + suffix: &str, + width: f32, + whole: bool, ) -> (Response, bool) { let (lo, hi) = (*range.start(), *range.end()); - let fit = |v: f32| if v.is_finite() { v.round().clamp(lo, hi) } else { lo }; + let fit = |v: f32| { + if !v.is_finite() { + lo + } else if whole { + v.round().clamp(lo, hi) + } else { + v.clamp(lo, hi) + } + }; *value = fit(*value); let draft_id = ui.make_persistent_id(egui::IdSalt::new(("whole-alerting-typed", name))); // Typed text only while its field still has the keyboard: one left without being drawn (a From 51f001c929cdcb7a3604715a500f2702ce811697 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Wed, 11 Nov 2026 16:02:55 -0500 Subject: [PATCH 23/31] Path menu: an expired point entry is drawn disabled (#12771) Co-Authored-By: Claude Sonnet 5.5 Model-Effort: Opus 5.5 drove; Opus 5.5 briefed; Sonnet 5.5 wrote at medium --- crates/ui-egui/src/canvas/canvas_tool_menu.rs | 2 +- .../canvas_tool_menu/delete_anchor_menu_tests.rs | 12 +++++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/canvas/canvas_tool_menu.rs b/crates/ui-egui/src/canvas/canvas_tool_menu.rs index 40c8e96c02..c66d036d94 100644 --- a/crates/ui-egui/src/canvas/canvas_tool_menu.rs +++ b/crates/ui-egui/src/canvas/canvas_tool_menu.rs @@ -1727,7 +1727,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &Context) { } if let Some((command, ..)) = &menu.point { let item = egui::Button::new(point_label(command)).min_size(egui::vec2(200.0, 20.0)); - if ui.add(item).clicked() { + if ui.add_enabled(available(app, &menu, command), item).clicked() { selected = Some(command.clone()); } ui.separator(); diff --git a/crates/ui-egui/src/canvas/canvas_tool_menu/delete_anchor_menu_tests.rs b/crates/ui-egui/src/canvas/canvas_tool_menu/delete_anchor_menu_tests.rs index 0d6f1923c4..7db812349d 100644 --- a/crates/ui-egui/src/canvas/canvas_tool_menu/delete_anchor_menu_tests.rs +++ b/crates/ui-egui/src/canvas/canvas_tool_menu/delete_anchor_menu_tests.rs @@ -5,7 +5,7 @@ use egui::Modifiers; use egui_kittest::Harness; -use egui_kittest::kittest::Queryable; +use egui_kittest::kittest::{NodeT, Queryable}; use photocraft_doc::vector::Path; use serde_json::json; @@ -178,6 +178,16 @@ fn a_point_entry_expires_once_the_path_is_edited() { assert_eq!(steps(h.state()), n, "and added no history step"); } +/// The expired entry is drawn disabled, not just rejected on click (#12771, CodeRabbit). +#[test] +fn an_expired_point_entry_is_drawn_disabled() { + let mut h = right_clicked(Tool::DeleteAnchorPoint, [300.0, 300.0]); + assert!(!h.get_by_label("Delete Anchor Point").accesskit_node().is_disabled(), "enabled while the path is as it was"); + h.state_mut().run("path.addAnchor", json!({"name": "Path 1", "subpath": 0, "knot": 0, "t": 0.5})).unwrap(); + h.run(); + assert!(h.get_by_label("Delete Anchor Point").accesskit_node().is_disabled(), "disabled once the path is edited"); +} + /// A shape with only its own position lock is ignored by the anchor tools' click (#12730, #12825), /// so the menu offers no point entry on its corner either: the rest of the menu is as before. #[test] From af7c632a86ac08a570c4652b8d6cb40d07dc4279 Mon Sep 17 00:00:00 2001 From: RickiNano <81099017+RickiNano@users.noreply.github.com> Date: Wed, 11 Nov 2026 22:19:14 +0100 Subject: [PATCH 24/31] fix(ui): Radius steps past the ends don't alert; restore keeps up to 150000 (#13336) An arrow-key or focused wheel step past either end of an alerting field was taken for typing and raised the range alert once the field was left; it now stops at the end silently, as the Angle field does. A saved Rectangle Radius above 10000 was cut back to 10000 on restore; it now keeps up to the field's 150000. --- crates/ui-egui/src/tool_memory.rs | 2 +- .../src/tools/rectangle_radius_range_tests.rs | 67 +++++++++++++++++++ crates/ui-egui/src/tools/shape_options.rs | 2 +- crates/ui-egui/src/widgets/fields.rs | 8 ++- 4 files changed, 75 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/tool_memory.rs b/crates/ui-egui/src/tool_memory.rs index 4775e15db9..b13a4020ab 100644 --- a/crates/ui-egui/src/tool_memory.rs +++ b/crates/ui-egui/src/tool_memory.rs @@ -361,7 +361,7 @@ pub(crate) fn sanitize(o: &mut ToolOptions) { fit(&mut o.exposure, 1.0, 100.0, d.exposure); fit(&mut o.strength, 1.0, 100.0, d.strength); fit(&mut o.stroke_width, 0.0, 288.0, d.stroke_width); - fit(&mut o.corner_radius, 0.0, 10000.0, d.corner_radius); + fit(&mut o.corner_radius, *crate::shape_options::RADIUS_RANGE.start(), *crate::shape_options::RADIUS_RANGE.end(), d.corner_radius); fit(&mut o.line_weight, 1.0, 1000.0, d.line_weight); fit(&mut o.curve_fit, 0.5, 10.0, d.curve_fit); // The marquee's and the Slice tool's Width and Height are a ratio or a size in px, as their diff --git a/crates/ui-egui/src/tools/rectangle_radius_range_tests.rs b/crates/ui-egui/src/tools/rectangle_radius_range_tests.rs index 2a85f87aa9..57de138e47 100644 --- a/crates/ui-egui/src/tools/rectangle_radius_range_tests.rs +++ b/crates/ui-egui/src/tools/rectangle_radius_range_tests.rs @@ -93,3 +93,70 @@ fn path_selection_radius_keeps_its_own_range_without_an_alert() { assert!(alerts(&h).is_empty(), "{tool:?}: no alert"); } } + +/// Focuses Radius, presses `key` `n` times, then Tabs out. +fn step_radius(h: &mut Harness<'static, PhotocraftApp>, key: egui::Key, n: usize) { + h.query_all_by_role(Role::SpinButton).find(|n| n.accesskit_node().label().as_deref() == Some("Radius:")).expect("the Radius field").click(); + h.run_steps(1); + for _ in 0..n { + h.key_press(key); + h.run_steps(1); + } + h.key_press(egui::Key::Tab); + h.run_steps(3); +} + +#[test] +fn arrow_steps_stop_at_the_ends_without_the_alert() { + let mut h = bar(); + type_radius(&mut h, "149998"); + step_radius(&mut h, egui::Key::ArrowUp, 5); + assert_eq!(radius(&h), 150_000.0); + type_radius(&mut h, "2"); + step_radius(&mut h, egui::Key::ArrowDown, 5); + assert_eq!(radius(&h), 0.0); + assert!(alerts(&h).is_empty(), "stepping is not typing: {:?}", alerts(&h)); +} + +#[test] +fn wheel_steps_with_the_focus_stop_at_the_end_without_the_alert() { + // As in the app: the focused field takes the wheel before the bar is laid out. + let mut app = new_app(); + app.ui.tool = Tool::Rectangle; + let mut h = Harness::builder().with_size(vec2(1800.0, 300.0)).build_ui_state( + |ui, app| { + crate::field_wheel::begin_frame(ui.ctx()); + crate::panels::options_bar(app, ui) + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, Default::default()); + h.run_steps(4); + type_radius(&mut h, "149998"); + h.query_all_by_role(Role::SpinButton).find(|n| n.accesskit_node().label().as_deref() == Some("Radius:")).expect("the Radius field").click(); + h.run_steps(1); + for _ in 0..5 { + h.event(egui::Event::MouseWheel { + source: egui::MouseWheelSource::Unknown, + unit: egui::MouseWheelUnit::Line, + delta: vec2(0.0, 1.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + }); + h.run_steps(1); + } + h.key_press(egui::Key::Tab); + h.run_steps(3); + assert_eq!(radius(&h), 150_000.0); + assert!(alerts(&h).is_empty(), "a wheel step is not typing: {:?}", alerts(&h)); +} + +#[test] +fn a_saved_radius_up_to_the_range_end_is_restored() { + let mut o = crate::state::ToolOptions { corner_radius: 150_000.0, ..Default::default() }; + crate::tool_memory::sanitize(&mut o); + assert_eq!(o.corner_radius, 150_000.0); + o.corner_radius = 200_000.0; + crate::tool_memory::sanitize(&mut o); + assert_eq!(o.corner_radius, 150_000.0, "past the range takes the end"); +} diff --git a/crates/ui-egui/src/tools/shape_options.rs b/crates/ui-egui/src/tools/shape_options.rs index a4a9425068..d0f7e3c5a8 100644 --- a/crates/ui-egui/src/tools/shape_options.rs +++ b/crates/ui-egui/src/tools/shape_options.rs @@ -204,7 +204,7 @@ pub(crate) fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool } /// The Rectangle tool's corner radius in px, as the reference takes it (#13336). -const RADIUS_RANGE: std::ops::RangeInclusive = 0.0..=150_000.0; +pub(crate) const RADIUS_RANGE: std::ops::RangeInclusive = 0.0..=150_000.0; /// The reference's alert for a Rectangle tool Radius typed past [`RADIUS_RANGE`] (#13336). pub(crate) const RADIUS_RANGE_ALERT: &str = "A value between 0.000 pixel and 150000.000 Pixels is required. Closest value inserted."; diff --git a/crates/ui-egui/src/widgets/fields.rs b/crates/ui-egui/src/widgets/fields.rs index d2708ae061..0989f85535 100644 --- a/crates/ui-egui/src/widgets/fields.rs +++ b/crates/ui-egui/src/widgets/fields.rs @@ -16,7 +16,8 @@ pub fn value_field(ui: &mut Ui, value: &mut f32, range: std::ops::RangeInclusive /// (Photoshop's "Closest value inserted" alerts, #11938): it stores the closest whole number in /// range, and returns true once the field is left after such a value was typed, so the caller /// shows its alert. While the field has the keyboard it keeps the typed text (a `0` on the way to -/// `05` stays). A scrub past the ends just stops there and says nothing. +/// `05` stays). A scrub, an arrow-key or a wheel step past the ends just stops there and says +/// nothing. pub fn value_field_whole_alerting( ui: &mut Ui, name: &str, @@ -60,6 +61,9 @@ fn value_field_alerting_in( let draft = ui.ctx().data(|d| d.get_temp::<(f32, f32, Option)>(draft_id)); let draft = draft.filter(|(_, stored, field)| *stored == *value && field.is_some_and(|f| ui.ctx().memory(|m| m.has_focus(f)))); let mut typed = draft.map_or(*value, |(typed, _, _)| typed); + // Read before the field: it consumes the arrow keys it steps with, and the wheel it steps + // with while focused never shows in the scroll delta. A step past the ends is no typing. + let stepped = ui.input(|i| i.key_pressed(egui::Key::ArrowUp) || i.key_pressed(egui::Key::ArrowDown)) || crate::field_wheel::focused_field_wheeled(ui.ctx()); // The field's own range is wide, so the typed value reaches here unclamped. let r = value_field_named(ui, name, &mut typed, -1.0e9..=1.0e9, suffix, width); let out_id = r.id.with("out-of-range"); @@ -68,7 +72,7 @@ fn value_field_alerting_in( } if r.changed() && typed.is_finite() { *value = fit(typed); - let typing = (r.has_focus() || r.lost_focus()) && ui.ctx().dragged_id().is_none(); + let typing = (r.has_focus() || r.lost_focus()) && ui.ctx().dragged_id().is_none() && !stepped; let out = typing && !(lo..=hi).contains(&typed); ui.ctx().data_mut(|d| d.insert_temp(out_id, out)); if typing && r.has_focus() { From 5bfab0f449048ff8a0895c728d3629494000a844 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Thu, 12 Nov 2026 11:41:46 +0530 Subject: [PATCH 25/31] Shape tools: Path operations, alignment and arrangement menus (#13235) The shape tools' options bar gets Path operations, Path alignment and Path arrangement after the shape options. The operation is the shape tools' own setting (New Layer by default, Exclude in Path mode); any other operation adds the next drag to the active shape layer with shape.create addTo and op. Reset Tool restores New Layer. Alignment and arrangement entries stay greyed until the engine can move path components. --- crates/ui-egui/src/state/tool_options.rs | 10 ++ crates/ui-egui/src/tools/path_components.rs | 2 +- crates/ui-egui/src/tools/tool_reset.rs | 14 +- crates/ui-egui/src/tools/vector_ui.rs | 5 +- .../vector_ui/ellipse_path_menus_tests.rs | 155 ++++++++++++++++++ crates/ui-egui/src/tools/vector_ui/options.rs | 7 + crates/ui-egui/src/tools/vector_ui/pen.rs | 121 +++++++++++++- crates/ui-egui/src/tools/vector_ui/shapes.rs | 31 ++++ 8 files changed, 337 insertions(+), 8 deletions(-) create mode 100644 crates/ui-egui/src/tools/vector_ui/ellipse_path_menus_tests.rs diff --git a/crates/ui-egui/src/state/tool_options.rs b/crates/ui-egui/src/state/tool_options.rs index 18d9c09d48..ea741e4b97 100644 --- a/crates/ui-egui/src/state/tool_options.rs +++ b/crates/ui-egui/src/state/tool_options.rs @@ -103,6 +103,11 @@ pub struct ToolOptions { /// draws it as "combine"), "combine", "subtract", "intersect" or "exclude". #[serde(default = "default_pen_path_op")] pub pen_path_op: String, + /// Shape tools › Path operations (#13235): how a drag combines with the active shape layer + /// (Shape mode) or work path (Path mode). Same values as `pen_path_op`; in Path mode "newLayer" + /// means Exclude Overlapping Shapes. + #[serde(default = "default_shape_path_op")] + pub shape_path_op: String, /// Shape tools: "shape" (a shape layer) or "path" (the work path), as Photoshop's tool mode /// (#9453). Shape by default, where the Pen's own mode defaults to Path. pub shape_mode: String, @@ -332,6 +337,10 @@ fn default_pen_path_op() -> String { "newLayer".into() } +fn default_shape_path_op() -> String { + "newLayer".into() +} + fn default_curve_fit() -> f32 { 2.0 } @@ -502,6 +511,7 @@ impl Default for ToolOptions { enhance_edge: false, vector_mode: "path".into(), pen_path_op: default_pen_path_op(), + shape_path_op: default_shape_path_op(), shape_mode: "shape".into(), shape_fill: true, stroke_width: 0.0, diff --git a/crates/ui-egui/src/tools/path_components.rs b/crates/ui-egui/src/tools/path_components.rs index 74f866c624..041279b748 100644 --- a/crates/ui-egui/src/tools/path_components.rs +++ b/crates/ui-egui/src/tools/path_components.rs @@ -64,7 +64,7 @@ pub(crate) fn selected_op(app: &PhotocraftApp) -> Option<&'static str> { /// for the next path. New Layer is not an operation a component can have: it only sets the tool. /// The first component always combines (it is what the others act on), so it keeps its operation. pub(crate) fn set_op(app: &mut PhotocraftApp, op: &str) -> Result<(), String> { - app.ui.tool_options.pen_path_op = op.into(); + *crate::vector_ui::path_op_field(app) = op.into(); if op == "newLayer" { return Ok(()); } diff --git a/crates/ui-egui/src/tools/tool_reset.rs b/crates/ui-egui/src/tools/tool_reset.rs index 0351a0eeae..7f439d31e8 100644 --- a/crates/ui-egui/src/tools/tool_reset.rs +++ b/crates/ui-egui/src/tools/tool_reset.rs @@ -250,6 +250,7 @@ pub fn option_keys(tool: Tool) -> &'static [&'static str] { Tool::FreeformPen => reset!(vector_mode, pen_path_op, curve_fit), Tool::Line => reset!( shape_mode, + shape_path_op, shape_fill, stroke_width, shape_stroke, @@ -265,7 +266,18 @@ pub fn option_keys(tool: Tool) -> &'static [&'static str] { arrow_concavity, ), Tool::Rectangle | Tool::EllipseShape | Tool::Triangle | Tool::Polygon | Tool::Star | Tool::CustomShape => { - reset!(shape_mode, shape_fill, stroke_width, shape_stroke, shape_fill_gradient, shape_stroke_gradient, corner_radius, polygon_sides, line_weight) + reset!( + shape_mode, + shape_path_op, + shape_fill, + stroke_width, + shape_stroke, + shape_fill_gradient, + shape_stroke_gradient, + corner_radius, + polygon_sides, + line_weight + ) } Tool::MixerBrush => reset!(mixer_combination), Tool::ColorSampler diff --git a/crates/ui-egui/src/tools/vector_ui.rs b/crates/ui-egui/src/tools/vector_ui.rs index 1cdf3c357e..558d7a60f0 100644 --- a/crates/ui-egui/src/tools/vector_ui.rs +++ b/crates/ui-egui/src/tools/vector_ui.rs @@ -57,7 +57,7 @@ pub use paths_panel::{PathEntry, PathRow, path_rows, paths_footer, paths_panel}; pub(crate) use pen::over_first_anchor; #[cfg(test)] use pen::pen_to_json; -pub(crate) use pen::{PATH_OPS, pen_path, pen_replace}; +pub(crate) use pen::{PATH_OPS, path_op_field, pen_path, pen_replace}; pub use pen::{ cancel_stale, closes_path_at, continues_path_at, freeform_finish, pen_break_handle, pen_commit, pen_down, pen_end, pen_enter, pen_grab_handle, pen_move, pen_undo_last_point, pen_up, @@ -238,6 +238,9 @@ mod pen_enter_deselects_tests; #[cfg(test)] mod pen_mode_menu_tests; +#[cfg(test)] +mod ellipse_path_menus_tests; + pub(crate) use pen::freeform_locked_shape; #[cfg(test)] use pen_resume::pen_finish_on_redo; diff --git a/crates/ui-egui/src/tools/vector_ui/ellipse_path_menus_tests.rs b/crates/ui-egui/src/tools/vector_ui/ellipse_path_menus_tests.rs new file mode 100644 index 0000000000..0d34abb993 --- /dev/null +++ b/crates/ui-egui/src/tools/vector_ui/ellipse_path_menus_tests.rs @@ -0,0 +1,155 @@ +//! #13235: the shape tools' options bar has Path operations, Path alignment and Path arrangement. +//! The operation is the shape tools' own setting: with one other than New Layer a drag becomes a +//! component of the active shape layer; Reset Tool puts it back to New Layer. + +use egui::vec2; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use photocraft_doc::vector::PathOp; +use photocraft_doc::{LayerContent, ShapeLayer}; +use serde_json::json; + +use super::pen::{PATH_ALIGN_ENTRIES, PATH_ARRANGE_ENTRIES, PATH_DISTRIBUTE_ENTRIES, PATH_SPACING_ENTRIES, shape_path_op}; +use crate::PhotocraftApp; +use crate::canvas::{ToolEvent, tool_event}; +use crate::state::{Tool, ToolOptions}; + +fn app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 400})).unwrap(); + app.sync_views(); + app.ui.tool = Tool::EllipseShape; + app +} + +fn drag(app: &mut PhotocraftApp, from: [f64; 2], to: [f64; 2]) { + let mods = egui::Modifiers::NONE; + tool_event(app, ToolEvent::Down { x: from[0], y: from[1], pressure: 1.0 }, mods); + tool_event(app, ToolEvent::Move { x: to[0], y: to[1], pressure: 1.0 }, mods); + tool_event(app, ToolEvent::Up { x: to[0], y: to[1] }, mods); +} + +fn layer_count(app: &PhotocraftApp) -> usize { + app.session.active().unwrap().doc.layer_count() +} + +fn active_shape(app: &PhotocraftApp) -> ShapeLayer { + let st = app.session.active().unwrap(); + match &st.doc.layer(st.active_layer.unwrap()).unwrap().content { + LayerContent::Shape(s) => s.clone(), + _ => panic!("the active layer is not a shape layer"), + } +} + +#[test] +fn defaults_are_new_layer_in_shape_mode_and_exclude_in_path_mode() { + let mut o = ToolOptions::default(); + assert_eq!(o.shape_path_op, "newLayer"); + assert_eq!(shape_path_op(&o), None); + o.shape_mode = "path".into(); + assert_eq!(shape_path_op(&o), Some("exclude")); +} + +#[test] +fn an_operation_adds_the_next_drag_to_the_active_shape_layer() { + for (op, want) in [("combine", PathOp::Combine), ("subtract", PathOp::Subtract), ("intersect", PathOp::Intersect), ("exclude", PathOp::Exclude)] { + let mut app = app(); + drag(&mut app, [50.0, 50.0], [200.0, 200.0]); + let layers = layer_count(&app); + app.ui.tool_options.shape_path_op = op.into(); + drag(&mut app, [100.0, 100.0], [250.0, 250.0]); + assert_eq!(layer_count(&app), layers, "{op}: no new layer"); + let shape = active_shape(&app); + assert_eq!(shape.path.subpaths.len(), 2, "{op}: a second component"); + assert_eq!(shape.path.subpaths[1].op, want, "{op}"); + } +} + +#[test] +fn new_layer_makes_a_new_shape_layer() { + let mut app = app(); + drag(&mut app, [50.0, 50.0], [200.0, 200.0]); + let layers = layer_count(&app); + drag(&mut app, [100.0, 100.0], [250.0, 250.0]); + assert_eq!(layer_count(&app), layers + 1); +} + +#[test] +fn an_operation_with_a_pixel_layer_active_still_makes_a_shape_layer() { + let mut app = app(); + app.run("layer.new", json!({})).unwrap(); + app.ui.tool_options.shape_path_op = "combine".into(); + let layers = layer_count(&app); + drag(&mut app, [50.0, 50.0], [200.0, 200.0]); + assert_eq!(layer_count(&app), layers + 1); + assert!(matches!(app.session.active().unwrap().doc.layer(app.session.active().unwrap().active_layer.unwrap()).unwrap().content, LayerContent::Shape(_))); +} + +#[test] +fn set_op_writes_the_active_tools_own_setting() { + let mut app = app(); + crate::path_components::set_op(&mut app, "subtract").unwrap(); + assert_eq!(app.ui.tool_options.shape_path_op, "subtract"); + assert_eq!(app.ui.tool_options.pen_path_op, "newLayer"); + app.ui.tool = Tool::Pen; + crate::path_components::set_op(&mut app, "intersect").unwrap(); + assert_eq!(app.ui.tool_options.pen_path_op, "intersect"); + assert_eq!(app.ui.tool_options.shape_path_op, "subtract"); +} + +#[test] +fn reset_tool_restores_new_layer() { + let mut app = app(); + app.ui.tool_options.shape_path_op = "exclude".into(); + crate::menus::invoke(&mut app, &egui::Context::default(), crate::tool_reset::RESET, json!({})).unwrap(); + assert_eq!(app.ui.tool_options.shape_path_op, "newLayer"); +} + +#[test] +fn entries_follow_the_reference_order() { + let labels = |e: &[(&str, &str)]| e.iter().map(|(l, _)| *l).collect::>(); + assert_eq!( + labels(&PATH_ALIGN_ENTRIES), + ["Align Left Edges", "Align Horizontal Centers", "Align Right Edges", "Align Top Edges", "Align Vertical Centers", "Align Bottom Edges"] + ); + assert_eq!( + labels(&PATH_DISTRIBUTE_ENTRIES), + [ + "Distribute Left Edges", + "Distribute Horizontal Centers", + "Distribute Right Edges", + "Distribute Top Edges", + "Distribute Vertical Centers", + "Distribute Bottom Edges" + ] + ); + assert_eq!(labels(&PATH_SPACING_ENTRIES), ["Distribute Horizontally", "Distribute Vertically"]); + assert_eq!(PATH_ARRANGE_ENTRIES, ["Bring Shape To Front", "Bring Shape Forward", "Send Shape Backward", "Send Shape To Back"]); +} + +#[test] +fn options_bar_shows_the_three_buttons_and_greys_the_entries_without_a_shape() { + let mut h = Harness::builder().with_size(vec2(1600.0, 500.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + ui.horizontal(|ui| super::options_bar(app, ui, Tool::EllipseShape)); + } + }, + app(), + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Studio); + h.run_steps(4); + let x = |h: &Harness<'_, PhotocraftApp>, l: &str| h.get_by_label(l).rect().min.x; + let order = [x(&h, "Path operations"), x(&h, "Path alignment"), x(&h, "Path arrangement")]; + assert!(order.is_sorted_by(|a, b| a < b), "left to right: {order:?}"); + h.get_by_label("Path operations").click(); + h.run_steps(3); + assert!(h.get_by_label("Merge Shape Components").accesskit_node().is_disabled()); + h.key_press(egui::Key::Escape); + h.run_steps(2); + h.get_by_label("Path alignment").click(); + h.run_steps(3); + for (label, _) in PATH_ALIGN_ENTRIES { + assert!(h.get_by_label(label).accesskit_node().is_disabled(), "{label}"); + } +} diff --git a/crates/ui-egui/src/tools/vector_ui/options.rs b/crates/ui-egui/src/tools/vector_ui/options.rs index 39519be0a6..28772ea0f9 100644 --- a/crates/ui-egui/src/tools/vector_ui/options.rs +++ b/crates/ui-egui/src/tools/vector_ui/options.rs @@ -137,6 +137,13 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo // geometry, the Line's arrowheads included, stays. let paint = !(is_shape_tool(tool) && draws_path(&app.ui.tool_options)); crate::shape_options::options_bar(app, ui, tool, paint); + if is_shape_tool(tool) { + // Path operations, alignment and arrangement, in Shape and Path mode (#13235). + crate::widgets::vline(ui, 22.0); + super::pen::path_operations_menu(app, ui); + super::pen::path_alignment_menu(app, ui); + super::pen::path_arrangement_menu(app, ui); + } // Photoshop-style arrowhead settings at the right of Line options. if tool == Tool::Line { crate::widgets::vline(ui, 22.0); diff --git a/crates/ui-egui/src/tools/vector_ui/pen.rs b/crates/ui-egui/src/tools/vector_ui/pen.rs index 28203f6441..461bbb0c65 100644 --- a/crates/ui-egui/src/tools/vector_ui/pen.rs +++ b/crates/ui-egui/src/tools/vector_ui/pen.rs @@ -709,6 +709,41 @@ pub(crate) fn pen_path_op(o: &ToolOptions) -> Option<&'static str> { } } +/// The operation (`shape.create` `op`) a Shape-tool drag is added with (#13235); None for a new +/// shape layer (New Layer, in Shape mode). Path mode has no New Layer: there it is Exclude +/// Overlapping Shapes, as the reference application's default. +pub(crate) fn shape_path_op(o: &ToolOptions) -> Option<&'static str> { + let op = PATH_OPS.iter().map(|(v, ..)| *v).find(|v| *v == o.shape_path_op).unwrap_or("newLayer"); + match op { + "newLayer" if o.shape_mode == "shape" => None, + "newLayer" => Some("exclude"), + op => Some(op), + } +} + +/// Whether the active tool draws shape layers (so New Layer applies): the Shape tools in Shape +/// mode, the Pens in Shape mode; Path Selection has no shape layer to make. +fn tool_in_shape_mode(app: &PhotocraftApp) -> bool { + let (tool, o) = (app.ui.tool, &app.ui.tool_options); + if tool == Tool::PathSelection { + false + } else if super::shapes::is_shape_tool(tool) { + o.shape_mode == "shape" + } else { + o.vector_mode == "shape" + } +} + +/// The active tool's resolved operation (see `pen_path_op` and `shape_path_op`). +fn tool_path_op(app: &PhotocraftApp) -> Option<&'static str> { + if super::shapes::is_shape_tool(app.ui.tool) { shape_path_op(&app.ui.tool_options) } else { pen_path_op(&app.ui.tool_options) } +} + +/// The Path Operations setting the active tool keeps: the Shape tools' own, else the Pens'. +pub(crate) fn path_op_field(app: &mut PhotocraftApp) -> &mut String { + if super::shapes::is_shape_tool(app.ui.tool) { &mut app.ui.tool_options.shape_path_op } else { &mut app.ui.tool_options.pen_path_op } +} + /// What the Path Operations menu's Merge Shape Components merges, as a command, its params and /// the number of shape components there: with Path Selection the path it selects in; in Shape /// mode the selected shape layer; in Path mode the path the Pen draws into (a targeted vector @@ -719,7 +754,7 @@ fn merge_target(app: &PhotocraftApp) -> Option<(&'static str, Value, usize)> { return Some(("path.mergeShapeComponents", r.params(app), p.components().len())); } let st = app.session.active()?; - if app.ui.tool_options.vector_mode == "shape" { + if tool_in_shape_mode(app) { let id = st.active_layer?; let LayerContent::Shape(sh) = &st.doc.layer(id)?.content else { return None }; return Some(("layer.combineShapes.mergeShapeComponents", json!({"layer": id.0}), sh.path.components().len())); @@ -738,10 +773,9 @@ fn merge_target(app: &PhotocraftApp) -> Option<(&'static str, Value, usize)> { /// shows the selected components' operation, else the tool's. pub(super) fn path_operations_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui) { // New Layer makes a shape layer: Path mode and Path Selection have none. - let shape = app.ui.tool_options.vector_mode == "shape" && app.ui.tool != Tool::PathSelection; - let current = crate::path_components::selected_op(app) - .or_else(|| pen_path_op(&app.ui.tool_options)) - .map_or("newLayer", |op| if op == "newLayer" && !shape { "combine" } else { op }); + let shape = tool_in_shape_mode(app); + let current = + crate::path_components::selected_op(app).or_else(|| tool_path_op(app)).map_or("newLayer", |op| if op == "newLayer" && !shape { "combine" } else { op }); let icon = PATH_OPS.iter().find(|(v, ..)| *v == current).map_or("path-op-combine", |(.., i)| *i); let resp = crate::icons::button_with_icon_size(ui, icon, 24.0, 16.0, false, tl!("Path operations")); let resp = crate::brush_picker::named(resp, "Path operations"); @@ -776,6 +810,83 @@ pub(super) fn path_operations_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } } +/// Path alignment's Align entries: label and icon (#13235). +pub(crate) const PATH_ALIGN_ENTRIES: [(&str, &str); 6] = [ + ("Align Left Edges", "align-edge-left"), + ("Align Horizontal Centers", "align-edge-hcenter"), + ("Align Right Edges", "align-edge-right"), + ("Align Top Edges", "align-edge-top"), + ("Align Vertical Centers", "align-edge-vcenter"), + ("Align Bottom Edges", "align-edge-bottom"), +]; + +/// Path alignment's Distribute entries. +pub(crate) const PATH_DISTRIBUTE_ENTRIES: [(&str, &str); 6] = [ + ("Distribute Left Edges", "distribute-left"), + ("Distribute Horizontal Centers", "distribute-hcenter"), + ("Distribute Right Edges", "distribute-right"), + ("Distribute Top Edges", "distribute-top"), + ("Distribute Vertical Centers", "distribute-vcenter"), + ("Distribute Bottom Edges", "distribute-bottom"), +]; + +/// Path alignment's Distribute Spacing entries. +pub(crate) const PATH_SPACING_ENTRIES: [(&str, &str); 2] = + [("Distribute Horizontally", "distribute-horizontal"), ("Distribute Vertically", "distribute-vertical")]; + +/// Path arrangement's entries, front to back. +pub(crate) const PATH_ARRANGE_ENTRIES: [&str; 4] = ["Bring Shape To Front", "Bring Shape Forward", "Send Shape Backward", "Send Shape To Back"]; + +/// The Path alignment button of the Shape tools (#13235). Every entry is greyed: the engine has no +/// command that aligns or distributes the components of a path yet. +pub(super) fn path_alignment_menu(_app: &mut PhotocraftApp, ui: &mut egui::Ui) { + let resp = crate::icons::button_with_icon_size(ui, "align-edge-left", 24.0, 16.0, false, tl!("Path alignment")); + let resp = crate::brush_picker::named(resp, "Path alignment"); + // Stays open while the Align To dropdown is used. + egui::Popup::menu(&resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { + ui.set_min_width(240.0); + ui.label(tl!("Align:")); + for (label, glyph) in PATH_ALIGN_ENTRIES { + ui.add_enabled_ui(false, |ui| path_op_button(ui, glyph, tl!(label), false)); + } + ui.separator(); + ui.label(tl!("Distribute:")); + for (label, glyph) in PATH_DISTRIBUTE_ENTRIES { + ui.add_enabled_ui(false, |ui| path_op_button(ui, glyph, tl!(label), false)); + } + ui.separator(); + ui.label(tl!("Distribute Spacing:")); + for (label, glyph) in PATH_SPACING_ENTRIES { + ui.add_enabled_ui(false, |ui| path_op_button(ui, glyph, tl!(label), false)); + } + ui.separator(); + let key = egui::Id::new("path-align-to"); + let mut canvas = ui.data(|d| d.get_temp::(key)).unwrap_or(false); + ui.horizontal(|ui| { + ui.label(tl!("Align To:")); + let text = if canvas { tl!("Canvas") } else { tl!("Selection") }; + egui::ComboBox::from_id_salt("path-align-to-box").selected_text(text).show_ui(ui, |ui| { + ui.selectable_value(&mut canvas, false, tl!("Selection")); + ui.selectable_value(&mut canvas, true, tl!("Canvas")); + }); + }); + ui.data_mut(|d| d.insert_temp(key, canvas)); + }); +} + +/// The Path arrangement button of the Shape tools (#13235). Its entries are greyed: the engine +/// has no command that reorders the components of a path yet. +pub(super) fn path_arrangement_menu(_app: &mut PhotocraftApp, ui: &mut egui::Ui) { + let resp = crate::icons::button_with_icon_size(ui, "layers", 24.0, 16.0, false, tl!("Path arrangement")); + let resp = crate::brush_picker::named(resp, "Path arrangement"); + egui::Popup::menu(&resp).show(|ui| { + ui.set_min_width(200.0); + for label in PATH_ARRANGE_ENTRIES { + ui.add_enabled(false, egui::Button::new(tl!(label))); + } + }); +} + /// A Path Operations menu row: its icon and label, highlighted when it is the current operation. fn path_op_button(ui: &mut egui::Ui, glyph: &str, label: &str, checked: bool) -> egui::Response { let icon = egui::IdSalt::new((glyph, "icon")); diff --git a/crates/ui-egui/src/tools/vector_ui/shapes.rs b/crates/ui-egui/src/tools/vector_ui/shapes.rs index 3d80360ea5..dd1fab09d9 100644 --- a/crates/ui-egui/src/tools/vector_ui/shapes.rs +++ b/crates/ui-egui/src/tools/vector_ui/shapes.rs @@ -151,7 +151,15 @@ pub fn finish_shape(app: &mut PhotocraftApp, tool: Tool, start: [f64; 2], end: [ .and_then(|st| st.doc.work_path.clone()) .unwrap_or_else(|| photocraft_doc::vector::Path { fill_rule: outline.fill_rule, ..Default::default() }); let outline = as_one_component(&outline, path.fill_rule).unwrap_or(outline); + let first_new = path.subpaths.len(); path.subpaths.extend(outline.subpaths); + // The first component of a path always combines; the later ones take the drag's operation. + if first_new > 0 + && let Some(op) = drag_path_op(app) + && let Some(sp) = path.subpaths.get_mut(first_new) + { + sp.op = path_op_of(op); + } let path = photocraft_engine::vector_cmds::path_json(&path); let r = app.run("path.set", json!({"name": "work", "path": path, "historyName": line_history_name(tool)})); match r { @@ -173,12 +181,35 @@ pub fn finish_shape(app: &mut PhotocraftApp, tool: Tool, start: [f64; 2], end: [ return; } p["historyName"] = json!(line_history_name(tool)); + // With an operation other than New Layer the drag joins the active shape layer. + if let Some(op) = drag_path_op(app) + && let Some(id) = super::options::selected_shape(&app.session) + { + p["addTo"] = json!(id.0); + p["op"] = json!(op); + } if let Err(e) = create_shape(app, p) { app.ui.status = e; app.ui.status_error = true; } } +/// The operation the next Shape-tool drag is added with (the options bar's Path operations); +/// None for a new shape layer. The one place press modifiers can override it (#13227). +fn drag_path_op(app: &PhotocraftApp) -> Option<&'static str> { + super::pen::shape_path_op(&app.ui.tool_options) +} + +fn path_op_of(op: &str) -> photocraft_doc::vector::PathOp { + use photocraft_doc::vector::PathOp; + match op { + "subtract" => PathOp::Subtract, + "intersect" => PathOp::Intersect, + "exclude" => PathOp::Exclude, + _ => PathOp::Combine, + } +} + /// A Line tool drag is one History step named after the tool, as in Photoshop (#13244); the /// other Shape tools keep the commands' own names. fn line_history_name(tool: Tool) -> Option<&'static str> { From 5b5e706f90fef537c322a67e1ad1e7fb210b5719 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Thu, 12 Nov 2026 12:12:16 +0530 Subject: [PATCH 26/31] Use craft_ui::ComboBox and Id::unique in Path alignment; fix test lifetime --- .../ui-egui/src/tools/vector_ui/ellipse_path_menus_tests.rs | 5 ++++- crates/ui-egui/src/tools/vector_ui/pen.rs | 4 ++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/tools/vector_ui/ellipse_path_menus_tests.rs b/crates/ui-egui/src/tools/vector_ui/ellipse_path_menus_tests.rs index 0d34abb993..580a6f19e3 100644 --- a/crates/ui-egui/src/tools/vector_ui/ellipse_path_menus_tests.rs +++ b/crates/ui-egui/src/tools/vector_ui/ellipse_path_menus_tests.rs @@ -105,9 +105,12 @@ fn reset_tool_restores_new_layer() { assert_eq!(app.ui.tool_options.shape_path_op, "newLayer"); } +fn labels(e: &[(&'static str, &'static str)]) -> Vec<&'static str> { + e.iter().map(|(l, _)| *l).collect() +} + #[test] fn entries_follow_the_reference_order() { - let labels = |e: &[(&str, &str)]| e.iter().map(|(l, _)| *l).collect::>(); assert_eq!( labels(&PATH_ALIGN_ENTRIES), ["Align Left Edges", "Align Horizontal Centers", "Align Right Edges", "Align Top Edges", "Align Vertical Centers", "Align Bottom Edges"] diff --git a/crates/ui-egui/src/tools/vector_ui/pen.rs b/crates/ui-egui/src/tools/vector_ui/pen.rs index 461bbb0c65..cac8010dc1 100644 --- a/crates/ui-egui/src/tools/vector_ui/pen.rs +++ b/crates/ui-egui/src/tools/vector_ui/pen.rs @@ -860,12 +860,12 @@ pub(super) fn path_alignment_menu(_app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.add_enabled_ui(false, |ui| path_op_button(ui, glyph, tl!(label), false)); } ui.separator(); - let key = egui::Id::new("path-align-to"); + let key = egui::Id::unique("path-align-to"); let mut canvas = ui.data(|d| d.get_temp::(key)).unwrap_or(false); ui.horizontal(|ui| { ui.label(tl!("Align To:")); let text = if canvas { tl!("Canvas") } else { tl!("Selection") }; - egui::ComboBox::from_id_salt("path-align-to-box").selected_text(text).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("path-align-to-box").selected_text(text).show_ui(ui, |ui| { ui.selectable_value(&mut canvas, false, tl!("Selection")); ui.selectable_value(&mut canvas, true, tl!("Canvas")); }); From cc06433f0a21fa252bb4cfa57de41e15903cf3b2 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Thu, 12 Nov 2026 13:09:59 +0530 Subject: [PATCH 27/31] Path mode's default Exclude in the custom-shape test; use layer.new.layer --- crates/ui-egui/src/tools/vector_ui/ellipse_path_menus_tests.rs | 2 +- crates/ui-egui/src/tools/vector_ui/tests.rs | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/tools/vector_ui/ellipse_path_menus_tests.rs b/crates/ui-egui/src/tools/vector_ui/ellipse_path_menus_tests.rs index 580a6f19e3..c6000b3512 100644 --- a/crates/ui-egui/src/tools/vector_ui/ellipse_path_menus_tests.rs +++ b/crates/ui-egui/src/tools/vector_ui/ellipse_path_menus_tests.rs @@ -77,7 +77,7 @@ fn new_layer_makes_a_new_shape_layer() { #[test] fn an_operation_with_a_pixel_layer_active_still_makes_a_shape_layer() { let mut app = app(); - app.run("layer.new", json!({})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); app.ui.tool_options.shape_path_op = "combine".into(); let layers = layer_count(&app); drag(&mut app, [50.0, 50.0], [200.0, 200.0]); diff --git a/crates/ui-egui/src/tools/vector_ui/tests.rs b/crates/ui-egui/src/tools/vector_ui/tests.rs index b7d19a4d5f..ecfdd7e72d 100644 --- a/crates/ui-egui/src/tools/vector_ui/tests.rs +++ b/crates/ui-egui/src/tools/vector_ui/tests.rs @@ -539,7 +539,8 @@ fn path_mode_custom_shape_joins_its_hole_to_it() { app.ui.presets_ui.custom_shape = "Ring".into(); finish_shape(&mut app, Tool::CustomShape, [20.0, 20.0], [70.0, 70.0], egui::Modifiers::NONE); let ops: Vec = app.session.active().unwrap().doc.work_path.as_ref().unwrap().subpaths.iter().map(|s| s.op).collect(); - assert_eq!(ops, [PathOp::Combine, PathOp::Combine, PathOp::Join], "the rectangle, the ring and its hole"); + // The ring takes Path mode's default operation, Exclude Overlapping Shapes (#13235). + assert_eq!(ops, [PathOp::Combine, PathOp::Exclude, PathOp::Join], "the rectangle, the ring and its hole"); assert_eq!(work_covered(&app, 45, 45), 0.0, "the ring's hole"); assert_eq!(work_covered(&app, 22, 45), 1.0, "the ring"); } From 74330157cb601573ae4523a95bb9132822fc3918 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Thu, 12 Nov 2026 02:53:54 -0500 Subject: [PATCH 28/31] Use craft-psd for PSD/PSB format code (#13414) * Use craft-psd from craft-libs; remove crates/psd * Regenerate the scorecard (28 crate roots) * Take craft-psd at craft-libs 55d120f2 * Regenerate lockfile for current shared format crates * Pin shared format crates to current craft-libs main * Match brush import cap to shared decoder budget * xtask: update layer fixtures after PSD extraction --- ATTRIBUTION.md | 2 +- Cargo.lock | 37 +- Cargo.toml | 5 +- README.md | 2 +- apps/photocraft-cli/Cargo.toml | 2 +- apps/photocraft-cli/tests/it/cli.rs | 4 +- apps/photocraft-web/Cargo.toml | 2 +- apps/photocraft-web/src/preset_bridge.rs | 4 +- book/src/formats/overview.md | 2 +- book/src/formats/psd-psb.md | 2 +- book/src/formats/raster-formats.md | 2 +- book/src/security/parser-hardening.md | 2 +- crates/affinity/README.md | 2 +- crates/automation/Cargo.toml | 2 +- crates/automation/src/rpc/tests.rs | 4 +- crates/automation/src/workspace/tests.rs | 2 +- crates/automation/tests/it/mcp.rs | 4 +- crates/codecs/src/image.rs | 4 +- crates/engine/Cargo.toml | 2 +- crates/engine/src/cmd/atn_cmds.rs | 6 +- .../src/cmd/atn_cmds/layer_list_tests.rs | 4 +- crates/engine/src/cmd/atn_cmds/tests.rs | 4 +- crates/engine/src/cmd/brush_preset_cmds.rs | 2 +- crates/engine/src/cmd/contour_lib_cmds.rs | 2 +- crates/engine/src/cmd/file_cmds/tests.rs | 8 +- crates/engine/src/cmd/preset_import_cmds.rs | 30 +- crates/engine/src/cmd/style_asl_cmds.rs | 2 +- crates/engine/src/cmd/swatch_cmds.rs | 36 +- crates/engine/src/cmd/swatch_cmds/tests.rs | 24 +- crates/engine/src/layer_style.rs | 10 +- crates/engine/src/preset_store/tests.rs | 6 +- crates/engine/src/presets/shapes/csh.rs | 4 +- crates/engine/src/presets/swatches.rs | 2 +- crates/engine/tests/atn_mapped_identifiers.rs | 4 +- .../tests/common/csh_fixture.rs | 0 crates/engine/tests/content_aware_fill_atn.rs | 6 +- crates/engine/tests/convert_profile_atn.rs | 12 +- crates/engine/tests/csh_import.rs | 2 +- .../tests/fixtures/atn/v12-commands.atn | Bin .../tests/image_size_scale_styles_atn.rs | 8 +- crates/engine/tests/it/actions_panel_menu.rs | 16 +- .../tests/it/atn_adjustment_make_props.rs | 4 +- crates/engine/tests/it/atn_calculations.rs | 4 +- .../engine/tests/it/atn_classic_gradient.rs | 4 +- crates/engine/tests/it/atn_color_lookup.rs | 4 +- .../engine/tests/it/atn_content_aware_fill.rs | 4 +- .../tests/it/atn_crystallize_cell_size.rs | 2 +- crates/engine/tests/it/atn_dialog_filters.rs | 4 +- .../engine/tests/it/atn_direct_adjustments.rs | 4 +- crates/engine/tests/it/atn_layer_style.rs | 8 +- crates/engine/tests/it/atn_newer_events.rs | 6 +- .../engine/tests/it/atn_perspective_warp.rs | 8 +- crates/engine/tests/it/atn_reference_forms.rs | 4 +- .../engine/tests/it/atn_review_regressions.rs | 4 +- crates/engine/tests/it/atn_ripple_amount.rs | 2 +- crates/engine/tests/it/atn_shape_blur.rs | 10 +- crates/engine/tests/it/atn_shear.rs | 4 +- crates/engine/tests/it/atn_stack_modes.rs | 8 +- crates/engine/tests/it/atn_stroke_width.rs | 2 +- crates/engine/tests/it/atn_type_path.rs | 8 +- crates/engine/tests/it/atn_v12.rs | 4 +- crates/engine/tests/it/atn_video_frames.rs | 6 +- .../tests/it/crop_delete_off_background.rs | 2 +- crates/engine/tests/it/photoshop_oracles.rs | 2 +- .../engine/tests/it/rasterize_psd_reopen.rs | 2 +- crates/engine/tests/it/smart_duplicates.rs | 2 +- crates/engine/tests/it/smart_psd.rs | 32 +- crates/engine/tests/memory_gate.rs | 4 +- crates/engine/tests/paste_style_options.rs | 8 +- crates/engine/tests/vector_paste_options.rs | 8 +- crates/io/Cargo.toml | 4 +- crates/io/examples/bench_abr.rs | 4 +- crates/io/examples/big_psb_check.rs | 4 +- crates/io/examples/gen_abr_library.rs | 6 +- crates/io/examples/oracle_diff.rs | 34 +- crates/io/src/abr_map.rs | 8 +- crates/io/src/abr_map/tests.rs | 4 +- crates/io/src/adjust_map.rs | 2 +- crates/io/src/adjust_map/encode.rs | 2 +- crates/io/src/adjust_map/tests.rs | 12 +- crates/io/src/annotations_map.rs | 6 +- crates/io/src/asl_map.rs | 10 +- crates/io/src/blocks.rs | 4 +- crates/io/src/blocks/more_tests.rs | 4 +- crates/io/src/blocks/tests.rs | 10 +- crates/io/src/camera_raw_map.rs | 8 +- crates/io/src/camera_raw_map/tests.rs | 2 +- crates/io/src/comps_map.rs | 4 +- crates/io/src/count_resource_tests.rs | 4 +- crates/io/src/displace_map.rs | 6 +- crates/io/src/duotone_map.rs | 2 +- crates/io/src/effects_map.rs | 2 +- crates/io/src/flat.rs | 4 +- crates/io/src/hue_sat_filter_map.rs | 4 +- crates/io/src/lib.rs | 41 +- crates/io/src/linked.rs | 4 +- crates/io/src/memory.rs | 2 +- crates/io/src/multichannel_map.rs | 10 +- crates/io/src/palette_map.rs | 4 +- crates/io/src/pattern_map.rs | 10 +- crates/io/src/perspective_warp_map.rs | 2 +- crates/io/src/pixels.rs | 6 +- crates/io/src/psd_budget.rs | 14 +- crates/io/src/psd_budget/tests.rs | 16 +- crates/io/src/psd_export.rs | 14 +- crates/io/src/psd_export/banded.rs | 10 +- crates/io/src/psd_export/document.rs | 2 +- crates/io/src/psd_export/estimate.rs | 4 +- crates/io/src/psd_export/smart.rs | 12 +- crates/io/src/psd_export/stage.rs | 12 +- crates/io/src/psd_export/tests.rs | 6 +- crates/io/src/psd_import.rs | 82 +- crates/io/src/psd_import/tests.rs | 36 +- crates/io/src/psd_thumbnail.rs | 2 +- crates/io/src/puppet_map.rs | 4 +- crates/io/src/slices_map.rs | 6 +- crates/io/src/smart_map.rs | 8 +- crates/io/src/smart_map/tests.rs | 4 +- crates/io/src/tiff_layers.rs | 52 +- crates/io/src/vector_map.rs | 4 +- crates/io/src/vector_preview.rs | 4 +- crates/io/tests/it/adjust_blend_roundtrip.rs | 4 +- crates/io/tests/it/adjust_warnings.rs | 6 +- crates/io/tests/it/annotations.rs | 4 +- crates/io/tests/it/artboard_guide_order.rs | 2 +- crates/io/tests/it/blend_modes_corpus.rs | 2 +- crates/io/tests/it/cancel.rs | 2 +- crates/io/tests/it/channels.rs | 2 +- crates/io/tests/it/cmyk_composite_export.rs | 2 +- crates/io/tests/it/cmyk_export.rs | 2 +- crates/io/tests/it/color.rs | 8 +- crates/io/tests/it/common/mod.rs | 6 +- crates/io/tests/it/composite.rs | 14 +- crates/io/tests/it/comps_artboards.rs | 10 +- crates/io/tests/it/corpus.rs | 6 +- crates/io/tests/it/corpus_regressions.rs | 8 +- crates/io/tests/it/cylinder_warp.rs | 6 +- crates/io/tests/it/deflate_compatibility.rs | 10 +- crates/io/tests/it/flat.rs | 2 +- crates/io/tests/it/guides_v2.rs | 4 +- crates/io/tests/it/lab16.rs | 2 +- crates/io/tests/it/large_files.rs | 8 +- crates/io/tests/it/large_psb.rs | 4 +- crates/io/tests/it/layer_labels.rs | 2 +- crates/io/tests/it/layer_xmp.rs | 4 +- crates/io/tests/it/multichannel.rs | 6 +- crates/io/tests/it/native_vector_preview.rs | 2 +- crates/io/tests/it/orientation.rs | 2 +- .../io/tests/it/psd16_merged_transparency.rs | 4 +- crates/io/tests/it/psd32_export.rs | 8 +- crates/io/tests/it/psd_background.rs | 2 +- crates/io/tests/it/psd_block_alignment.rs | 2 +- crates/io/tests/it/psd_block_layout.rs | 10 +- crates/io/tests/it/psd_damaged_merged.rs | 12 +- crates/io/tests/it/psd_empty_channels.rs | 2 +- crates/io/tests/it/psd_flat_spot_channels.rs | 4 +- crates/io/tests/it/psd_frame_stroke.rs | 4 +- crates/io/tests/it/psd_import_file.rs | 20 +- crates/io/tests/it/psd_kerning.rs | 16 +- crates/io/tests/it/psd_layer_block_length.rs | 2 +- .../io/tests/it/psd_maximize_compatibility.rs | 2 +- .../io/tests/it/psd_noise_fill_roundtrip.rs | 6 +- crates/io/tests/it/psd_recovery_generated.rs | 4 +- crates/io/tests/it/psd_roundtrip_bytes.rs | 4 +- crates/io/tests/it/psd_structure.rs | 72 +- crates/io/tests/it/psd_style_sheets.rs | 2 +- crates/io/tests/it/psd_thumbnail.rs | 4 +- crates/io/tests/it/psd_type_bounds.rs | 10 +- .../io/tests/it/psd_type_photoshop_bytes.rs | 6 +- crates/io/tests/it/psd_unknown_adjustment.rs | 4 +- crates/io/tests/it/resolution_axes.rs | 8 +- crates/io/tests/it/roundtrip.rs | 10 +- crates/io/tests/it/smart_contents_identity.rs | 4 +- crates/io/tests/it/smart_corpus.rs | 2 +- crates/io/tests/it/tiff_layers.rs | 22 +- crates/io/tests/it/tiff_save_options.rs | 4 +- crates/io/tests/it/tiff_type_geometry.rs | 4 +- crates/io/tests/it/vector.rs | 8 +- crates/psd/Cargo.toml | 31 - crates/psd/README.md | 200 --- crates/psd/examples/animation_inspect.rs | 103 -- crates/psd/examples/atn_dump.rs | 91 -- crates/psd/examples/atn_roundtrip.rs | 54 - crates/psd/examples/bench_layer_block.rs | 39 - crates/psd/examples/big_psb.rs | 24 - crates/psd/fuzz/.gitignore | 5 - crates/psd/fuzz/Cargo.toml | 114 -- crates/psd/fuzz/fuzz_targets/abr.rs | 14 - crates/psd/fuzz/fuzz_targets/aco.rs | 21 - crates/psd/fuzz/fuzz_targets/ase.rs | 39 - crates/psd/fuzz/fuzz_targets/asl.rs | 15 - crates/psd/fuzz/fuzz_targets/atn.rs | 12 - crates/psd/fuzz/fuzz_targets/descriptor.rs | 13 - .../psd/fuzz/fuzz_targets/filter_effects.rs | 22 - crates/psd/fuzz/fuzz_targets/grd.rs | 52 - .../fuzz/fuzz_targets/image_source_data.rs | 18 - crates/psd/fuzz/fuzz_targets/metadata.rs | 17 - crates/psd/fuzz/fuzz_targets/parse.rs | 27 - crates/psd/fuzz/fuzz_targets/patterns.rs | 20 - crates/psd/fuzz/fuzz_targets/resources.rs | 24 - crates/psd/fuzz/fuzz_targets/tagged.rs | 28 - .../psd/proptest-regressions/descriptor.txt | 7 - crates/psd/src/abr.rs | 676 -------- crates/psd/src/abr/tests.rs | 298 ---- crates/psd/src/aco.rs | 341 ---- crates/psd/src/act.rs | 143 -- crates/psd/src/animation.rs | 228 --- crates/psd/src/ase.rs | 439 ------ crates/psd/src/asl.rs | 276 ---- crates/psd/src/atn.rs | 860 ---------- crates/psd/src/atn/legacy.rs | 111 -- crates/psd/src/blend.rs | 143 -- crates/psd/src/builder.rs | 471 ------ crates/psd/src/codepage.rs | 874 ---------- crates/psd/src/compression.rs | 679 -------- crates/psd/src/compression/tests.rs | 465 ------ crates/psd/src/csh.rs | 127 -- crates/psd/src/descriptor.rs | 867 ---------- crates/psd/src/error.rs | 76 - crates/psd/src/file.rs | 1174 -------------- crates/psd/src/filter_effects.rs | 344 ---- crates/psd/src/fixtures.rs | 566 ------- crates/psd/src/grd.rs | 299 ---- crates/psd/src/hdr.rs | 93 -- crates/psd/src/header.rs | 254 --- crates/psd/src/image_data.rs | 98 -- crates/psd/src/io.rs | 518 ------ crates/psd/src/layer.rs | 889 ----------- crates/psd/src/layer/tests.rs | 374 ----- crates/psd/src/lazy.rs | 448 ------ crates/psd/src/lib.rs | 101 -- crates/psd/src/metadata.rs | 266 ---- crates/psd/src/path.rs | 312 ---- crates/psd/src/patterns.rs | 631 -------- crates/psd/src/patterns/tests.rs | 352 ----- crates/psd/src/pixels.rs | 465 ------ crates/psd/src/resources.rs | 404 ----- crates/psd/src/rows.rs | 234 --- crates/psd/src/shc.rs | 251 --- crates/psd/src/slices.rs | 531 ------- crates/psd/src/source.rs | 270 ---- crates/psd/src/tagged.rs | 681 -------- crates/psd/src/testgen.rs | 384 ----- crates/psd/src/tiff.rs | 339 ---- crates/psd/src/tiff/tests.rs | 1043 ------------ crates/psd/src/tiff/transcode.rs | 1399 ----------------- crates/psd/src/tree.rs | 126 -- crates/psd/tests/csh.rs | 101 -- crates/psd/tests/fixtures/atn/README.md | 31 - crates/psd/tests/fixtures/atn/descriptors.atn | Bin 859 -> 0 bytes crates/psd/tests/fixtures/atn/empty.atn | Bin 13 -> 0 bytes crates/psd/tests/fixtures/atn/float-bits.atn | Bin 428 -> 0 bytes crates/psd/tests/fixtures/atn/metadata.atn | Bin 214 -> 0 bytes crates/psd/tests/fixtures/atn/v12-stop.atn | Bin 113 -> 0 bytes crates/psd/tests/fixtures/atn/v12-values.atn | Bin 562 -> 0 bytes crates/psd/tests/it/animation.rs | 262 --- crates/psd/tests/it/atn_contract.rs | 243 --- crates/psd/tests/it/atn_v12.rs | 145 -- crates/psd/tests/it/builder.rs | 309 ---- crates/psd/tests/it/common/atn_roundtrip.rs | 71 - crates/psd/tests/it/corpus.rs | 129 -- crates/psd/tests/it/corpus_atn.rs | 60 - crates/psd/tests/it/layer_block_order.rs | 26 - crates/psd/tests/it/main.rs | 15 - crates/psd/tests/it/malformed.rs | 283 ---- crates/psd/tests/it/merged_recovery.rs | 43 - crates/psd/tests/it/proptests.rs | 549 ------- crates/psd/tests/it/roundtrip.rs | 333 ---- crates/psd/tests/it/swatch_malformed.rs | 943 ----------- crates/psd/tests/it/tree.rs | 249 --- crates/text/Cargo.toml | 2 +- crates/text/src/engine_data.rs | 456 ------ crates/text/src/lib.rs | 3 +- crates/text/src/psd.rs | 2 +- crates/text/src/psd_styles.rs | 2 +- crates/text/src/tests.rs | 12 +- crates/ui-egui/Cargo.toml | 4 +- crates/ui-egui/examples/large_file_bench.rs | 4 +- crates/ui-egui/examples/perf_scenarios.rs | 2 +- .../src/dialogs/adjust_editors/ui_tests.rs | 9 +- .../src/dialogs/preset_group_prompt/tests.rs | 4 +- crates/ui-egui/src/home_recent_ui.rs | 38 +- crates/ui-egui/src/panels/actions/tests.rs | 34 +- crates/ui-egui/src/panels/swatches_ui.rs | 6 +- crates/ui-egui/src/preset_files_ui.rs | 4 +- crates/ui-egui/src/psd_open_tests.rs | 6 +- docs/architecture.md | 8 +- docs/atn.md | 2 +- docs/development.md | 14 +- docs/scorecard.md | 4 +- perf/budgets.toml | 2 +- xtask/src/corpus.rs | 3 +- xtask/src/corpus_pins.rs | 2 +- xtask/src/layers.rs | 9 +- 294 files changed, 755 insertions(+), 25930 deletions(-) rename crates/{psd => engine}/tests/common/csh_fixture.rs (100%) rename crates/{psd => engine}/tests/fixtures/atn/v12-commands.atn (100%) delete mode 100644 crates/psd/Cargo.toml delete mode 100644 crates/psd/README.md delete mode 100644 crates/psd/examples/animation_inspect.rs delete mode 100644 crates/psd/examples/atn_dump.rs delete mode 100644 crates/psd/examples/atn_roundtrip.rs delete mode 100644 crates/psd/examples/bench_layer_block.rs delete mode 100644 crates/psd/examples/big_psb.rs delete mode 100644 crates/psd/fuzz/.gitignore delete mode 100644 crates/psd/fuzz/Cargo.toml delete mode 100644 crates/psd/fuzz/fuzz_targets/abr.rs delete mode 100644 crates/psd/fuzz/fuzz_targets/aco.rs delete mode 100644 crates/psd/fuzz/fuzz_targets/ase.rs delete mode 100644 crates/psd/fuzz/fuzz_targets/asl.rs delete mode 100644 crates/psd/fuzz/fuzz_targets/atn.rs delete mode 100644 crates/psd/fuzz/fuzz_targets/descriptor.rs delete mode 100644 crates/psd/fuzz/fuzz_targets/filter_effects.rs delete mode 100644 crates/psd/fuzz/fuzz_targets/grd.rs delete mode 100644 crates/psd/fuzz/fuzz_targets/image_source_data.rs delete mode 100644 crates/psd/fuzz/fuzz_targets/metadata.rs delete mode 100644 crates/psd/fuzz/fuzz_targets/parse.rs delete mode 100644 crates/psd/fuzz/fuzz_targets/patterns.rs delete mode 100644 crates/psd/fuzz/fuzz_targets/resources.rs delete mode 100644 crates/psd/fuzz/fuzz_targets/tagged.rs delete mode 100644 crates/psd/proptest-regressions/descriptor.txt delete mode 100644 crates/psd/src/abr.rs delete mode 100644 crates/psd/src/abr/tests.rs delete mode 100644 crates/psd/src/aco.rs delete mode 100644 crates/psd/src/act.rs delete mode 100644 crates/psd/src/animation.rs delete mode 100644 crates/psd/src/ase.rs delete mode 100644 crates/psd/src/asl.rs delete mode 100644 crates/psd/src/atn.rs delete mode 100644 crates/psd/src/atn/legacy.rs delete mode 100644 crates/psd/src/blend.rs delete mode 100644 crates/psd/src/builder.rs delete mode 100644 crates/psd/src/codepage.rs delete mode 100644 crates/psd/src/compression.rs delete mode 100644 crates/psd/src/compression/tests.rs delete mode 100644 crates/psd/src/csh.rs delete mode 100644 crates/psd/src/descriptor.rs delete mode 100644 crates/psd/src/error.rs delete mode 100644 crates/psd/src/file.rs delete mode 100644 crates/psd/src/filter_effects.rs delete mode 100644 crates/psd/src/fixtures.rs delete mode 100644 crates/psd/src/grd.rs delete mode 100644 crates/psd/src/hdr.rs delete mode 100644 crates/psd/src/header.rs delete mode 100644 crates/psd/src/image_data.rs delete mode 100644 crates/psd/src/io.rs delete mode 100644 crates/psd/src/layer.rs delete mode 100644 crates/psd/src/layer/tests.rs delete mode 100644 crates/psd/src/lazy.rs delete mode 100644 crates/psd/src/lib.rs delete mode 100644 crates/psd/src/metadata.rs delete mode 100644 crates/psd/src/path.rs delete mode 100644 crates/psd/src/patterns.rs delete mode 100644 crates/psd/src/patterns/tests.rs delete mode 100644 crates/psd/src/pixels.rs delete mode 100644 crates/psd/src/resources.rs delete mode 100644 crates/psd/src/rows.rs delete mode 100644 crates/psd/src/shc.rs delete mode 100644 crates/psd/src/slices.rs delete mode 100644 crates/psd/src/source.rs delete mode 100644 crates/psd/src/tagged.rs delete mode 100644 crates/psd/src/testgen.rs delete mode 100644 crates/psd/src/tiff.rs delete mode 100644 crates/psd/src/tiff/tests.rs delete mode 100644 crates/psd/src/tiff/transcode.rs delete mode 100644 crates/psd/src/tree.rs delete mode 100644 crates/psd/tests/csh.rs delete mode 100644 crates/psd/tests/fixtures/atn/README.md delete mode 100644 crates/psd/tests/fixtures/atn/descriptors.atn delete mode 100644 crates/psd/tests/fixtures/atn/empty.atn delete mode 100644 crates/psd/tests/fixtures/atn/float-bits.atn delete mode 100644 crates/psd/tests/fixtures/atn/metadata.atn delete mode 100644 crates/psd/tests/fixtures/atn/v12-stop.atn delete mode 100644 crates/psd/tests/fixtures/atn/v12-values.atn delete mode 100644 crates/psd/tests/it/animation.rs delete mode 100644 crates/psd/tests/it/atn_contract.rs delete mode 100644 crates/psd/tests/it/atn_v12.rs delete mode 100644 crates/psd/tests/it/builder.rs delete mode 100644 crates/psd/tests/it/common/atn_roundtrip.rs delete mode 100644 crates/psd/tests/it/corpus.rs delete mode 100644 crates/psd/tests/it/corpus_atn.rs delete mode 100644 crates/psd/tests/it/layer_block_order.rs delete mode 100644 crates/psd/tests/it/main.rs delete mode 100644 crates/psd/tests/it/malformed.rs delete mode 100644 crates/psd/tests/it/merged_recovery.rs delete mode 100644 crates/psd/tests/it/proptests.rs delete mode 100644 crates/psd/tests/it/roundtrip.rs delete mode 100644 crates/psd/tests/it/swatch_malformed.rs delete mode 100644 crates/psd/tests/it/tree.rs delete mode 100644 crates/text/src/engine_data.rs diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 1a12a265d5..953c3ccef8 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -130,7 +130,7 @@ Files copied into `corpus/` by hand (tiff, exr, raw) must be MIT, BSD or CC0. | Path | Title | Author | Source | License | |---|---|---|---|---| -| `crates/psd/tests/fixtures/atn/*.atn` | Independently encoded ATN version-16 and version-12 parser fixtures | PhotoCraft contributors (version-12 files: @yoriswind) | Original synthetic test data; wire layout documented in `crates/psd/src/atn.rs`; provenance in the adjacent README | MIT OR Apache-2.0 | +| `crates/engine/tests/fixtures/atn/v12-commands.atn` | Independently encoded ATN version-12 parser fixture (Gaussian Blur, Levels, Select All, Make Layer; 404 bytes, sha256 `451958b686da9aab45e65ae902c86333b7149d72021c21904e3aae8e39eb77cb`), copied from the PSD crate's fixtures, which now live in craft-libs' `craft-psd` | PhotoCraft contributors (@yoriswind) | Original synthetic test data | MIT OR Apache-2.0 | ## Embedded-vector editor source diff --git a/Cargo.lock b/Cargo.lock index 7549011f7e..d83eea4686 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1533,6 +1533,17 @@ dependencies = [ "winsafe", ] +[[package]] +name = "craft-psd" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=09dcbc225b95232998498a40ca56e24716571d53#09dcbc225b95232998498a40ca56e24716571d53" +dependencies = [ + "encoding_rs", + "flate2", + "rayon", + "thiserror 2.0.21", +] + [[package]] name = "craft-raster-codecs" version = "0.1.0" @@ -5505,6 +5516,7 @@ dependencies = [ "base64 0.23.1", "cap-std", "clap", + "craft-psd", "getrandom 0.3.4", "hmac", "photocraft-codecs", @@ -5516,7 +5528,6 @@ dependencies = [ "photocraft-geom", "photocraft-gpu", "photocraft-io", - "photocraft-psd", "photocraft-raster", "photocraft-raw", "pollster 1.0.1", @@ -5538,6 +5549,7 @@ dependencies = [ "clap", "clap_complete", "clap_mangen", + "craft-psd", "photocraft-automation", "photocraft-cms", "photocraft-codecs", @@ -5545,7 +5557,6 @@ dependencies = [ "photocraft-engine", "photocraft-format", "photocraft-io", - "photocraft-psd", "photocraft-raster", "photocraft-raw", "serde", @@ -5637,6 +5648,7 @@ dependencies = [ "blake3", "chrono", "craft-print", + "craft-psd", "exr", "flate2", "photocraft-affinity", @@ -5653,7 +5665,6 @@ dependencies = [ "photocraft-ops", "photocraft-paint", "photocraft-plugins", - "photocraft-psd", "photocraft-raster", "photocraft-raw", "photocraft-testkit", @@ -5731,6 +5742,7 @@ dependencies = [ "blake3", "craft-docpath", "craft-pdf", + "craft-psd", "crc32fast", "flate2", "imagesize", @@ -5747,7 +5759,6 @@ dependencies = [ "photocraft-format", "photocraft-geom", "photocraft-paint", - "photocraft-psd", "photocraft-raster", "photocraft-raw", "photocraft-testkit", @@ -5805,18 +5816,6 @@ dependencies = [ "wat", ] -[[package]] -name = "photocraft-psd" -version = "0.23.1" -dependencies = [ - "encoding_rs", - "flate2", - "photocraft-psd", - "proptest", - "serde", - "thiserror 2.0.21", -] - [[package]] name = "photocraft-raster" version = "0.23.1" @@ -5875,12 +5874,12 @@ dependencies = [ name = "photocraft-text" version = "0.23.1" dependencies = [ + "craft-psd", "flate2", "parley", "photocraft-color", "photocraft-doc", "photocraft-geom", - "photocraft-psd", "photocraft-raster", "rayon", "skrifa 0.47.0", @@ -5894,6 +5893,7 @@ dependencies = [ "chrono", "craft-i18n", "craft-print", + "craft-psd", "craft-ui", "craft-update", "eframe", @@ -5918,7 +5918,6 @@ dependencies = [ "photocraft-io", "photocraft-paint", "photocraft-plugins", - "photocraft-psd", "photocraft-raster", "photocraft-raw", "photocraft-testkit", @@ -5950,6 +5949,7 @@ dependencies = [ name = "photocraft-web" version = "0.23.1" dependencies = [ + "craft-psd", "eframe", "egui", "js-sys", @@ -5959,7 +5959,6 @@ dependencies = [ "photocraft-engine", "photocraft-io", "photocraft-paint", - "photocraft-psd", "photocraft-raster", "photocraft-ui-egui", "rfd", diff --git a/Cargo.toml b/Cargo.toml index d2c09b3557..5068387eec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,8 @@ photocraft-cms = { path = "crates/cms" } photocraft-raster = { path = "crates/raster" } photocraft-doc = { path = "crates/doc" } photocraft-ops = { path = "crates/ops" } -photocraft-psd = { path = "crates/psd" } +# PSD/PSB and the Photoshop resource files (ABR, ASL, ACO, ATN, ...): the shared reader and writer (craft-libs#875). +craft-psd = { git = "https://github.com/storytold/craft-libs", rev = "09dcbc225b95232998498a40ca56e24716571d53" } photocraft-codecs = { path = "crates/codecs" } photocraft-raw = { path = "crates/raw" } photocraft-affinity = { path = "crates/affinity" } @@ -161,7 +162,7 @@ opt-level = "s" # (runs when a pipeline is created). "z" keeps the wasm well under the size gate. [profile.wasm-release.package.photocraft-doc] opt-level = "z" -[profile.wasm-release.package.photocraft-psd] +[profile.wasm-release.package.craft-psd] opt-level = "z" [profile.wasm-release.package.photocraft-format] opt-level = "z" diff --git a/README.md b/README.md index c1789ab7d2..3c92dd9c5d 100644 --- a/README.md +++ b/README.md @@ -221,7 +221,7 @@ Every screenshot here is the real app at work on public-domain art, rendered off PhotoCraft's PSD support is a standalone crate written from Adobe's public specification and tested against a corpus of real-world files. -- **Faithful round trips:** opening and re-saving a document renders the same for 307 of the 309 files in the psd-tools test set and 169 of 170 in our mixed ag-psd/psd-tools set (`crates/io/tests/it/corpus.rs`; fetch the psd-tools set with `cargo xtask corpus --psd-tools`), and anything we don't model yet (raw blocks, descriptors, extras) is carried over instead of being dropped. A re-saved file is not byte-identical to its source: PhotoCraft rewrites image resources, layer records and the composite. Only the standalone `photocraft-psd` crate, parsing and writing a file without the document model, reproduces every parseable corpus file byte for byte (`crates/psd/tests/it/corpus.rs`). +- **Faithful round trips:** opening and re-saving a document renders the same for 307 of the 309 files in the psd-tools test set and 169 of 170 in our mixed ag-psd/psd-tools set (`crates/io/tests/it/corpus.rs`; fetch the psd-tools set with `cargo xtask corpus --psd-tools`), and anything we don't model yet (raw blocks, descriptors, extras) is carried over instead of being dropped. A re-saved file is not byte-identical to its source: PhotoCraft rewrites image resources, layer records and the composite. Only the standalone `craft-psd` crate, parsing and writing a file without the document model, reproduces every parseable corpus file byte for byte (`crates/psd/tests/it/corpus.rs`). - **Pixels that match:** a composite oracle compares our render with Photoshop's own merged image, covering gradient interpolation (Classic, Perceptual and Linear), layer effects, shape strokes, clipping and fill opacity. - **Large documents:** PSB, 16 and 32-bit files, and CMYK and Lab documents open natively. diff --git a/apps/photocraft-cli/Cargo.toml b/apps/photocraft-cli/Cargo.toml index 059e53eb39..35af450c4f 100644 --- a/apps/photocraft-cli/Cargo.toml +++ b/apps/photocraft-cli/Cargo.toml @@ -46,7 +46,7 @@ tokio = { version = "1", features = ["rt-multi-thread", "macros"] } serde = { workspace = true } tempfile = "3" # Reading back a saved PSD's merged-image flag (#8064). -photocraft-psd = { workspace = true } +craft-psd = { workspace = true } # A real ICC profile for the --metadata test (#9466). photocraft-cms = { workspace = true } # Synthetic camera raws (#7789). diff --git a/apps/photocraft-cli/tests/it/cli.rs b/apps/photocraft-cli/tests/it/cli.rs index d99c405eda..4713ae2a20 100644 --- a/apps/photocraft-cli/tests/it/cli.rs +++ b/apps/photocraft-cli/tests/it/cli.rs @@ -879,7 +879,7 @@ fn no_maximize_compatibility_writes_the_psd_placeholder() { { let out = d.join(format!("a.{ext}")); ok(bin().arg("run").arg(&a).args(["--cmd", "layer.new.layer"]).arg("--out").arg(&out).args(args)); - let psd = photocraft_psd::PsdFile::from_bytes(&std::fs::read(&out).unwrap()).unwrap(); + let psd = craft_psd::PsdFile::from_bytes(&std::fs::read(&out).unwrap()).unwrap(); assert_eq!(psd.has_real_merged_data(), Some(real), "{args:?} {ext}"); } std::fs::remove_dir_all(d).unwrap(); @@ -1442,7 +1442,7 @@ fn batch_plays_a_photoshop_actions_file() { fn extensionless_version_12_actions_file_is_read_as_atn() { let d = tmp("atn-v12"); let path = d.join("Legacy Actions"); - std::fs::write(&path, include_bytes!("../../../../crates/psd/tests/fixtures/atn/v12-commands.atn")).unwrap(); + std::fs::write(&path, include_bytes!("../../../../crates/engine/tests/fixtures/atn/v12-commands.atn")).unwrap(); let (steps, set, warnings) = photocraft_cli::load_actions_file(&path, None).unwrap(); assert_eq!(set.len(), 1); assert_eq!(steps, vec![("actions.play".to_string(), json!({"action": set[0].name}))]); diff --git a/apps/photocraft-web/Cargo.toml b/apps/photocraft-web/Cargo.toml index 7054d67bf9..075b734d3e 100644 --- a/apps/photocraft-web/Cargo.toml +++ b/apps/photocraft-web/Cargo.toml @@ -40,4 +40,4 @@ web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlE [dev-dependencies] serde_json = { workspace = true } photocraft-paint = { workspace = true } -photocraft-psd = { workspace = true } +craft-psd = { workspace = true } diff --git a/apps/photocraft-web/src/preset_bridge.rs b/apps/photocraft-web/src/preset_bridge.rs index a39fdfb4b4..4b79130493 100644 --- a/apps/photocraft-web/src/preset_bridge.rs +++ b/apps/photocraft-web/src/preset_bridge.rs @@ -181,7 +181,7 @@ impl PresetBackend for Bridge { #[cfg(test)] mod tests { use super::*; - use photocraft_psd::abr::{AbrSample, LegacyBrush, LegacyTip, write_v12}; + use craft_psd::abr::{AbrSample, LegacyBrush, LegacyTip, write_v12}; use serde_json::json; fn import(s: &mut Session) { @@ -367,7 +367,7 @@ mod tests { } #[test] fn indexed_brush_store_coexists_with_imported_gradient_preferences() { - use photocraft_psd::grd::{GrdColor, GrdGradient, GrdStop, write}; + use craft_psd::grd::{GrdColor, GrdGradient, GrdStop, write}; let b = Bridge::default(); let (mut s, _) = session(b.clone()); import(&mut s); diff --git a/book/src/formats/overview.md b/book/src/formats/overview.md index 0f066983cb..18c94c2f67 100644 --- a/book/src/formats/overview.md +++ b/book/src/formats/overview.md @@ -2,7 +2,7 @@ External bytes enter PhotoCraft through four main paths: -- `photocraft-psd` parses and writes PSD and PSB structures. +- `craft-psd` parses and writes PSD and PSB structures. - `photocraft-codecs` decodes and encodes flat raster formats. - `photocraft-format` loads and saves the native `.pcraft` bundle. - `photocraft-affinity` reads native Affinity documents (`.af`, `.afdesign`, `.afphoto`, `.afpub`): layers, artboards, vectors, text, images and masks; it does not write them. diff --git a/book/src/formats/psd-psb.md b/book/src/formats/psd-psb.md index d0f4e6b734..c82fc8a135 100644 --- a/book/src/formats/psd-psb.md +++ b/book/src/formats/psd-psb.md @@ -1,6 +1,6 @@ # PSD and PSB -`photocraft-psd` is a standalone clean-room reader and writer based on Adobe's public PSD specification. It supports PSD version 1 and PSB version 2, preserves unknown blocks for round-trip fidelity, and decodes channel data lazily. +`craft-psd` is a standalone clean-room reader and writer based on Adobe's public PSD specification. It supports PSD version 1 and PSB version 2, preserves unknown blocks for round-trip fidelity, and decodes channel data lazily. The parser accepts all four PSD compression methods handled by the crate and represents format-level structures before `photocraft-io` maps them to `photocraft-doc`. The current public API and fidelity behavior are documented in [`crates/psd/README.md`](https://github.com/storytold/photocraft/blob/main/crates/psd/README.md). diff --git a/book/src/formats/raster-formats.md b/book/src/formats/raster-formats.md index 8182c97cab..f47703e0dc 100644 --- a/book/src/formats/raster-formats.md +++ b/book/src/formats/raster-formats.md @@ -4,7 +4,7 @@ ## Layered TIFF -A TIFF saved by Photoshop with its layers carries them in two private tags: 37724 (`ImageSourceData`, the same layer section a PSD has, preceded by the string `Adobe Photoshop Document Data Block`) and 34377 (the PSD image resources). `photocraft-codecs` passes both through as opaque bytes on `Metadata`; `photocraft-io` rebuilds a PSD model from them plus the TIFF's pixels and hands it to the PSD importer, so groups, masks, layer styles, type, shapes, smart objects and adjustment layers open from a TIFF exactly as from a PSD, and are written back the same way. In Intel-order (`II`) files Photoshop stores the whole block structure byte-swapped (reversed keys such as `MIB8` and `ryaL`, little-endian integers and doubles) while pixel samples stay big-endian; `photocraft-psd`'s `tiff` module converts between the two orders field by field, and drops, with a warning, any block whose layout it does not know rather than pass byte-swapped data to other readers. Limits: up to sixteen extra samples (spot colours and alpha channels, plus a Quick Mask) are written in the IFD and round-trip, with Photoshop image resources for the channel name, ink colour and solidity; Lab documents are saved flat, with their extra samples. Save As asks "Including layers will increase file size" (with "Discard Layers and Save a Copy") while *Ask Before Saving Layered TIFF Files* is on in Preferences › File Handling. Scripted and agent saves write a flat TIFF unless they ask for layers: `--tiff-layers` for `photocraft-cli convert`, `run` and `batch`, and `"tiffLayers": true` in the save, batch and export commands and MCP `doc_save`. +A TIFF saved by Photoshop with its layers carries them in two private tags: 37724 (`ImageSourceData`, the same layer section a PSD has, preceded by the string `Adobe Photoshop Document Data Block`) and 34377 (the PSD image resources). `photocraft-codecs` passes both through as opaque bytes on `Metadata`; `photocraft-io` rebuilds a PSD model from them plus the TIFF's pixels and hands it to the PSD importer, so groups, masks, layer styles, type, shapes, smart objects and adjustment layers open from a TIFF exactly as from a PSD, and are written back the same way. In Intel-order (`II`) files Photoshop stores the whole block structure byte-swapped (reversed keys such as `MIB8` and `ryaL`, little-endian integers and doubles) while pixel samples stay big-endian; `craft-psd`'s `tiff` module converts between the two orders field by field, and drops, with a warning, any block whose layout it does not know rather than pass byte-swapped data to other readers. Limits: up to sixteen extra samples (spot colours and alpha channels, plus a Quick Mask) are written in the IFD and round-trip, with Photoshop image resources for the channel name, ink colour and solidity; Lab documents are saved flat, with their extra samples. Save As asks "Including layers will increase file size" (with "Discard Layers and Save a Copy") while *Ask Before Saving Layered TIFF Files* is on in Preferences › File Handling. Scripted and agent saves write a flat TIFF unless they ask for layers: `--tiff-layers` for `photocraft-cli convert`, `run` and `batch`, and `"tiffLayers": true` in the save, batch and export commands and MCP `doc_save`. ## JPEG XL diff --git a/book/src/security/parser-hardening.md b/book/src/security/parser-hardening.md index 7eedf0e039..3f7ea7153b 100644 --- a/book/src/security/parser-hardening.md +++ b/book/src/security/parser-hardening.md @@ -6,7 +6,7 @@ Parser safety is broader than “does not crash.” A parser must also bound mem ### PSD/PSB -`photocraft-psd` enforces maximum document dimensions and channel counts, bounds decoded channel data, limits ActionDescriptor nesting and pattern edges, and uses checked arithmetic for key decoded-size calculations. Tests cover malformed headers, truncation, property-generated inputs, and dedicated fuzz targets. +`craft-psd` enforces maximum document dimensions and channel counts, bounds decoded channel data, limits ActionDescriptor nesting and pattern edges, and uses checked arithmetic for key decoded-size calculations. Tests cover malformed headers, truncation, property-generated inputs, and dedicated fuzz targets. ### Raster codecs diff --git a/crates/affinity/README.md b/crates/affinity/README.md index 1be7fd954e..c82911d939 100644 --- a/crates/affinity/README.md +++ b/crates/affinity/README.md @@ -71,7 +71,7 @@ are rejected. The I/O adapter decodes it with a 128 MiB allocation limit. Affinity has no published file-format specification, so this reader is built only from a public description of the format and from public files, never from Affinity itself. This is how -PhotoCraft already treats formats that are only partly documented: `photocraft-psd` fills the gaps +PhotoCraft already treats formats that are only partly documented: `craft-psd` fills the gaps in Adobe's specification from the MIT-licensed psd-tools and ag-psd, and `photocraft-raw` recovered Nikon's compression tables by black-box analysis of CC0 sample files (see their READMEs). diff --git a/crates/automation/Cargo.toml b/crates/automation/Cargo.toml index bbc0aa6bb6..c03900b66e 100644 --- a/crates/automation/Cargo.toml +++ b/crates/automation/Cargo.toml @@ -53,7 +53,7 @@ photocraft-raw = { workspace = true, features = ["testgen"] } # Tiny generated PSDs and the scoped estimate charge for the high-memory approval tests # (no multi-gigabyte fixture or global override). photocraft-io = { workspace = true, features = ["testgen"] } -photocraft-psd = { workspace = true, features = ["testgen"] } +craft-psd = { workspace = true, features = ["testgen"] } rmcp = { version = "3.5", default-features = false, features = ["client", "server", "macros", "transport-io"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-std", "sync", "net", "io-util", "time"] } diff --git a/crates/automation/src/rpc/tests.rs b/crates/automation/src/rpc/tests.rs index 3db0960e15..6598d5d9c0 100644 --- a/crates/automation/src/rpc/tests.rs +++ b/crates/automation/src/rpc/tests.rs @@ -312,7 +312,7 @@ fn doc_save_takes_maximize_compatibility() { let mut h = Headless::trusted_local(); h.handle("doc.new", json!({"width": 64, "height": 32})).unwrap(); h.handle("engine.execute", json!({"command": "layer.new.layer"})).unwrap(); - let real_merged = |p: &std::path::Path| photocraft_psd::PsdFile::from_bytes(&std::fs::read(p).unwrap()).unwrap().has_real_merged_data(); + let real_merged = |p: &std::path::Path| craft_psd::PsdFile::from_bytes(&std::fs::read(p).unwrap()).unwrap().has_real_merged_data(); for (name, maximize, real) in [("default.psd", json!(null), true), ("off.psd", json!(false), false), ("on.psd", json!(true), true)] { let path = dir.join(name); h.handle("doc.save", json!({"path": path.to_string_lossy(), "maximizeCompatibility": maximize})).unwrap(); @@ -1155,7 +1155,7 @@ fn doc_place_forwards_high_memory_approval() { let gb = 1u64 << 30; let root = std::env::temp_dir().join(format!("pc-doc-place-approval-{}", std::process::id())); std::fs::create_dir_all(&root).unwrap(); - let psd = photocraft_psd::testgen::small(photocraft_psd::Version::Psd, photocraft_psd::Compression::Zip).to_bytes().unwrap(); + let psd = craft_psd::testgen::small(craft_psd::Version::Psd, craft_psd::Compression::Zip).to_bytes().unwrap(); std::fs::write(root.join("high.psd"), &psd).unwrap(); // Known 5 GiB RAM: S = 2.5 GiB, H = 3.75 GiB. A 3 GiB estimate charge lands in (S, H]. let snap = photocraft_io::memory::MemorySnapshot { available_ram: Some(5 * gb), swap_free: None, commit_headroom: None }; diff --git a/crates/automation/src/workspace/tests.rs b/crates/automation/src/workspace/tests.rs index 59bd768bc5..819adb9d55 100644 --- a/crates/automation/src/workspace/tests.rs +++ b/crates/automation/src/workspace/tests.rs @@ -1508,7 +1508,7 @@ fn csh_import_requires_inline_data_in_automation() { /// A one-shape CSH set (version 2) named `name`, with a single open one-knot subpath. fn one_shape_csh(name: &str) -> Vec { - use photocraft_psd::path::{PathData, PsdKnot, PsdSubpath}; + use craft_psd::path::{PathData, PsdKnot, PsdSubpath}; let mut bytes = b"cush".to_vec(); bytes.extend_from_slice(&2u32.to_be_bytes()); bytes.extend_from_slice(&1u32.to_be_bytes()); diff --git a/crates/automation/tests/it/mcp.rs b/crates/automation/tests/it/mcp.rs index 12cc75f19f..e901ee6a37 100644 --- a/crates/automation/tests/it/mcp.rs +++ b/crates/automation/tests/it/mcp.rs @@ -532,7 +532,7 @@ async fn headless_doc_save_takes_maximize_compatibility() { let r = call(&client, tool, params.clone()).await; assert_ne!(r.is_error, Some(true), "{params}: {}", text(&r)); let bytes = std::fs::read(dir.join(params["path"].as_str().unwrap())).unwrap(); - assert_eq!(photocraft_psd::PsdFile::from_bytes(&bytes).unwrap().has_real_merged_data(), Some(real), "{params}"); + assert_eq!(craft_psd::PsdFile::from_bytes(&bytes).unwrap().has_real_merged_data(), Some(real), "{params}"); } let r = call(&client, "doc_export", json!({"path": "bad.psd", "maximizeCompatibility": "no"})).await; assert_eq!(r.is_error, Some(true), "{}", text(&r)); @@ -555,7 +555,7 @@ async fn headless_doc_export_layers_takes_maximize_compatibility() { let file = result["files"][0].as_str().unwrap(); assert!(file.starts_with(sub), "{file}"); let bytes = std::fs::read(dir.join(file)).unwrap(); - assert_eq!(photocraft_psd::PsdFile::from_bytes(&bytes).unwrap().has_real_merged_data(), Some(real), "{params}"); + assert_eq!(craft_psd::PsdFile::from_bytes(&bytes).unwrap().has_real_merged_data(), Some(real), "{params}"); } let r = call(&client, "doc_export_layers", json!({"dir": "bad", "format": "psd", "maximizeCompatibility": "no"})).await; assert_eq!(r.is_error, Some(true), "{}", text(&r)); diff --git a/crates/codecs/src/image.rs b/crates/codecs/src/image.rs index a5588de640..f9d238ba82 100644 --- a/crates/codecs/src/image.rs +++ b/crates/codecs/src/image.rs @@ -140,10 +140,10 @@ pub struct Metadata { /// Free-form key/value text (PNG tEXt/iTXt, TIFF ASCII tags). pub text: Vec<(String, String)>, /// Photoshop image resources (`8BIM` resource blocks): TIFF tag 34377. Kept opaque here; - /// `photocraft-io` reads and writes them through `photocraft-psd`. + /// `photocraft-io` reads and writes them through `craft-psd`. pub photoshop_resources: Option>, /// Photoshop layer data (TIFF tag 37724 `ImageSourceData`): a layered TIFF's layers. Kept - /// opaque here; `photocraft-io` reads and writes it through `photocraft-psd`. + /// opaque here; `photocraft-io` reads and writes it through `craft-psd`. pub photoshop_layers: Option>, /// Samples per pixel beyond the colour channels and the one alpha sample. TIFF extra /// samples (spot colours, extra alpha channels) the pixel layout does not hold. Each entry diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index b5b814b1a2..51f652c77c 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -30,7 +30,7 @@ photocraft-algo = { workspace = true } photocraft-text = { workspace = true } photocraft-vector = { workspace = true } photocraft-io = { workspace = true } -photocraft-psd = { workspace = true } +craft-psd = { workspace = true } photocraft-format = { workspace = true } photocraft-plugins = { workspace = true } base64 = "0.22" diff --git a/crates/engine/src/cmd/atn_cmds.rs b/crates/engine/src/cmd/atn_cmds.rs index a2e0046155..3cd4be5f8a 100644 --- a/crates/engine/src/cmd/atn_cmds.rs +++ b/crates/engine/src/cmd/atn_cmds.rs @@ -3,7 +3,7 @@ //! (Photoshop's Insert Stop) and `actions.photoshopStep` (a step PhotoCraft has no command for //! yet, kept verbatim so Save Actions writes it back unchanged). //! -//! Parsing and writing live in `photocraft-psd` (`atn`); this module maps Photoshop *events* +//! Parsing and writing live in `craft-psd` (`atn`); this module maps Photoshop *events* //! (`gaussianBlur` with `Rds`, `make` of an adjustment layer, `set` of the selection…) onto //! PhotoCraft commands and back. An event is keyed by both its four-character code (`GsnB`, //! older files) and its string name (`gaussianBlur`, Photoshop 2024 and later), as the two are @@ -18,8 +18,8 @@ //! {"offset"}`, the document that many places from the active one in opening order, not dragged //! tab order (#7103). -use photocraft_psd::atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}; -use photocraft_psd::descriptor::{Class, Descriptor, Id, ReferenceItem, UnicodeString, Value as D}; +use craft_psd::atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}; +use craft_psd::descriptor::{Class, Descriptor, Id, ReferenceItem, UnicodeString, Value as D}; use serde_json::{Map, Value, json}; use crate::actions_cmds::Action; diff --git a/crates/engine/src/cmd/atn_cmds/layer_list_tests.rs b/crates/engine/src/cmd/atn_cmds/layer_list_tests.rs index 4f6f1c6a47..089d96b2bb 100644 --- a/crates/engine/src/cmd/atn_cmds/layer_list_tests.rs +++ b/crates/engine/src/cmd/atn_cmds/layer_list_tests.rs @@ -1,6 +1,6 @@ use super::*; -use photocraft_psd::atn::{AtnAction, AtnEvent, AtnFile, AtnItem}; -use photocraft_psd::descriptor::{Descriptor, ReferenceItem, UnicodeString, Value as D}; +use craft_psd::atn::{AtnAction, AtnEvent, AtnFile, AtnItem}; +use craft_psd::descriptor::{Descriptor, ReferenceItem, UnicodeString, Value as D}; use serde_json::json; fn item(event: &str, label: &str, d: Option) -> AtnItem { diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index 09c635208e..40dcf23ffe 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -1,6 +1,6 @@ use super::*; -use photocraft_psd::atn::{AtnAction, AtnEvent, AtnFile, AtnItem}; -use photocraft_psd::descriptor::{Descriptor, Id, ReferenceItem, UnicodeString, Value as D}; +use craft_psd::atn::{AtnAction, AtnEvent, AtnFile, AtnItem}; +use craft_psd::descriptor::{Descriptor, Id, ReferenceItem, UnicodeString, Value as D}; use serde_json::json; /// A wire key has one mapper; distinct wire aliases may still name the same command. diff --git a/crates/engine/src/cmd/brush_preset_cmds.rs b/crates/engine/src/cmd/brush_preset_cmds.rs index cd54b1ff61..6cfe6365c7 100644 --- a/crates/engine/src/cmd/brush_preset_cmds.rs +++ b/crates/engine/src/cmd/brush_preset_cmds.rs @@ -30,7 +30,7 @@ fn always(_: &Session) -> std::result::Result<(), String> { /// Longest preset or group name accepted. pub const MAX_NAME: usize = 255; /// Deepest folder path accepted inside a group (matches the `.abr` reader's limit). -pub const MAX_FOLDER_DEPTH: usize = photocraft_psd::abr::MAX_FOLDER_DEPTH; +pub const MAX_FOLDER_DEPTH: usize = craft_psd::abr::MAX_FOLDER_DEPTH; fn text(p: &Value, k: &str, cmd: &str) -> Result { let v = p.get(k).and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(cmd, format!("missing `{k}`")))?; diff --git a/crates/engine/src/cmd/contour_lib_cmds.rs b/crates/engine/src/cmd/contour_lib_cmds.rs index 3b6ab53955..36ba5ac209 100644 --- a/crates/engine/src/cmd/contour_lib_cmds.rs +++ b/crates/engine/src/cmd/contour_lib_cmds.rs @@ -5,7 +5,7 @@ //! The library is [`PresetState::contours`](crate::presets::PresetState); like the other preset //! sets it lives in the preferences document and changes the preferences, not a document's //! history. A user contour is applied to an effect as a `contour` curve object (see -//! [`contour_param`]); `.shc` files go through `photocraft_psd::shc` (see [`contours_from_shc`]). +//! [`contour_param`]); `.shc` files go through `craft_psd::shc` (see [`contours_from_shc`]). use photocraft_doc::Contour; use photocraft_doc::adjust::CurvePoint; diff --git a/crates/engine/src/cmd/file_cmds/tests.rs b/crates/engine/src/cmd/file_cmds/tests.rs index 57535c4b3c..9b3207a8ee 100644 --- a/crates/engine/src/cmd/file_cmds/tests.rs +++ b/crates/engine/src/cmd/file_cmds/tests.rs @@ -1871,8 +1871,8 @@ fn image_previews_preference_controls_the_psd_thumbnail() { let dir = tmp("previews"); let mut s = session(300, 200, 16); let has_thumbnail = |path: &str| { - let file = photocraft_psd::PsdFile::from_bytes(&std::fs::read(path).unwrap()).unwrap(); - file.resources.iter().any(|r| r.id == photocraft_psd::resources::ids::THUMBNAIL) + let file = craft_psd::PsdFile::from_bytes(&std::fs::read(path).unwrap()).unwrap(); + file.resources.iter().any(|r| r.id == craft_psd::resources::ids::THUMBNAIL) }; for (ask, expect) in [(Ask::Always, true), (Ask::Never, false), (Ask::Ask, true)] { s.edit_prefs(|p| p.file_handling.image_previews = ask); @@ -1893,7 +1893,7 @@ fn maximize_compatibility_preference_controls_the_psd_merged_image() { let dir = tmp("maximize"); let mut s = session(300, 200, 16); s.execute("layer.new.layer", json!({})).unwrap(); - let real_merged = |path: &str| photocraft_psd::PsdFile::from_bytes(&std::fs::read(path).unwrap()).unwrap().has_real_merged_data(); + let real_merged = |path: &str| craft_psd::PsdFile::from_bytes(&std::fs::read(path).unwrap()).unwrap().has_real_merged_data(); for (ask, expect) in [(Ask::Always, true), (Ask::Never, false), (Ask::Ask, true)] { s.edit_prefs(|p| p.file_handling.maximize_psd_compatibility = ask); for ext in ["psd", "psb"] { @@ -1944,7 +1944,7 @@ fn maximize_compatibility_param_overrides_the_preference_for_one_save() { let dir = tmp("maximize-param"); let mut s = session(300, 200, 16); s.execute("layer.new.layer", json!({})).unwrap(); - let real_merged = |path: &str| photocraft_psd::PsdFile::from_bytes(&std::fs::read(path).unwrap()).unwrap().has_real_merged_data(); + let real_merged = |path: &str| craft_psd::PsdFile::from_bytes(&std::fs::read(path).unwrap()).unwrap().has_real_merged_data(); for (ask, param, expect) in [ (Ask::Always, json!(false), false), (Ask::Never, json!(true), true), diff --git a/crates/engine/src/cmd/preset_import_cmds.rs b/crates/engine/src/cmd/preset_import_cmds.rs index 4430f4d1d2..a68e297d62 100644 --- a/crates/engine/src/cmd/preset_import_cmds.rs +++ b/crates/engine/src/cmd/preset_import_cmds.rs @@ -3,7 +3,7 @@ //! - `brush.presets.importAbr` adds the presets of an `.abr` file (v1, v2, v6+) to the brush //! library as one group named after the file, with the file's own folders nested inside it //! as Photoshop does (Brushes panel › Import Brushes…, Preset Manager). Parsing lives in -//! `photocraft-psd` (`abr`), the mapping onto [`BrushSettings`] in `photocraft-io` (`abr_map`). +//! `craft-psd` (`abr`), the mapping onto [`BrushSettings`] in `photocraft-io` (`abr_map`). //! - `brush.presets.importImage` turns an image file (anything File › Open reads: PNG, JPEG, //! TIFF, PSD…) into a sampled-tip brush preset, as Edit › Define Brush Preset does with a //! selection: dark pixels paint, white and transparent ones don't (TOOL-213-12). @@ -30,7 +30,7 @@ pub const MAX_FILE_BYTES: u64 = 512 << 20; /// Largest accepted `.abr` file: brush libraries of large sampled tips outgrow [`MAX_FILE_BYTES`]. /// Tied to the decoder's sample budget (1 GiB, 512 MiB on 32-bit targets) so it never refuses /// what this accepts. -pub const MAX_ABR_FILE_BYTES: u64 = photocraft_psd::abr::MAX_TOTAL_BYTES as u64; +pub const MAX_ABR_FILE_BYTES: u64 = craft_psd::abr::MAX_TOTAL_BYTES as u64; /// File bytes from `data` (base64) or `path`, plus a default group name. pub(crate) fn file_bytes(p: &Value, cmd: &str, what: &str) -> Result<(Vec, String)> { @@ -350,9 +350,9 @@ fn lab_to_rgb([l, a, b]: [f32; 3]) -> [f32; 3] { [gamma(r), gamma(g), gamma(bb)] } -fn stop_color(c: photocraft_psd::grd::GrdColor) -> crate::presets::gradients::StopColor { +fn stop_color(c: craft_psd::grd::GrdColor) -> crate::presets::gradients::StopColor { use crate::presets::gradients::StopColor; - use photocraft_psd::grd::GrdColor; + use craft_psd::grd::GrdColor; let clamp = |c: [f32; 3]| StopColor::Rgb(c.map(|v| if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 })); match c { GrdColor::Foreground => StopColor::Foreground, @@ -375,7 +375,7 @@ fn import_grd(s: &mut Session, p: &Value) -> Result { None if !stem.is_empty() => stem, None => "Imported Gradients".to_string(), }; - let grads = photocraft_psd::grd::parse(&bytes).map_err(|e| bad(cmd, format!("not a readable gradient file (.grd): {e}")))?; + let grads = craft_psd::grd::parse(&bytes).map_err(|e| bad(cmd, format!("not a readable gradient file (.grd): {e}")))?; // Same default as the group insertion below and the documented contract (#10640). let replace = p.get("replace").and_then(Value::as_bool).unwrap_or(false); let mut taken = @@ -459,8 +459,8 @@ mod tests { assert!(super::file_bytes_limited(&over_limit, "test", "preset", 2).is_err()); } use super::*; - use photocraft_psd::abr::{AbrSample, LegacyBrush, LegacyTip, write_v6, write_v12}; - use photocraft_psd::descriptor::{Descriptor, UnicodeString, Value as DV}; + use craft_psd::abr::{AbrSample, LegacyBrush, LegacyTip, write_v6, write_v12}; + use craft_psd::descriptor::{Descriptor, UnicodeString, Value as DV}; fn abr_v2() -> Vec { let tip = AbrSample { id: String::new(), width: 6, height: 4, depth: 8, data: vec![255; 24] }; @@ -484,14 +484,14 @@ mod tests { /// the value its options-bar field shows, so it paints instead of laying down nothing. #[test] fn selecting_an_imported_zero_opacity_brush_floors_it_at_one_percent() { - use photocraft_psd::descriptor::{Descriptor, UnicodeString, Value as D}; + use craft_psd::descriptor::{Descriptor, UnicodeString, Value as D}; let preset = |opacity: i32| { Descriptor::new("brushPreset") .with("Nm ", D::Text(UnicodeString::new_nul("Faint"))) .with("toolOptions", D::Descriptor(Descriptor::new("currentToolOptions").with("Opct", D::Integer(opacity)))) }; for (opacity, expected) in [(0, 0.01), (37, 0.37)] { - let abr = photocraft_psd::abr::write_v6_folders(2, &[], &[], &[preset(opacity)], &[vec![]], true).unwrap(); + let abr = craft_psd::abr::write_v6_folders(2, &[], &[], &[preset(opacity)], &[vec![]], true).unwrap(); let mut s = Session::new(); s.execute("brush.presets.importAbr", json!({"data": photocraft_paint::tile::b64_encode(&abr), "select": true})).unwrap(); assert!((s.tools.brush.opacity - expected).abs() < 1e-6, "{opacity}: {}", s.tools.brush.opacity); @@ -588,7 +588,7 @@ mod tests { #[test] fn duplicate_gradient_names_remain_independently_selectable() { - use photocraft_psd::grd::{GrdColor, GrdGradient, GrdStop, write}; + use craft_psd::grd::{GrdColor, GrdGradient, GrdStop, write}; let gradients: Vec<_> = [0.2, 0.8] .into_iter() .map(|red| GrdGradient { @@ -627,7 +627,7 @@ mod tests { /// names distinct from the existing group's, keeping name-only selection unambiguous. #[test] fn default_gradient_reimport_keeps_names_unique_across_sibling_groups() { - use photocraft_psd::grd::{GrdColor, GrdGradient, GrdStop, write}; + use craft_psd::grd::{GrdColor, GrdGradient, GrdStop, write}; let gradients = vec![GrdGradient { name: "Sunset".into(), stops: vec![ @@ -651,7 +651,7 @@ mod tests { #[test] fn gradient_import_preserves_original_and_case_distinct_names() { - use photocraft_psd::grd::{GrdColor, GrdGradient, GrdStop, write}; + use craft_psd::grd::{GrdColor, GrdGradient, GrdStop, write}; let gradients: Vec<_> = ["Foo", "Foo", "Foo 2", "FOO"] .into_iter() .enumerate() @@ -687,7 +687,7 @@ mod tests { #[test] fn grd_import_adds_a_gradient_group() { use crate::presets::gradients::StopColor; - use photocraft_psd::grd::{GrdColor, GrdGradient, GrdStop, write}; + use craft_psd::grd::{GrdColor, GrdGradient, GrdStop, write}; let grads = vec![ GrdGradient { name: "Ember".into(), @@ -849,8 +849,8 @@ mod tests { #[test] fn abr_files_get_a_larger_size_cap() { - // 1 GiB on 64-bit; 32-bit (wasm) keeps 512 MiB so a big file fails cleanly instead of aborting. - const WANT: u64 = if cfg!(target_pointer_width = "64") { 1 << 30 } else { 512 << 20 }; + // Match craft-psd's aggregate decoded-sample budget: 2 GiB on 64-bit, 512 MiB on 32-bit. + const WANT: u64 = if cfg!(target_pointer_width = "64") { 2 << 30 } else { 512 << 20 }; const _: () = assert!(MAX_ABR_FILE_BYTES == WANT && MAX_ABR_FILE_BYTES >= MAX_FILE_BYTES); let dir = std::env::temp_dir().join(format!("pc-abr-cap-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); diff --git a/crates/engine/src/cmd/style_asl_cmds.rs b/crates/engine/src/cmd/style_asl_cmds.rs index 840459bf6d..1a7eaf4fe1 100644 --- a/crates/engine/src/cmd/style_asl_cmds.rs +++ b/crates/engine/src/cmd/style_asl_cmds.rs @@ -7,7 +7,7 @@ //! - `style.presets.exportAsl` writes styles (all, one group or named ones) as an `.asl`, with the //! patterns their effects use. //! -//! The container lives in `photocraft-psd` (`asl`), the mapping onto effects in `photocraft-io` +//! The container lives in `craft-psd` (`asl`), the mapping onto effects in `photocraft-io` //! (`asl_map`). use serde_json::{Value, json}; diff --git a/crates/engine/src/cmd/swatch_cmds.rs b/crates/engine/src/cmd/swatch_cmds.rs index 756e10883a..1a65981567 100644 --- a/crates/engine/src/cmd/swatch_cmds.rs +++ b/crates/engine/src/cmd/swatch_cmds.rs @@ -3,14 +3,14 @@ //! Photoshop `.aco` and Adobe Swatch Exchange `.ase` files. //! //! The library is [`PresetState::swatches`](crate::presets::PresetState) (model and colour -//! conversions in [`crate::presets::swatches`]); file parsing is `photocraft_psd::{aco, ase}`. +//! conversions in [`crate::presets::swatches`]); file parsing is `craft_psd::{aco, ase}`. //! Swatches are addressed by name (names are kept unique across the library, as for the other //! presets), optionally narrowed by `group`, or by `index` (position across all groups, in //! panel order). Like the other preset commands they change the preferences document, not a //! document's history. -use photocraft_psd::aco::{AcoColor, AcoSwatch}; -use photocraft_psd::ase::{AseColor, AseEntry, AseFile, AseKind, AseSwatch}; +use craft_psd::aco::{AcoColor, AcoSwatch}; +use craft_psd::ase::{AseColor, AseEntry, AseFile, AseKind, AseSwatch}; use serde_json::{Value, json}; use crate::commands::CommandSpec; @@ -473,18 +473,18 @@ fn kind_to_ase(k: SwatchKind) -> AseKind { } /// Colors in an `.act` color table (what the format holds). -pub const ACT_COLORS: usize = photocraft_psd::act::COLOR_COUNT; +pub const ACT_COLORS: usize = craft_psd::act::COLOR_COUNT; /// `.act` bytes as `#rrggbb` colors plus the transparent index, for the Color Table dialog. pub fn act_table(bytes: &[u8]) -> std::result::Result<(Vec, Option), String> { - let f = photocraft_psd::act::parse(bytes).map_err(|e| e.to_string())?; + let f = craft_psd::act::parse(bytes).map_err(|e| e.to_string())?; let hex = |c: &[u8; 3]| format!("#{:02x}{:02x}{:02x}", c[0], c[1], c[2]); Ok((f.used().iter().map(hex).collect(), f.transparent_index().map(|i| i as u64))) } /// Encode colors as `.act` bytes (at most [`ACT_COLORS`]); the transparent index is kept. pub fn act_bytes(colors: &[[u8; 3]], transparent: Option) -> std::result::Result, String> { - photocraft_psd::act::write(colors, Some(colors.len().min(ACT_COLORS) as u16), transparent).map_err(|e| e.to_string()) + craft_psd::act::write(colors, Some(colors.len().min(ACT_COLORS) as u16), transparent).map_err(|e| e.to_string()) } /// Parse a swatch file into groups (`group` names the group for ungrouped colours). @@ -494,7 +494,7 @@ pub fn read_file(bytes: &[u8], format: Format, group: &str) -> std::result::Resu let fallback = |i: usize, name: &str| if name.trim().is_empty() { format!("Swatch {}", i + 1) } else { clip(name.trim()) }; match format { Format::Aco => { - let f = photocraft_psd::aco::parse(bytes).map_err(|e| format!("not a readable .aco swatch file: {e}"))?; + let f = craft_psd::aco::parse(bytes).map_err(|e| format!("not a readable .aco swatch file: {e}"))?; let books = f.swatches.iter().filter(|s| matches!(s.color, AcoColor::Other { .. })).count(); if books > 0 { warnings.push(format!( @@ -510,7 +510,7 @@ pub fn read_file(bytes: &[u8], format: Format, group: &str) -> std::result::Resu out.push(Group::new(group, items)); } Format::Act => { - let f = photocraft_psd::act::parse(bytes).map_err(|e| format!("not a readable .act color table: {e}"))?; + let f = craft_psd::act::parse(bytes).map_err(|e| format!("not a readable .act color table: {e}"))?; if let Some(t) = f.transparent_index() { warnings.push(format!("color {} is the table's transparent color; swatches have no transparency so it was kept opaque", t + 1)); } @@ -523,7 +523,7 @@ pub fn read_file(bytes: &[u8], format: Format, group: &str) -> std::result::Resu out.push(Group::new(group, items)); } Format::Ase => { - let f = photocraft_psd::ase::parse(bytes).map_err(|e| format!("not a readable .ase swatch file: {e}"))?; + let f = craft_psd::ase::parse(bytes).map_err(|e| format!("not a readable .ase swatch file: {e}"))?; let mut skipped = 0; let mut n = 0usize; let mut conv = |s: &AseSwatch| -> Option { @@ -576,7 +576,7 @@ pub fn write_file(groups: &[Group], format: Format) -> std::result::Resu warnings.push(".aco files don't mark spot or global colours; save as .ase to keep them".into()); } let n = swatches.len(); - let bytes = photocraft_psd::aco::write(&swatches).map_err(|e| e.to_string())?; + let bytes = craft_psd::aco::write(&swatches).map_err(|e| e.to_string())?; Ok((bytes, n, warnings)) } Format::Act => { @@ -597,12 +597,12 @@ pub fn write_file(groups: &[Group], format: Format) -> std::result::Resu if skipped > 0 { warnings.push(format!("{skipped} non-RGB swatches can't be stored in an .act file and were left out (save as .aco to keep them)")); } - if triplets.len() > photocraft_psd::act::COLOR_COUNT { - warnings.push(format!("only the first {} swatches fit in an .act file", photocraft_psd::act::COLOR_COUNT)); - triplets.truncate(photocraft_psd::act::COLOR_COUNT); + if triplets.len() > craft_psd::act::COLOR_COUNT { + warnings.push(format!("only the first {} swatches fit in an .act file", craft_psd::act::COLOR_COUNT)); + triplets.truncate(craft_psd::act::COLOR_COUNT); } let n = triplets.len(); - let bytes = photocraft_psd::act::write(&triplets, Some(n as u16), None).map_err(|e| e.to_string())?; + let bytes = craft_psd::act::write(&triplets, Some(n as u16), None).map_err(|e| e.to_string())?; Ok((bytes, n, warnings)) } Format::Ase => { @@ -627,7 +627,7 @@ pub fn write_file(groups: &[Group], format: Format) -> std::result::Resu if books > 0 { warnings.push(format!("{books} colour-book swatches can't be stored in an .ase file and were left out (save as .aco to keep them)")); } - let bytes = photocraft_psd::ase::write(&AseFile { entries }).map_err(|e| e.to_string())?; + let bytes = craft_psd::ase::write(&AseFile { entries }).map_err(|e| e.to_string())?; Ok((bytes, n, warnings)) } } @@ -639,12 +639,12 @@ fn import(s: &mut Session, p: &Value) -> Result { // `.act` has no magic: exactly 768/772 bytes. An `.act` whose first colour is `0, 1, x` or // `0, 2, x` starts like an `.aco`, and the .aco reader ignores trailing bytes, so at those // lengths an .aco header only counts when its sections fill the file exactly. - let act_len = bytes.len() == photocraft_psd::act::PALETTE_LEN || bytes.len() == photocraft_psd::act::PALETTE_LEN + photocraft_psd::act::TRAILER_LEN; + let act_len = bytes.len() == craft_psd::act::PALETTE_LEN || bytes.len() == craft_psd::act::PALETTE_LEN + craft_psd::act::TRAILER_LEN; let format = match str_param(p, "format") { Some(f) => Format::parse(f).ok_or_else(|| bad(CMD, format!("unknown format `{f}` (aco|act|ase)")))?, None if ext_format(str_param(p, "path")) == Some(Format::Act) => Format::Act, - None if bytes.starts_with(photocraft_psd::ase::SIGNATURE) => Format::Ase, - None if matches!(bytes.get(..2), Some([0, 1] | [0, 2])) && (!act_len || photocraft_psd::aco::sections_len(&bytes) == Some(bytes.len())) => Format::Aco, + None if bytes.starts_with(craft_psd::ase::SIGNATURE) => Format::Ase, + None if matches!(bytes.get(..2), Some([0, 1] | [0, 2])) && (!act_len || craft_psd::aco::sections_len(&bytes) == Some(bytes.len())) => Format::Aco, None if act_len => Format::Act, None => ext_format(str_param(p, "path")).ok_or_else(|| bad(CMD, "not an .aco, .act or .ase swatch file"))?, }; diff --git a/crates/engine/src/cmd/swatch_cmds/tests.rs b/crates/engine/src/cmd/swatch_cmds/tests.rs index 54fadda05c..0a7529de7a 100644 --- a/crates/engine/src/cmd/swatch_cmds/tests.rs +++ b/crates/engine/src/cmd/swatch_cmds/tests.rs @@ -141,7 +141,7 @@ fn aco_round_trip_keeps_16_bit_values_and_books() { AcoSwatch { name: "G".into(), color: AcoColor::Gray(1234) }, AcoSwatch { name: "Book".into(), color: AcoColor::Other { space: 3, values: [9, 8, 7, 6] } }, ]; - let file = photocraft_psd::aco::write(&swatches).unwrap(); + let file = craft_psd::aco::write(&swatches).unwrap(); let mut s = Session::new(); let r = s.execute("swatches.import", json!({"data": b64_encode(&file), "group": "Pro", "mode": "replace"})).unwrap(); assert_eq!((r["format"].as_str(), r["count"].as_u64()), (Some("aco"), Some(6))); @@ -174,7 +174,7 @@ fn act_round_trip_loads_a_photoshop_color_table() { let v = i as u8; *c = [v, 255 - v, 128]; } - let file = photocraft_psd::act::write(&colors, Some(256), Some(255)).unwrap(); + let file = craft_psd::act::write(&colors, Some(256), Some(255)).unwrap(); assert_eq!(file.len(), 772); let mut s = Session::new(); let r = s.execute("swatches.import", json!({"data": b64_encode(&file), "group": "Table", "mode": "replace"})).unwrap(); @@ -187,7 +187,7 @@ fn act_round_trip_loads_a_photoshop_color_table() { assert_eq!(r["format"].as_str(), Some("act")); // Export keeps the 256 RGB colors; the trailer comes back in canonical form // (swatches have no transparency to preserve). - let canon = photocraft_psd::act::write(&colors, Some(256), None).unwrap(); + let canon = craft_psd::act::write(&colors, Some(256), None).unwrap(); assert_eq!(export_bytes(&mut s, json!({"format": "act"})), canon); // A bigger library is capped at 256 with a warning; groups collapse to one list. for i in 0..300 { @@ -207,7 +207,7 @@ fn act_that_starts_like_an_aco_is_still_an_act() { for (i, c) in colors.iter_mut().enumerate().skip(1) { *c = [(i as u8).wrapping_add(4), 7, 9]; } - for file in [photocraft_psd::act::write(&colors, None, None).unwrap(), photocraft_psd::act::write(&colors, Some(256), None).unwrap()] { + for file in [craft_psd::act::write(&colors, None, None).unwrap(), craft_psd::act::write(&colors, Some(256), None).unwrap()] { assert_eq!(&file[..4], &[0, 1, 0, 5]); let mut s = Session::new(); let r = s.execute("swatches.import", json!({"data": b64_encode(&file), "mode": "replace"})).unwrap(); @@ -215,8 +215,8 @@ fn act_that_starts_like_an_aco_is_still_an_act() { } // A real .aco of exactly 768 bytes (ten swatches with 25-character names) stays an .aco. let swatches: Vec = (0..10u16).map(|i| AcoSwatch { name: format!("{i:0>25}"), color: AcoColor::Rgb([i * 1000, 0, 65535]) }).collect(); - let file = photocraft_psd::aco::write(&swatches).unwrap(); - assert_eq!(file.len(), photocraft_psd::act::PALETTE_LEN); + let file = craft_psd::aco::write(&swatches).unwrap(); + assert_eq!(file.len(), craft_psd::act::PALETTE_LEN); let mut s = Session::new(); let r = s.execute("swatches.import", json!({"data": b64_encode(&file), "mode": "replace"})).unwrap(); assert_eq!((r["format"].as_str(), r["count"].as_u64()), (Some("aco"), Some(10))); @@ -239,7 +239,7 @@ fn ase_round_trip_keeps_groups_floats_and_kinds() { }, ], }; - let bytes = photocraft_psd::ase::write(&src).unwrap(); + let bytes = craft_psd::ase::write(&src).unwrap(); let mut s = Session::new(); let r = s.execute("swatches.import", json!({"data": b64_encode(&bytes), "group": "Exchange"})).unwrap(); assert_eq!(r["groups"], json!(["Exchange", "Brand"])); @@ -251,7 +251,7 @@ fn ase_round_trip_keeps_groups_floats_and_kinds() { assert_eq!(brand.items[2].color, SwatchColor::Gray(0.75)); // Export both groups and read them back. let out = export_bytes(&mut s, json!({"format": "ase", "swatches": ["Loose", "Spot ink", "Global lab", "Gray"]})); - let back = photocraft_psd::ase::parse(&out).unwrap(); + let back = craft_psd::ase::parse(&out).unwrap(); let flat: Vec<&AseSwatch> = back .entries .iter() @@ -271,7 +271,7 @@ fn ase_round_trip_keeps_groups_floats_and_kinds() { // HSB swatches become RGB in .ase. s.execute("swatches.add", json!({"name": "Hue", "color": {"model": "hsb", "values": [120, 100, 100]}})).unwrap(); let out = export_bytes(&mut s, json!({"format": "ase", "swatches": ["Hue"]})); - let back = photocraft_psd::ase::parse(&out).unwrap(); + let back = craft_psd::ase::parse(&out).unwrap(); assert_eq!( back.entries, vec![AseEntry::Group { @@ -357,7 +357,7 @@ fn bad_params_fail_gracefully() { fn library_limits() { let mut s = Session::new(); let one = vec![AcoSwatch { name: "x".into(), color: AcoColor::Gray(0) }; 60_000]; - let file = b64_encode(&photocraft_psd::aco::write(&one).unwrap()); + let file = b64_encode(&craft_psd::aco::write(&one).unwrap()); s.execute("swatches.import", json!({"data": file, "mode": "replace"})).unwrap(); assert!(s.execute("swatches.import", json!({"data": file})).is_err(), "over the library limit"); assert_eq!(names(&s).len(), 60_000); @@ -381,7 +381,7 @@ fn unnamed_groups_are_numbered_in_linear_time() { let paper = AseSwatch { name: "Paper".into(), color: AseColor::Gray(1.0), kind: AseKind::Process }; let mut entries = vec![AseEntry::Group { name: String::new(), swatches: Vec::new() }; n]; entries[0] = AseEntry::Group { name: String::new(), swatches: vec![paper] }; - let file = b64_encode(&photocraft_psd::ase::write(&AseFile { entries }).unwrap()); + let file = b64_encode(&craft_psd::ase::write(&AseFile { entries }).unwrap()); let r = s.execute("swatches.import", json!({"data": file, "group": "Ink"})).unwrap(); assert_eq!(s.presets.swatches.len(), MAX_GROUPS); let names: Vec = r["groups"].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect(); @@ -406,7 +406,7 @@ fn repeated_group_names_take_the_first_free_number() { } let paper = AseSwatch { name: "Paper".into(), color: AseColor::Gray(1.0), kind: AseKind::Process }; let entries = ["Brand", "Brand", "Brand", "Brand 1"].into_iter().map(|name| AseEntry::Group { name: name.into(), swatches: vec![paper.clone()] }).collect(); - let file = b64_encode(&photocraft_psd::ase::write(&AseFile { entries }).unwrap()); + let file = b64_encode(&craft_psd::ase::write(&AseFile { entries }).unwrap()); let r = s.execute("swatches.import", json!({"data": file})).unwrap(); // The file's "Brand 1" comes after the first "Brand" took that name. assert_eq!(r["groups"], json!(["Brand 1", "Brand 3", "Brand 4", "Brand 1 1"])); diff --git a/crates/engine/src/layer_style.rs b/crates/engine/src/layer_style.rs index f89779954b..9f7c2c7873 100644 --- a/crates/engine/src/layer_style.rs +++ b/crates/engine/src/layer_style.rs @@ -512,7 +512,7 @@ pub fn contour_from_param(v: &Value) -> Option { /// The contours of an `.shc` contour preset file (Contour Editor › Load…), as `contour` params. /// Points are rescaled from the file's `0..=255` levels; unusable curves are skipped. pub fn contours_from_shc(bytes: &[u8]) -> Result> { - let file = photocraft_psd::shc::parse(bytes).map_err(|e| EngineError::Other(format!("not a contour file: {e}")))?; + let file = craft_psd::shc::parse(bytes).map_err(|e| EngineError::Other(format!("not a contour file: {e}")))?; Ok(file .into_iter() .filter_map(|c| { @@ -533,7 +533,7 @@ pub fn contour_to_shc(contour: &Contour) -> Result> { /// An `.shc` file holding every contour of `list` (Save Contours…). pub fn contour_to_shc_all(list: &[&Contour]) -> Result> { - let file: Vec = list + let file: Vec = list .iter() .map(|contour| { let (name, points) = match contour { @@ -543,12 +543,12 @@ pub fn contour_to_shc_all(list: &[&Contour]) -> Result> { let points = points .iter() .enumerate() - .map(|(i, p)| photocraft_psd::shc::ShcPoint { input: p.input * 255.0, output: p.output * 255.0, continuous: !contour.is_corner(i) }) + .map(|(i, p)| craft_psd::shc::ShcPoint { input: p.input * 255.0, output: p.output * 255.0, continuous: !contour.is_corner(i) }) .collect(); - photocraft_psd::shc::ShcContour { name, points } + craft_psd::shc::ShcContour { name, points } }) .collect(); - photocraft_psd::shc::write(&file).map_err(|e| EngineError::Other(format!("can't write the contour: {e}"))) + craft_psd::shc::write(&file).map_err(|e| EngineError::Other(format!("can't write the contour: {e}"))) } /// An effect of `kind` with its factory settings (before params are applied). diff --git a/crates/engine/src/preset_store/tests.rs b/crates/engine/src/preset_store/tests.rs index e5481061ba..f1b925a550 100644 --- a/crates/engine/src/preset_store/tests.rs +++ b/crates/engine/src/preset_store/tests.rs @@ -1,5 +1,5 @@ use super::*; -use photocraft_psd::abr::{AbrSample, LegacyBrush, LegacyTip, write_v12}; +use craft_psd::abr::{AbrSample, LegacyBrush, LegacyTip, write_v12}; use serde_json::json; use std::path::PathBuf; @@ -598,10 +598,10 @@ fn a_store_without_previews_makes_them_once_and_then_loads_lazily() { /// A v6 `.abr` whose presets sit in Photoshop folders (`phry`). fn abr_with_folders(presets: &[(&str, &[&str])]) -> Vec { - use photocraft_psd::descriptor::{Descriptor, UnicodeString, Value}; + use craft_psd::descriptor::{Descriptor, UnicodeString, Value}; let descs: Vec = presets.iter().map(|(n, _)| Descriptor::new("brushPreset").with("Nm ", Value::Text(UnicodeString::new_nul(n)))).collect(); let folders: Vec> = presets.iter().map(|(_, f)| f.iter().map(|x| x.to_string()).collect()).collect(); - photocraft_psd::abr::write_v6_folders(2, &[], &[], &descs, &folders, true).unwrap() + craft_psd::abr::write_v6_folders(2, &[], &[], &descs, &folders, true).unwrap() } #[test] diff --git a/crates/engine/src/presets/shapes/csh.rs b/crates/engine/src/presets/shapes/csh.rs index d9c83bca5a..290d1f0367 100644 --- a/crates/engine/src/presets/shapes/csh.rs +++ b/crates/engine/src/presets/shapes/csh.rs @@ -38,8 +38,8 @@ fn import(s: &mut Session, p: &Value) -> Result { false, move |ctx| { ctx.check()?; - let (bytes, stem) = crate::preset_import_cmds::file_bytes_limited(¶ms, CMD, ".csh file", photocraft_psd::csh::MAX_FILE_BYTES as u64)?; - let shapes = photocraft_psd::csh::parse(&bytes).map_err(|e| bad(CMD, e.to_string()))?; + let (bytes, stem) = crate::preset_import_cmds::file_bytes_limited(¶ms, CMD, ".csh file", craft_psd::csh::MAX_FILE_BYTES as u64)?; + let shapes = craft_psd::csh::parse(&bytes).map_err(|e| bad(CMD, e.to_string()))?; if shapes.is_empty() { return Err(bad(CMD, "the CSH set contains no shapes")); } diff --git a/crates/engine/src/presets/swatches.rs b/crates/engine/src/presets/swatches.rs index eb666e1ec5..d8fe6c194a 100644 --- a/crates/engine/src/presets/swatches.rs +++ b/crates/engine/src/presets/swatches.rs @@ -11,7 +11,7 @@ //! The library persists with the preferences document (`presets.swatches`, see //! [`super::PresetState::to_json`]). The default set is our own (clean-room): grays, pastels, //! pure and dark hues. The commands live in `crate::swatch_cmds`; file formats in -//! `photocraft_psd::{aco, ase}`. +//! `craft_psd::{aco, ase}`. use std::sync::Arc; diff --git a/crates/engine/tests/atn_mapped_identifiers.rs b/crates/engine/tests/atn_mapped_identifiers.rs index df643b2608..f2a2ed0f6e 100644 --- a/crates/engine/tests/atn_mapped_identifiers.rs +++ b/crates/engine/tests/atn_mapped_identifiers.rs @@ -1,10 +1,10 @@ //! Recorded identifiers must match Photoshop, even when a local round trip would accept a typo. -use photocraft_engine::{Session, actions_cmds::Action, atn_cmds}; -use photocraft_psd::{ +use craft_psd::{ atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}, descriptor::{Class, Descriptor, Id, ReferenceItem, UnicodeString, Value as D}, }; +use photocraft_engine::{Session, actions_cmds::Action, atn_cmds}; use serde_json::json; /// A small marked document makes canvas translation and image resampling distinguishable. diff --git a/crates/psd/tests/common/csh_fixture.rs b/crates/engine/tests/common/csh_fixture.rs similarity index 100% rename from crates/psd/tests/common/csh_fixture.rs rename to crates/engine/tests/common/csh_fixture.rs diff --git a/crates/engine/tests/content_aware_fill_atn.rs b/crates/engine/tests/content_aware_fill_atn.rs index 796982207f..fe15599c21 100644 --- a/crates/engine/tests/content_aware_fill_atn.rs +++ b/crates/engine/tests/content_aware_fill_atn.rs @@ -1,11 +1,11 @@ //! #10694: Content-Aware Fill options survive ATN load, save and playback. -use photocraft_doc::{LayerId, Rect}; -use photocraft_engine::{Session, actions_cmds::Action, atn_cmds}; -use photocraft_psd::{ +use craft_psd::{ atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}, descriptor::{Descriptor, Id, UnicodeString, Value as D}, }; +use photocraft_doc::{LayerId, Rect}; +use photocraft_engine::{Session, actions_cmds::Action, atn_cmds}; use serde_json::{Value, json}; const FLAGS: [(&str, &str); 4] = [ diff --git a/crates/engine/tests/convert_profile_atn.rs b/crates/engine/tests/convert_profile_atn.rs index cf1f91b6de..a635979f1c 100644 --- a/crates/engine/tests/convert_profile_atn.rs +++ b/crates/engine/tests/convert_profile_atn.rs @@ -2,15 +2,15 @@ use std::sync::Arc; +use craft_psd::{ + atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}, + descriptor::{Descriptor, Id, UnicodeString, Value as D}, +}; use photocraft_cms::Builtin; use photocraft_color::{ColorMode, PixelFormat, SampleType}; use photocraft_doc::{Document, Layer, LayerContent, SmartObject, SmartSource}; use photocraft_engine::{Session, actions_cmds::Action, atn_cmds}; use photocraft_geom::{Affine, Size}; -use photocraft_psd::{ - atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}, - descriptor::{Descriptor, Id, UnicodeString, Value as D}, -}; use photocraft_raster::Surface; use serde_json::{Value, json}; @@ -259,8 +259,8 @@ fn reference_recorded_convert_to_profile_maps() { let d = Descriptor::new("null") .with( "null", - D::Reference(vec![photocraft_psd::descriptor::ReferenceItem::Enumerated { - class: photocraft_psd::descriptor::Class { name: UnicodeString::new_nul(""), class_id: Id::new("Dcmn") }, + D::Reference(vec![craft_psd::descriptor::ReferenceItem::Enumerated { + class: craft_psd::descriptor::Class { name: UnicodeString::new_nul(""), class_id: Id::new("Dcmn") }, type_id: Id::new("Ordn"), value: Id::new("Trgt"), }]), diff --git a/crates/engine/tests/csh_import.rs b/crates/engine/tests/csh_import.rs index 3c6e8779dd..7c48d6a3a9 100644 --- a/crates/engine/tests/csh_import.rs +++ b/crates/engine/tests/csh_import.rs @@ -4,7 +4,7 @@ use photocraft_engine::Session; use serde_json::json; -#[path = "../../psd/tests/common/csh_fixture.rs"] +#[path = "common/csh_fixture.rs"] mod fixture; use fixture::{polygon_csh, triangle_csh}; diff --git a/crates/psd/tests/fixtures/atn/v12-commands.atn b/crates/engine/tests/fixtures/atn/v12-commands.atn similarity index 100% rename from crates/psd/tests/fixtures/atn/v12-commands.atn rename to crates/engine/tests/fixtures/atn/v12-commands.atn diff --git a/crates/engine/tests/image_size_scale_styles_atn.rs b/crates/engine/tests/image_size_scale_styles_atn.rs index 72b91241a4..633057d752 100644 --- a/crates/engine/tests/image_size_scale_styles_atn.rs +++ b/crates/engine/tests/image_size_scale_styles_atn.rs @@ -1,12 +1,12 @@ //! #10694: Image Size style scaling survives ATN import, playback and saving. -use photocraft_color::{BlendMode, Color}; -use photocraft_doc::{Effect, Effects, FxCommon, FxPaint, LayerId, StrokeFx, StrokePosition}; -use photocraft_engine::{Session, actions_cmds::Action, atn_cmds}; -use photocraft_psd::{ +use craft_psd::{ atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}, descriptor::{Descriptor, Id, UnicodeString, Value as D}, }; +use photocraft_color::{BlendMode, Color}; +use photocraft_doc::{Effect, Effects, FxCommon, FxPaint, LayerId, StrokeFx, StrokePosition}; +use photocraft_engine::{Session, actions_cmds::Action, atn_cmds}; use serde_json::{Value, json}; fn recorded(event: &str, descriptor: Option) -> Vec { diff --git a/crates/engine/tests/it/actions_panel_menu.rs b/crates/engine/tests/it/actions_panel_menu.rs index 6b65f2f08d..02719d56af 100644 --- a/crates/engine/tests/it/actions_panel_menu.rs +++ b/crates/engine/tests/it/actions_panel_menu.rs @@ -143,7 +143,7 @@ fn inserted_menu_events_roundtrip_absent_settings_and_stateless_items_execute() let mut s = session(); s.execute("actions.insertMenuItem", json!({"action":"A","id":id,"eventKind":kind})).unwrap(); let bytes = atn_cmds::export("S", &[&s.actions.list[0]], &[], false).unwrap().0; - let raw = photocraft_psd::atn::parse(&bytes).unwrap(); + let raw = craft_psd::atn::parse(&bytes).unwrap(); assert!(raw.actions[0].items[0].descriptor.is_none()); let imported = atn_cmds::import(&bytes).unwrap(); let (step, params) = &imported.actions[0].steps[0]; @@ -152,7 +152,7 @@ fn inserted_menu_events_roundtrip_absent_settings_and_stateless_items_execute() assert_eq!(params["eventKind"], kind); let (saved, skipped) = atn_cmds::export("S", &[&imported.actions[0]], &[], false).unwrap(); assert!(skipped.is_empty()); - let saved = photocraft_psd::atn::parse(&saved).unwrap(); + let saved = craft_psd::atn::parse(&saved).unwrap(); assert_eq!(saved.actions[0].items[0], raw.actions[0].items[0]); let played = s.execute("actions.play", json!({"action":"A"})).unwrap(); assert_eq!(played.get("failed").is_none(), !atn_cmds::menu_item_needs_settings(id), "{id}: {played}"); @@ -166,16 +166,16 @@ fn inserted_menu_events_roundtrip_absent_settings_and_stateless_items_execute() assert!(s.execute("actions.insertMenuItem", params).is_err()); assert_eq!(s.actions, original); } - let mut raw = photocraft_psd::atn::AtnFile::default(); - raw.actions.push(photocraft_psd::atn::AtnAction { - items: vec![photocraft_psd::atn::AtnItem { - event: photocraft_psd::atn::AtnEvent::name("gaussianBlur"), - descriptor: Some(photocraft_psd::descriptor::Descriptor::new("null")), + let mut raw = craft_psd::atn::AtnFile::default(); + raw.actions.push(craft_psd::atn::AtnAction { + items: vec![craft_psd::atn::AtnItem { + event: craft_psd::atn::AtnEvent::name("gaussianBlur"), + descriptor: Some(craft_psd::descriptor::Descriptor::new("null")), ..Default::default() }], ..Default::default() }); - let loaded = atn_cmds::import(&photocraft_psd::atn::write(&raw).unwrap()).unwrap(); + let loaded = atn_cmds::import(&craft_psd::atn::write(&raw).unwrap()).unwrap(); assert_eq!(loaded.actions[0].steps[0].0, "filter.blur.gaussianBlur"); } diff --git a/crates/engine/tests/it/atn_adjustment_make_props.rs b/crates/engine/tests/it/atn_adjustment_make_props.rs index 3cd7475428..7048432330 100644 --- a/crates/engine/tests/it/atn_adjustment_make_props.rs +++ b/crates/engine/tests/it/atn_adjustment_make_props.rs @@ -1,9 +1,9 @@ //! #8384: an adjustment layer's Make step keeps the new layer's name (`Nm `), blend mode //! (`Md `) and clipping (`Grup`) on load, playback and save. +use craft_psd::atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}; +use craft_psd::descriptor::{Class, Descriptor, Id, ReferenceItem, UnicodeString, Value as D}; use photocraft_doc::{BlendMode, LayerContent}; use photocraft_engine::{Session, actions_cmds::Action, atn_cmds}; -use photocraft_psd::atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}; -use photocraft_psd::descriptor::{Class, Descriptor, Id, ReferenceItem, UnicodeString, Value as D}; use serde_json::{Value, json}; const NAME: &str = "Independent named adjustment"; diff --git a/crates/engine/tests/it/atn_calculations.rs b/crates/engine/tests/it/atn_calculations.rs index 7b9f16984e..063f69faa8 100644 --- a/crates/engine/tests/it/atn_calculations.rs +++ b/crates/engine/tests/it/atn_calculations.rs @@ -1,8 +1,8 @@ //! Recorded Calculations Make forms, using the measured reference and ScriptListener types. +use craft_psd::atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}; +use craft_psd::descriptor::{Class, Descriptor, Id, ReferenceItem, UnicodeString, Value as D}; use photocraft_engine::actions_cmds::Action; use photocraft_engine::{Session, atn_cmds}; -use photocraft_psd::atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}; -use photocraft_psd::descriptor::{Class, Descriptor, Id, ReferenceItem, UnicodeString, Value as D}; use serde_json::{Value, json}; fn class(id: &str) -> Class { diff --git a/crates/engine/tests/it/atn_classic_gradient.rs b/crates/engine/tests/it/atn_classic_gradient.rs index 709b7a7d90..88ae76f3c2 100644 --- a/crates/engine/tests/it/atn_classic_gradient.rs +++ b/crates/engine/tests/it/atn_classic_gradient.rs @@ -1,9 +1,9 @@ //! Synthetic measured-layout recordings through public ATN load/save and playback. -use photocraft_engine::{Session, actions_cmds::Action, atn_cmds}; -use photocraft_psd::{ +use craft_psd::{ atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}, descriptor::{Descriptor, Id, UnicodeString, Value as D}, }; +use photocraft_engine::{Session, actions_cmds::Action, atn_cmds}; use serde_json::json; fn enm(ty: &str, value: &str) -> D { diff --git a/crates/engine/tests/it/atn_color_lookup.rs b/crates/engine/tests/it/atn_color_lookup.rs index edc58a2122..9d86a27cc9 100644 --- a/crates/engine/tests/it/atn_color_lookup.rs +++ b/crates/engine/tests/it/atn_color_lookup.rs @@ -1,7 +1,7 @@ +use craft_psd::atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}; +use craft_psd::descriptor::{Descriptor, Id, UnicodeString, Value as D}; use photocraft_doc::{Adjustment, LayerContent}; use photocraft_engine::{Session, atn_cmds}; -use photocraft_psd::atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}; -use photocraft_psd::descriptor::{Descriptor, Id, UnicodeString, Value as D}; use serde_json::json; const CUBE: &[u8] = b"LUT_3D_SIZE 2\n1 1 1\n0 1 1\n1 0 1\n0 0 1\n1 1 0\n0 1 0\n1 0 0\n0 0 0\n"; diff --git a/crates/engine/tests/it/atn_content_aware_fill.rs b/crates/engine/tests/it/atn_content_aware_fill.rs index bb175549b4..c5d6aaa46b 100644 --- a/crates/engine/tests/it/atn_content_aware_fill.rs +++ b/crates/engine/tests/it/atn_content_aware_fill.rs @@ -1,11 +1,11 @@ //! Recorded CAF settings use the native operation; unsupported descriptors stay whole. +use craft_psd::atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}; +use craft_psd::descriptor::{Descriptor, Id, UnicodeString, Value as D}; use photocraft_engine::{ Session, actions_cmds::Action, atn_cmds::{export, import}, }; -use photocraft_psd::atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}; -use photocraft_psd::descriptor::{Descriptor, Id, UnicodeString, Value as D}; use photocraft_raster::Surface; use serde_json::{Value, json}; diff --git a/crates/engine/tests/it/atn_crystallize_cell_size.rs b/crates/engine/tests/it/atn_crystallize_cell_size.rs index 8a015f1497..85bedf1ee6 100644 --- a/crates/engine/tests/it/atn_crystallize_cell_size.rs +++ b/crates/engine/tests/it/atn_crystallize_cell_size.rs @@ -1,7 +1,7 @@ //! #7669: strict ATN export must not replace a native fractional Crystallize cell size. +use craft_psd::{atn, descriptor::Value as D}; use photocraft_engine::actions_cmds::Action; use photocraft_engine::{Session, atn_cmds}; -use photocraft_psd::{atn, descriptor::Value as D}; use serde_json::{Value, json}; const CRYSTALLIZE: &str = "filter.pixelate.crystallize"; diff --git a/crates/engine/tests/it/atn_dialog_filters.rs b/crates/engine/tests/it/atn_dialog_filters.rs index 0e1b925820..a419188fe6 100644 --- a/crates/engine/tests/it/atn_dialog_filters.rs +++ b/crates/engine/tests/it/atn_dialog_filters.rs @@ -1,6 +1,6 @@ +use craft_psd::atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}; +use craft_psd::descriptor::{Descriptor, Id, UnicodeString, Value as D}; use photocraft_engine::{Session, atn_cmds}; -use photocraft_psd::atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}; -use photocraft_psd::descriptor::{Descriptor, Id, UnicodeString, Value as D}; use serde_json::{Value, json}; fn encoded(code: &[u8; 4], d: Descriptor) -> Vec { diff --git a/crates/engine/tests/it/atn_direct_adjustments.rs b/crates/engine/tests/it/atn_direct_adjustments.rs index 8f59433b9e..66fce5090f 100644 --- a/crates/engine/tests/it/atn_direct_adjustments.rs +++ b/crates/engine/tests/it/atn_direct_adjustments.rs @@ -1,9 +1,9 @@ //! Recorded direct Gradient Map and Replace Color forms from #10603 / #10607. -use photocraft_engine::{Session, atn_cmds}; -use photocraft_psd::{ +use craft_psd::{ atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}, descriptor::{Descriptor, Id, UnicodeString, Value as D}, }; +use photocraft_engine::{Session, atn_cmds}; use serde_json::json; fn enumeration(kind: &str, value: &str) -> D { diff --git a/crates/engine/tests/it/atn_layer_style.rs b/crates/engine/tests/it/atn_layer_style.rs index 59b484022a..3cc35bfc73 100644 --- a/crates/engine/tests/it/atn_layer_style.rs +++ b/crates/engine/tests/it/atn_layer_style.rs @@ -1,14 +1,14 @@ //! Measured layer-style action steps retain their settings and use the existing native commands. +use craft_psd::{ + atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}, + descriptor::{Class, Descriptor, Id, MAX_DEPTH, ObjectArray, ReferenceItem, UnicodeString, Value as D}, +}; use photocraft_doc::{Document, Effect, LayerId}; use photocraft_engine::{ Session, actions_cmds::Action, atn_cmds::{export, import}, }; -use photocraft_psd::{ - atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}, - descriptor::{Class, Descriptor, Id, MAX_DEPTH, ObjectArray, ReferenceItem, UnicodeString, Value as D}, -}; use serde_json::{Value, json}; const SCALE: &str = "layer.layerStyle.scaleEffects"; diff --git a/crates/engine/tests/it/atn_newer_events.rs b/crates/engine/tests/it/atn_newer_events.rs index 302a0545c1..bb5adf300f 100644 --- a/crates/engine/tests/it/atn_newer_events.rs +++ b/crates/engine/tests/it/atn_newer_events.rs @@ -1,10 +1,10 @@ //! #10700: recorded Link and Unlink must not replay as a state-dependent toggle. -use photocraft_doc::{Layer, LayerContent, LayerId, SmartObject, SmartSource}; -use photocraft_engine::{Session, actions_cmds::Action, atn_cmds}; -use photocraft_psd::{ +use craft_psd::{ atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}, descriptor::{Class, Descriptor, Id, ReferenceItem, UnicodeString, Value as D}, }; +use photocraft_doc::{Layer, LayerContent, LayerId, SmartObject, SmartSource}; +use photocraft_engine::{Session, actions_cmds::Action, atn_cmds}; use serde_json::json; use std::sync::Arc; diff --git a/crates/engine/tests/it/atn_perspective_warp.rs b/crates/engine/tests/it/atn_perspective_warp.rs index 2967285842..0597150593 100644 --- a/crates/engine/tests/it/atn_perspective_warp.rs +++ b/crates/engine/tests/it/atn_perspective_warp.rs @@ -1,12 +1,12 @@ //! The measured Perspective Warp descriptor shape through ATN conversion and action playback. -use photocraft_doc::{LayerContent, LayerId}; -use photocraft_engine::{Session, actions_cmds::Action, atn_cmds}; -use photocraft_geom::Rect; -use photocraft_psd::{ +use craft_psd::{ atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}, descriptor::{Class, Descriptor, Id, ReferenceItem, UnicodeString, Value as D}, }; +use photocraft_doc::{LayerContent, LayerId}; +use photocraft_engine::{Session, actions_cmds::Action, atn_cmds}; +use photocraft_geom::Rect; use serde_json::{Value, json}; const EVENT: &str = "perspectiveWarpTransform"; diff --git a/crates/engine/tests/it/atn_reference_forms.rs b/crates/engine/tests/it/atn_reference_forms.rs index 0e35280eb7..3b2032b244 100644 --- a/crates/engine/tests/it/atn_reference_forms.rs +++ b/crates/engine/tests/it/atn_reference_forms.rs @@ -1,6 +1,6 @@ +use craft_psd::atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}; +use craft_psd::descriptor::{Class, Descriptor, Id, ReferenceItem, UnicodeString, Value as D}; use photocraft_engine::{Session, actions_cmds::Action, atn_cmds}; -use photocraft_psd::atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}; -use photocraft_psd::descriptor::{Class, Descriptor, Id, ReferenceItem, UnicodeString, Value as D}; use serde_json::{Value, json}; fn class(name: &str) -> Class { diff --git a/crates/engine/tests/it/atn_review_regressions.rs b/crates/engine/tests/it/atn_review_regressions.rs index aabe913b22..3e3abdcc40 100644 --- a/crates/engine/tests/it/atn_review_regressions.rs +++ b/crates/engine/tests/it/atn_review_regressions.rs @@ -1,8 +1,8 @@ //! Public-API regressions for the dialog-event and Batch review findings. +use craft_psd::atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}; +use craft_psd::descriptor::{Class, Descriptor, Id, ReferenceItem, UnicodeString, Value as D}; use photocraft_doc::LayerContent; use photocraft_engine::{Session, actions_cmds::Action, atn_cmds}; -use photocraft_psd::atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}; -use photocraft_psd::descriptor::{Class, Descriptor, Id, ReferenceItem, UnicodeString, Value as D}; use serde_json::{Value, json}; /// A bounded synthetic document for command and channel comparisons. diff --git a/crates/engine/tests/it/atn_ripple_amount.rs b/crates/engine/tests/it/atn_ripple_amount.rs index 0ff38f2036..eec6a46a69 100644 --- a/crates/engine/tests/it/atn_ripple_amount.rs +++ b/crates/engine/tests/it/atn_ripple_amount.rs @@ -1,7 +1,7 @@ //! #7670: integer ATN Ripple amounts must never silently replace native fractions. +use craft_psd::{atn, descriptor::Value as D}; use photocraft_engine::actions_cmds::Action; use photocraft_engine::{Session, atn_cmds}; -use photocraft_psd::{atn, descriptor::Value as D}; use serde_json::{Value, json}; const RIPPLE: &str = "filter.distort.ripple"; diff --git a/crates/engine/tests/it/atn_shape_blur.rs b/crates/engine/tests/it/atn_shape_blur.rs index df9a5b43eb..1dc3f05a5d 100644 --- a/crates/engine/tests/it/atn_shape_blur.rs +++ b/crates/engine/tests/it/atn_shape_blur.rs @@ -1,10 +1,10 @@ +use craft_psd::atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}; +use craft_psd::descriptor::{Descriptor, Id, UnicodeString, Value as D}; use photocraft_doc::LayerContent; use photocraft_engine::{Session, atn_cmds}; -use photocraft_psd::atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}; -use photocraft_psd::descriptor::{Descriptor, Id, UnicodeString, Value as D}; use serde_json::json; -#[path = "../../../psd/tests/common/csh_fixture.rs"] +#[path = "../common/csh_fixture.rs"] mod csh_fixture; const SHAPE_ID: &str = "original-fixture-square"; @@ -253,7 +253,7 @@ fn custom_shape_smart_filter_keeps_geometry_across_save_without_preset_library() fn imported_csh_id_resolves_in_an_action_after_reimport_and_preference_reload() { let mut s = fixture(16); let bytes = csh_fixture::triangle_csh(); - let id = photocraft_psd::csh::parse(&bytes).unwrap()[0].id.clone(); + let id = craft_psd::csh::parse(&bytes).unwrap()[0].id.clone(); let p = json!({"data":photocraft_paint::tile::b64_encode(&bytes)}); s.execute("shape.presets.importCsh", p.clone()).unwrap(); s.execute("shape.presets.importCsh", p).unwrap(); @@ -285,7 +285,7 @@ fn imported_csh_id_resolves_in_an_action_after_reimport_and_preference_reload() // This original fixture has the same ID but a different path. Keep both presets // available by their unique names, while refusing ambiguous action playback. let other = csh_fixture::polygon_csh(&[&[(0.25, 0.25), (0.75, 0.25), (0.75, 0.75), (0.25, 0.75)]]); - assert_eq!(photocraft_psd::csh::parse(&other).unwrap()[0].id, id); + assert_eq!(craft_psd::csh::parse(&other).unwrap()[0].id, id); s.execute("shape.presets.importCsh", json!({"data":photocraft_paint::tile::b64_encode(&other)})).unwrap(); let result = s.execute("actions.play", json!({"action":"Shape"})).unwrap(); assert_eq!(result["ran"], 0); diff --git a/crates/engine/tests/it/atn_shear.rs b/crates/engine/tests/it/atn_shear.rs index fb6e605d4d..30a0f9630a 100644 --- a/crates/engine/tests/it/atn_shear.rs +++ b/crates/engine/tests/it/atn_shear.rs @@ -1,11 +1,11 @@ //! Public load/save/playback witnesses for the measured Shear action mapping. +use craft_psd::atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}; +use craft_psd::descriptor::{Descriptor, Id, UnicodeString, Value as D}; use photocraft_engine::{ Session, actions_cmds::Action, atn_cmds::{Imported, export, import}, }; -use photocraft_psd::atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}; -use photocraft_psd::descriptor::{Descriptor, Id, UnicodeString, Value as D}; use serde_json::{Value, json}; fn desc(class: &str) -> Descriptor { diff --git a/crates/engine/tests/it/atn_stack_modes.rs b/crates/engine/tests/it/atn_stack_modes.rs index 2de2d887df..677cd07515 100644 --- a/crates/engine/tests/it/atn_stack_modes.rs +++ b/crates/engine/tests/it/atn_stack_modes.rs @@ -1,12 +1,12 @@ //! The measured Smart Object Stack Mode events retain their descriptor and native behavior. -use photocraft_doc::{Group, Layer, LayerContent, StackMode}; -use photocraft_engine::{Session, actions_cmds::Action, atn_cmds}; -use photocraft_geom::Rect; -use photocraft_psd::{ +use craft_psd::{ atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}, descriptor::{Class, Descriptor, Id, UnicodeString, Value as D}, }; +use photocraft_doc::{Group, Layer, LayerContent, StackMode}; +use photocraft_engine::{Session, actions_cmds::Action, atn_cmds}; +use photocraft_geom::Rect; use serde_json::json; const APPLY: &str = "applyImageStackPluginRenderer"; diff --git a/crates/engine/tests/it/atn_stroke_width.rs b/crates/engine/tests/it/atn_stroke_width.rs index 3034fb2f0a..6d4a058f8f 100644 --- a/crates/engine/tests/it/atn_stroke_width.rs +++ b/crates/engine/tests/it/atn_stroke_width.rs @@ -1,7 +1,7 @@ //! #7671: exported Stroke widths must have the same effect as command execution. +use craft_psd::{atn, descriptor::Value as D}; use photocraft_engine::actions_cmds::Action; use photocraft_engine::{Session, atn_cmds}; -use photocraft_psd::{atn, descriptor::Value as D}; use serde_json::{Value, json}; /// Synthetic colours and partial transparency make lost edge pixels observable at every depth. diff --git a/crates/engine/tests/it/atn_type_path.rs b/crates/engine/tests/it/atn_type_path.rs index f5b565b849..3b37deece0 100644 --- a/crates/engine/tests/it/atn_type_path.rs +++ b/crates/engine/tests/it/atn_type_path.rs @@ -1,14 +1,14 @@ //! Recorded Type > Create Work Path preserves its descriptor and uses native glyph outlines. +use craft_psd::{ + atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}, + descriptor::{Class, Descriptor, Id, ReferenceItem, UnicodeString, Value as D}, +}; use photocraft_doc::{LayerContent, LayerId}; use photocraft_engine::{ Session, actions_cmds::Action, atn_cmds::{export, import}, }; -use photocraft_psd::{ - atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}, - descriptor::{Class, Descriptor, Id, ReferenceItem, UnicodeString, Value as D}, -}; use serde_json::{Value, json}; const COMMAND: &str = "type.createWorkPath"; diff --git a/crates/engine/tests/it/atn_v12.rs b/crates/engine/tests/it/atn_v12.rs index d5bc6d8263..353201c343 100644 --- a/crates/engine/tests/it/atn_v12.rs +++ b/crates/engine/tests/it/atn_v12.rs @@ -1,11 +1,11 @@ +use craft_psd::atn; use photocraft_engine::{Session, paint::tile::b64_encode}; -use photocraft_psd::atn; use serde_json::json; /// Legacy descriptor syntax must reach the same commands and parameters as version 16. #[test] fn legacy_v12_commands_import_like_v16() { - let bytes = include_bytes!("../../../psd/tests/fixtures/atn/v12-commands.atn"); + let bytes = include_bytes!("../fixtures/atn/v12-commands.atn"); let modern = atn::write(&atn::parse(bytes).unwrap()).unwrap(); let expected = vec![ ("filter.blur.gaussianBlur".into(), json!({"radius": 2.5})), diff --git a/crates/engine/tests/it/atn_video_frames.rs b/crates/engine/tests/it/atn_video_frames.rs index 43c9261ae1..64073763c9 100644 --- a/crates/engine/tests/it/atn_video_frames.rs +++ b/crates/engine/tests/it/atn_video_frames.rs @@ -1,10 +1,10 @@ //! #10723: recorded frame edits use the active video layer and the current playhead. -use photocraft_doc::{LayerContent, LayerId}; -use photocraft_engine::{Session, actions_cmds::Action, atn_cmds}; -use photocraft_psd::{ +use craft_psd::{ atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}, descriptor::{Descriptor, UnicodeString, Value as D}, }; +use photocraft_doc::{LayerContent, LayerId}; +use photocraft_engine::{Session, actions_cmds::Action, atn_cmds}; use serde_json::json; use std::sync::Arc; diff --git a/crates/engine/tests/it/crop_delete_off_background.rs b/crates/engine/tests/it/crop_delete_off_background.rs index 96350d4a1c..5a0c4340ad 100644 --- a/crates/engine/tests/it/crop_delete_off_background.rs +++ b/crates/engine/tests/it/crop_delete_off_background.rs @@ -2,10 +2,10 @@ //! Photoshop does: it keeps the pixels past the canvas, and an extension is transparent on every //! layer. Delete on keeps the Background, and Image › Reveal All is unchanged (#10867). +use craft_psd::{atn, descriptor::Value as D}; use photocraft_engine::actions_cmds::Action; use photocraft_engine::{EngineError, Session, atn_cmds}; use photocraft_geom::Rect; -use photocraft_psd::{atn, descriptor::Value as D}; use serde_json::{Value, json}; const MAGENTA: [f32; 4] = [1.0, 0.0, 1.0, 1.0]; diff --git a/crates/engine/tests/it/photoshop_oracles.rs b/crates/engine/tests/it/photoshop_oracles.rs index 435bab8cc1..61b82d9cab 100644 --- a/crates/engine/tests/it/photoshop_oracles.rs +++ b/crates/engine/tests/it/photoshop_oracles.rs @@ -15,8 +15,8 @@ use std::path::{Path, PathBuf}; +use craft_psd::PsdFile; use photocraft_doc::{Document, LayerContent}; -use photocraft_psd::PsdFile; /// Within two 8-bit steps of Photoshop (same tolerance as the io corpus). const PASS_TOL: f32 = 2.0 / 255.0; diff --git a/crates/engine/tests/it/rasterize_psd_reopen.rs b/crates/engine/tests/it/rasterize_psd_reopen.rs index a39005ac14..981fd5895c 100644 --- a/crates/engine/tests/it/rasterize_psd_reopen.rs +++ b/crates/engine/tests/it/rasterize_psd_reopen.rs @@ -33,7 +33,7 @@ fn reopen(doc: &Document) -> Document { /// Whether the PSD written for `doc` gives the layer `name` any of `keys`. fn has_block(doc: &Document, name: &str, keys: &[&[u8; 4]]) -> bool { let out = photocraft_io::export(doc, "psd", &Default::default()).unwrap(); - let f = photocraft_psd::PsdFile::from_bytes(&out.bytes).unwrap(); + let f = craft_psd::PsdFile::from_bytes(&out.bytes).unwrap(); let rec = f.layers().iter().find(|r| r.name() == name).unwrap(); keys.iter().any(|k| rec.block(k).is_some()) } diff --git a/crates/engine/tests/it/smart_duplicates.rs b/crates/engine/tests/it/smart_duplicates.rs index 554fd23f0f..acbf6c8979 100644 --- a/crates/engine/tests/it/smart_duplicates.rs +++ b/crates/engine/tests/it/smart_duplicates.rs @@ -261,7 +261,7 @@ fn inspect_and_replace_contents_report_shared_instances() { fn photoshop_placed_copies_import_as_shared_instances() { let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/photoshop/smart-filters/sf-none-plain.psd"); let bytes = std::fs::read(&path).unwrap_or_else(|e| panic!("{}: {e} (run `cargo xtask corpus --all`)", path.display())); - let mut file = photocraft_psd::PsdFile::from_bytes(&bytes).unwrap(); + let mut file = craft_psd::PsdFile::from_bytes(&bytes).unwrap(); let placed = file.layers().iter().find(|r| r.block(b"SoLd").is_some()).expect("a placed layer").clone(); file.layers_mut().push(placed); let mut s = Session::new(); diff --git a/crates/engine/tests/it/smart_psd.rs b/crates/engine/tests/it/smart_psd.rs index 542220f34b..429a487505 100644 --- a/crates/engine/tests/it/smart_psd.rs +++ b/crates/engine/tests/it/smart_psd.rs @@ -52,7 +52,7 @@ fn max_diff(a: &[[f32; 4]], b: &[[f32; 4]]) -> f32 { /// Every block with inner structure re-parses strictly (`TaggedBlock::check_structure`). fn structure_errors(bytes: &[u8]) -> Vec { - let f = photocraft_psd::PsdFile::from_bytes(bytes).unwrap(); + let f = craft_psd::PsdFile::from_bytes(bytes).unwrap(); let mut errs = Vec::new(); for b in &f.global_blocks { if let Err(e) = b.check_structure() { @@ -229,9 +229,9 @@ fn camera_raw_psd_fixture() { .smart_filters .iter() .position(|f| { - photocraft_io::smart_map::item_for_filter(f).ok().is_some_and( - |item| matches!(item.get("Fltr"), Some(photocraft_psd::descriptor::Value::Descriptor(d)) if d.class_id.is("Adobe Camera Raw Filter")), - ) + photocraft_io::smart_map::item_for_filter(f) + .ok() + .is_some_and(|item| matches!(item.get("Fltr"), Some(craft_psd::descriptor::Value::Descriptor(d)) if d.class_id.is("Adobe Camera Raw Filter"))) }) .expect("a Camera Raw filter"); let original_item = photocraft_io::smart_map::item_for_filter(&sm.smart_filters[index]).unwrap(); @@ -261,11 +261,11 @@ fn camera_raw_psd_fixture() { assert_eq!(after.params["exposure"], 0.5); let mut expected = original_item; let mut fltr = match expected.get("Fltr").unwrap() { - photocraft_psd::descriptor::Value::Descriptor(d) => d.clone(), + craft_psd::descriptor::Value::Descriptor(d) => d.clone(), _ => panic!("Fltr must be a descriptor"), }; - fltr.items.iter_mut().find(|(key, _)| key.is("Ex12")).unwrap().1 = photocraft_psd::descriptor::Value::Double(0.5); - expected.items.iter_mut().find(|(key, _)| key.is("Fltr")).unwrap().1 = photocraft_psd::descriptor::Value::Descriptor(fltr); + fltr.items.iter_mut().find(|(key, _)| key.is("Ex12")).unwrap().1 = craft_psd::descriptor::Value::Double(0.5); + expected.items.iter_mut().find(|(key, _)| key.is("Fltr")).unwrap().1 = craft_psd::descriptor::Value::Descriptor(fltr); assert_eq!(photocraft_io::smart_map::item_for_filter(after).unwrap(), expected, "only Exposure changes"); } @@ -291,8 +291,8 @@ fn smart_filters_survive_psd_in_every_colour_model() { assert_eq!(b.smart_filters.len(), 1, "{mode} {depth}"); assert_eq!(b.smart_filters[0].command, a.smart_filters[0].command); // The cache's unfiltered planes decode back to the unfiltered rendering. - let f = photocraft_psd::PsdFile::from_bytes(&out.bytes).unwrap(); - let fx = f.global_blocks.iter().find(|g| &g.key == b"FEid").map(|g| photocraft_psd::filter_effects::FilterEffects::parse(&g.data).unwrap()).unwrap(); + let f = craft_psd::PsdFile::from_bytes(&out.bytes).unwrap(); + let fx = f.global_blocks.iter().find(|g| &g.key == b"FEid").map(|g| craft_psd::filter_effects::FilterEffects::parse(&g.data).unwrap()).unwrap(); let item = &fx.items[0]; let (w, h) = item.rect.size().unwrap(); let bits = u16::try_from(item.depth).unwrap(); @@ -396,7 +396,7 @@ fn edited_psd_smart_objects_drop_their_old_file() { let parent = s2.documents().iter().position(|d| d.doc.layer(photocraft_doc::LayerId(id)).is_some()).unwrap(); let doc2 = s2.documents()[parent].doc.clone(); let second = photocraft_io::export(&doc2, "psd", &Default::default()).unwrap().bytes; - let f = photocraft_psd::PsdFile::from_bytes(&second).unwrap(); + let f = craft_psd::PsdFile::from_bytes(&second).unwrap(); let uuids: Vec = f.global_blocks.iter().filter(|b| &b.key == b"lnk2").flat_map(|b| photocraft_io::linked::block_uuids(&b.data)).collect(); let back = photocraft_io::import("b.psd", &second).unwrap().document; let (_, sm) = smart(&back); @@ -409,7 +409,7 @@ fn edited_psd_smart_objects_drop_their_old_file() { /// through, and keeps that map through PSD export, `.pcraft` and a PSD re-import. #[test] fn displace_psd_filter_renders_with_its_embedded_map() { - use photocraft_psd::descriptor::{Descriptor, Id, UnicodeString, Value}; + use craft_psd::descriptor::{Descriptor, Id, UnicodeString, Value}; for depth in [8, 16, 32] { let mut s = session(depth); s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); @@ -474,7 +474,7 @@ fn displace_psd_filter_renders_with_its_embedded_map() { /// that file and the replaced contents come out displaced. #[test] fn displace_psd_filter_with_only_a_map_path_renders_after_replace_contents() { - use photocraft_psd::descriptor::{Descriptor, Id, UnicodeString, Value}; + use craft_psd::descriptor::{Descriptor, Id, UnicodeString, Value}; let dir = std::env::temp_dir().join(format!("pcraft-9162-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let png = |name: &str, size: (u32, u32), c: [f32; 4]| { @@ -543,7 +543,7 @@ fn displace_psd_filter_with_only_a_map_path_renders_after_replace_contents() { /// its planes through PSD export, `.pcraft` and a PSD re-import. #[test] fn perspective_warp_psd_filter_renders_warped() { - use photocraft_psd::descriptor::{Descriptor, UnicodeString, Value}; + use craft_psd::descriptor::{Descriptor, UnicodeString, Value}; let pnts = |p: &[[f64; 2]]| { let px = |v: f64| Value::UnitFloat { unit: *b"#Pxl", value: v }; Value::List(p.iter().map(|q| Value::Descriptor(Descriptor::new("Pnt ").with("Hrzn", px(q[0])).with("Vrtc", px(q[1])))).collect()) @@ -623,7 +623,7 @@ fn lifd_v2(f: &photocraft_io::linked::LinkedFile) -> Vec { /// version-2 items. fn cs2_layout(doc: &Document) -> Vec { let out = photocraft_io::export(doc, "psd", &Default::default()).unwrap(); - let mut file = photocraft_psd::PsdFile::from_bytes(&out.bytes).unwrap(); + let mut file = craft_psd::PsdFile::from_bytes(&out.bytes).unwrap(); for rec in file.layers_mut() { rec.blocks.retain(|b| !matches!(&b.key, b"SoLd" | b"SoLE")); } @@ -717,7 +717,7 @@ fn cs2_plld_only_smart_object_saves_its_new_transform() { s.execute("edit.transform", json!({"layer": id, "matrix": [0.75, 0, 0, 0.75, 10, 4]})).unwrap(); let cs2 = cs2_layout(&s.active().unwrap().doc); let plld = |bytes: &[u8]| { - let f = photocraft_psd::PsdFile::from_bytes(bytes).unwrap(); + let f = craft_psd::PsdFile::from_bytes(bytes).unwrap(); let rec = f.layers().iter().find(|l| l.block(b"PlLd").is_some()).cloned().expect("a placed layer"); (rec.block(b"PlLd").unwrap().data.clone(), rec.block(b"SoLd").is_some()) }; @@ -779,7 +779,7 @@ fn a_soft_filter_mask_is_stored_the_way_photoshop_stores_it() { let filtered = photocraft_engine::smart_cmds::render(&doc, &unmasked).unwrap().unwrap(); let out = photocraft_io::export(&doc, "psd", &Default::default()).unwrap(); - let file = photocraft_psd::PsdFile::from_bytes(&out.bytes).unwrap(); + let file = craft_psd::PsdFile::from_bytes(&out.bytes).unwrap(); let name = doc.layer(id).unwrap().name.clone(); let record = (0..file.layers().len()).filter_map(|i| file.layer(i)).find(|l| l.name() == name).unwrap(); let img = record.rgba8().unwrap(); diff --git a/crates/engine/tests/memory_gate.rs b/crates/engine/tests/memory_gate.rs index a740012c76..9f6228e4f7 100644 --- a/crates/engine/tests/memory_gate.rs +++ b/crates/engine/tests/memory_gate.rs @@ -116,7 +116,7 @@ fn smart_object_render_that_does_not_fit_is_refused_by_name() { #[test] fn layered_psd_is_refused_before_decode_without_changing_the_document() { - use photocraft_psd::{Compression, LayerSpec, PixelData, PsdBuilder}; + use craft_psd::{Compression, LayerSpec, PixelData, PsdBuilder}; let room = Room::new(None); const SIDE: u32 = 256; @@ -182,7 +182,7 @@ fn small_flat_psd_still_places_and_renders() { /// memory is there. #[test] fn a_psd_with_image_resources_places_and_renders_when_it_fits() { - use photocraft_psd::{LayerSpec, PixelData, PsdBuilder}; + use craft_psd::{LayerSpec, PixelData, PsdBuilder}; let room = Room::new(Some(headroom::RESERVE + 256 * MIB)); const SIDE: u32 = 64; diff --git a/crates/engine/tests/paste_style_options.rs b/crates/engine/tests/paste_style_options.rs index af0f131dd8..fe2f599ca9 100644 --- a/crates/engine/tests/paste_style_options.rs +++ b/crates/engine/tests/paste_style_options.rs @@ -1,13 +1,13 @@ //! Paste Layer Style applies group eligibility consistently and preserves the ATN flag. +use craft_psd::{ + atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}, + descriptor::{Class, Descriptor, Id, UnicodeString, Value as D}, +}; use photocraft_color::{BlendMode, Color, ColorMode, SampleType}; use photocraft_doc::{BlendRange, Document, Effect, Layer, LayerId}; use photocraft_engine::{Session, actions_cmds::Action, atn_cmds, layer_multi_cmds}; use photocraft_geom::Size; -use photocraft_psd::{ - atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}, - descriptor::{Class, Descriptor, Id, UnicodeString, Value as D}, -}; use serde_json::{Value, json}; const CMD: &str = "layer.layerStyle.pasteLayerStyle"; diff --git a/crates/engine/tests/vector_paste_options.rs b/crates/engine/tests/vector_paste_options.rs index 0482a5ab99..3a77938342 100644 --- a/crates/engine/tests/vector_paste_options.rs +++ b/crates/engine/tests/vector_paste_options.rs @@ -18,14 +18,14 @@ fn raster_clip_cannot_silently_paste_as_path() { use std::sync::Arc; +use craft_psd::{ + atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}, + descriptor::{Class, Descriptor, Id, UnicodeString, Value as D}, +}; use photocraft_color::{ColorMode, SampleType}; use photocraft_doc::{Fill, Layer, LayerContent, Path, Subpath}; use photocraft_engine::{actions_cmds::Action, atn_cmds, edit_cmds::Clip}; use photocraft_geom::{Point, Size}; -use photocraft_psd::{ - atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}, - descriptor::{Class, Descriptor, Id, UnicodeString, Value as D}, -}; use serde_json::Value; const SVG: &[u8] = br##""##; diff --git a/crates/io/Cargo.toml b/crates/io/Cargo.toml index 2090bdb00f..a23ef2b2b3 100644 --- a/crates/io/Cargo.toml +++ b/crates/io/Cargo.toml @@ -28,7 +28,7 @@ photocraft-raster = { workspace = true } photocraft-doc = { workspace = true } photocraft-compose = { workspace = true } photocraft-algo = { workspace = true } -photocraft-psd = { workspace = true } +craft-psd = { workspace = true } photocraft-codecs = { workspace = true } photocraft-raw = { workspace = true } photocraft-affinity = { workspace = true } @@ -69,7 +69,7 @@ miniz_oxide = "0.9" crc32fast = "1.5" photocraft-affinity = { workspace = true, features = ["synth"] } flate2 = { workspace = true } -photocraft-psd = { workspace = true, features = ["testgen"] } +craft-psd = { workspace = true, features = ["testgen"] } photocraft-raw = { workspace = true, features = ["testgen"] } photocraft-testkit = { workspace = true } proptest = { workspace = true } diff --git a/crates/io/examples/bench_abr.rs b/crates/io/examples/bench_abr.rs index 17d4740962..4db94562ee 100644 --- a/crates/io/examples/bench_abr.rs +++ b/crates/io/examples/bench_abr.rs @@ -7,10 +7,10 @@ use std::time::Instant; +use craft_psd::abr::{AbrSample, write_v6}; +use craft_psd::descriptor::{Descriptor, UnicodeString, Value}; use photocraft_color::PixelFormat; use photocraft_paint::{StrokePoint, render_stroke}; -use photocraft_psd::abr::{AbrSample, write_v6}; -use photocraft_psd::descriptor::{Descriptor, UnicodeString, Value}; use photocraft_raster::Surface; fn prc(v: f64) -> Value { diff --git a/crates/io/examples/big_psb_check.rs b/crates/io/examples/big_psb_check.rs index 3b1c21034b..a7c62a9a5e 100644 --- a/crates/io/examples/big_psb_check.rs +++ b/crates/io/examples/big_psb_check.rs @@ -1,10 +1,10 @@ -//! Opens the files written by `photocraft-psd`'s `big_psb` example and checks their pixels. +//! Opens the files written by `craft-psd`'s `big_psb` example and checks their pixels. //! //! `cargo run --release -p photocraft-io --example big_psb_check -- ...` //! //! The opt-in tests in `tests/it/large_files.rs` generate and check the same files. -use photocraft_psd::fixtures::sample; +use craft_psd::fixtures::sample; fn main() { for path in std::env::args().skip(1) { diff --git a/crates/io/examples/gen_abr_library.rs b/crates/io/examples/gen_abr_library.rs index c19e4a85d8..ce3c8d2e76 100644 --- a/crates/io/examples/gen_abr_library.rs +++ b/crates/io/examples/gen_abr_library.rs @@ -13,10 +13,10 @@ //! `--raw` store tips uncompressed (bigger files). Each file stays under the reader's decoded cap (1 GiB; 512 MiB on 32-bit). #![allow(clippy::unwrap_used, clippy::expect_used)] +use craft_psd::abr::{AbrSample, MAX_TOTAL_BYTES, write_v6}; +use craft_psd::descriptor::{Descriptor, UnicodeString, Value}; use photocraft_paint::procedural::{bristle_tip, chalk_tip, charcoal_tip, grass_tip, leaf_tip, rake_tip, spatter_tip, sponge_tip, star_tip}; use photocraft_paint::tile::GrayTile; -use photocraft_psd::abr::{AbrSample, MAX_TOTAL_BYTES, write_v6}; -use photocraft_psd::descriptor::{Descriptor, UnicodeString, Value}; use std::path::PathBuf; use std::time::Instant; @@ -90,7 +90,7 @@ fn main() { let num = |name: &str, default: u32| arg(name).map_or(default, |v| v.parse().expect("numeric option")); let out = PathBuf::from(arg("--out").unwrap_or_else(|| "target/abr-library".into())); let brushes = num("--brushes", 600); - let max_edge = num("--edge", 2000).clamp(16, photocraft_psd::abr::MAX_EDGE); + let max_edge = num("--edge", 2000).clamp(16, craft_psd::abr::MAX_EDGE); let min_edge = num("--min-edge", 800).clamp(16, max_edge); let depth = num("--depth", 16) as u16; assert!(matches!(depth, 8 | 16), "--depth must be 8 or 16"); diff --git a/crates/io/examples/oracle_diff.rs b/crates/io/examples/oracle_diff.rs index 13977e1a61..1da97fefa1 100644 --- a/crates/io/examples/oracle_diff.rs +++ b/crates/io/examples/oracle_diff.rs @@ -27,7 +27,7 @@ fn main() { fn run_file(path: &str) { let bytes = std::fs::read(path).unwrap(); - let file = photocraft_psd::PsdFile::from_bytes(&bytes).unwrap(); + let file = craft_psd::PsdFile::from_bytes(&bytes).unwrap(); let mut imp = photocraft_io::import(path, &bytes).unwrap(); configure_visibility(&mut imp.document.layers); let doc = &imp.document; @@ -360,15 +360,15 @@ fn print_layer_details(doc: &photocraft_doc::Document) { for (k, v) in &l.psd_blocks { if k == b"lfx2" || k == b"lmfx" { // Recursively list descriptor keys (effects), skipping colour stop lists. - fn walk(d: &photocraft_psd::descriptor::Descriptor, depth: usize, out: &mut Vec) { + fn walk(d: &craft_psd::descriptor::Descriptor, depth: usize, out: &mut Vec) { for (k, v) in &d.items { let key = match k { - photocraft_psd::descriptor::Id::Code(c) => String::from_utf8_lossy(c).to_string(), + craft_psd::descriptor::Id::Code(c) => String::from_utf8_lossy(c).to_string(), other => String::from_utf8_lossy(other.as_bytes()).to_string(), }; let vs = format!("{v:?}"); match v { - photocraft_psd::descriptor::Value::Descriptor(sub) => { + craft_psd::descriptor::Value::Descriptor(sub) => { out.push(format!("{}{key}:", " ".repeat(depth))); walk(sub, depth + 1, out); } @@ -376,7 +376,7 @@ fn print_layer_details(doc: &photocraft_doc::Document) { } } } - if let Ok((vd, _)) = photocraft_psd::descriptor::VersionedDescriptor::parse_prefix(&v[4..]) { + if let Ok((vd, _)) = craft_psd::descriptor::VersionedDescriptor::parse_prefix(&v[4..]) { let mut out = Vec::new(); walk(&vd.descriptor, 3, &mut out); for l in out.iter().filter(|l| !l.trim_start().starts_with("Clrs") && !l.trim_start().starts_with("Trns")) { @@ -385,13 +385,13 @@ fn print_layer_details(doc: &photocraft_doc::Document) { } } if k == b"TySh" - && let Some(Ok((vd, _))) = v.get(52..).map(photocraft_psd::descriptor::VersionedDescriptor::parse_prefix) + && let Some(Ok((vd, _))) = v.get(52..).map(craft_psd::descriptor::VersionedDescriptor::parse_prefix) { // Text bounds (text space) and the transform. let t: Vec = (0..6).map(|i| f64::from_be_bytes(v[2 + i * 8..10 + i * 8].try_into().unwrap())).collect(); println!(" TySh transform {t:?}"); for key in ["bounds", "boundingBox"] { - if let Some(photocraft_psd::descriptor::Value::Descriptor(d)) = vd.descriptor.get(key) { + if let Some(craft_psd::descriptor::Value::Descriptor(d)) = vd.descriptor.get(key) { let n: Vec = d.items.iter().map(|(k, v)| format!("{}={v:?}", String::from_utf8_lossy(k.as_bytes()))).collect(); println!(" TySh {key}: {}", n.join(" ")); } @@ -399,7 +399,7 @@ fn print_layer_details(doc: &photocraft_doc::Document) { } if k == b"GdFl" { // Skip the 4-byte version before the descriptor. - if let Ok((vd, _)) = photocraft_psd::descriptor::VersionedDescriptor::parse_prefix(v) { + if let Ok((vd, _)) = craft_psd::descriptor::VersionedDescriptor::parse_prefix(v) { println!( " GdFl keys: {:?}", vd.descriptor @@ -409,7 +409,7 @@ fn print_layer_details(doc: &photocraft_doc::Document) { .map(|(k, v)| format!( "{:?}={}", match k { - photocraft_psd::descriptor::Id::Code(c) => String::from_utf8_lossy(c).to_string(), + craft_psd::descriptor::Id::Code(c) => String::from_utf8_lossy(c).to_string(), other => format!("{other:?}"), }, format!("{v:?}").chars().take(120).collect::() @@ -422,24 +422,24 @@ fn print_layer_details(doc: &photocraft_doc::Document) { } } -fn dump_filter_effects(file: &photocraft_psd::PsdFile) { +fn dump_filter_effects(file: &craft_psd::PsdFile) { if std::env::var_os("DUMP_FX").is_some() { - fn walk(d: &photocraft_psd::descriptor::Descriptor, depth: usize) { + fn walk(d: &craft_psd::descriptor::Descriptor, depth: usize) { for (k, v) in &d.items { let key = String::from_utf8_lossy(k.as_bytes()).to_string(); if key == "Clrs" || key == "Trns" { continue; } match v { - photocraft_psd::descriptor::Value::Descriptor(sub) => { + craft_psd::descriptor::Value::Descriptor(sub) => { println!("{}{key}:", " ".repeat(depth)); walk(sub, depth + 1); } - photocraft_psd::descriptor::Value::Text(t) => println!("{}{key} = {:?}", " ".repeat(depth), t.to_string_lossy()), - photocraft_psd::descriptor::Value::List(items) if items.iter().all(|i| matches!(i, photocraft_psd::descriptor::Value::Descriptor(_))) => { + craft_psd::descriptor::Value::Text(t) => println!("{}{key} = {:?}", " ".repeat(depth), t.to_string_lossy()), + craft_psd::descriptor::Value::List(items) if items.iter().all(|i| matches!(i, craft_psd::descriptor::Value::Descriptor(_))) => { println!("{}{key} = [{} items]", " ".repeat(depth), items.len()); for (i, it) in items.iter().enumerate() { - if let photocraft_psd::descriptor::Value::Descriptor(sub) = it { + if let craft_psd::descriptor::Value::Descriptor(sub) = it { println!("{}[{i}]:", " ".repeat(depth + 1)); walk(sub, depth + 2); } @@ -452,7 +452,7 @@ fn dump_filter_effects(file: &photocraft_psd::PsdFile) { for rec in file.layers() { println!("-- layer {:?} rect {:?}", String::from_utf8_lossy(&rec.name), rec.rect); if let Some(b) = rec.block(b"lmfx").or(rec.block(b"lfx2")).or(rec.block(b"lfxs")) - && let Ok((vd, _)) = photocraft_psd::descriptor::VersionedDescriptor::parse_prefix(&b.data[4..]) + && let Ok((vd, _)) = craft_psd::descriptor::VersionedDescriptor::parse_prefix(&b.data[4..]) { walk(&vd.descriptor, 1); } @@ -481,7 +481,7 @@ fn corpus_summary(root: &std::path::Path) { let row = |p: &std::path::PathBuf| -> Row { let name = p.strip_prefix(root).unwrap_or(p).display().to_string(); let Ok(bytes) = std::fs::read(p) else { return (name, None) }; - let Ok(file) = photocraft_psd::PsdFile::from_bytes(&bytes) else { return (name, None) }; + let Ok(file) = craft_psd::PsdFile::from_bytes(&bytes) else { return (name, None) }; let Ok(imp) = photocraft_io::import(&name, &bytes) else { return (name, None) }; if file.has_real_merged_data() == Some(false) || file.layers().is_empty() { return (name, None); diff --git a/crates/io/src/abr_map.rs b/crates/io/src/abr_map.rs index a41ba3ac01..9ca85b8830 100644 --- a/crates/io/src/abr_map.rs +++ b/crates/io/src/abr_map.rs @@ -1,6 +1,6 @@ //! Photoshop brushes (`.abr`) → brush presets. //! -//! [`photocraft_psd::abr`] reads the file; this module maps its tips and `brushPreset` +//! [`craft_psd::abr`] reads the file; this module maps its tips and `brushPreset` //! descriptors onto [`BrushSettings`] (Brush Tip Shape, Shape Dynamics, Scattering, Texture, //! Dual Brush, Color Dynamics, Transfer, Brush Pose, Noise, Wet Edges, Build-up, Smoothing and //! the captured tool options). Settings without an equivalent are listed in @@ -9,12 +9,12 @@ use std::collections::BTreeSet; +use craft_psd::abr::{self, AbrFile, AbrSample, LegacyTip}; +use craft_psd::descriptor::{Descriptor, Value}; +use craft_psd::patterns::PsdPattern; use photocraft_paint::{ BrushPreset, BrushSettings, Control, DualBrush, Dynamic, GrayTile, MaskMode, MixerSettings, Pattern, PatternStyle, Pose, ShapeDynamics, TipShape, Transfer, }; -use photocraft_psd::abr::{self, AbrFile, AbrSample, LegacyTip}; -use photocraft_psd::descriptor::{Descriptor, Value}; -use photocraft_psd::patterns::PsdPattern; /// Largest sampled tip edge kept as is; bigger tips are downsampled to it. pub const MAX_TIP_EDGE: u32 = 2500; diff --git a/crates/io/src/abr_map/tests.rs b/crates/io/src/abr_map/tests.rs index 26f68d7aa0..930af83fa6 100644 --- a/crates/io/src/abr_map/tests.rs +++ b/crates/io/src/abr_map/tests.rs @@ -1,6 +1,6 @@ use super::*; -use photocraft_psd::abr::{LegacyBrush, write_v6, write_v6_folders, write_v12}; -use photocraft_psd::descriptor::{Id, UnicodeString}; +use craft_psd::abr::{LegacyBrush, write_v6, write_v6_folders, write_v12}; +use craft_psd::descriptor::{Id, UnicodeString}; fn sample(id: &str, w: u32, h: u32, depth: u16) -> AbrSample { let bpp = usize::from(depth / 8); diff --git a/crates/io/src/adjust_map.rs b/crates/io/src/adjust_map.rs index f302fac30d..584deb3ff3 100644 --- a/crates/io/src/adjust_map.rs +++ b/crates/io/src/adjust_map.rs @@ -10,9 +10,9 @@ //! that cannot be read (noise gradients, profile lookups, CMYK channel mixers, unknown versions) //! stay [`Adjustment::Unsupported`] and are written back verbatim. +use craft_psd::descriptor::{Descriptor, Value, VersionedDescriptor}; use photocraft_doc::Adjustment; use photocraft_doc::adjust::{CURVE_MAP_LEN, CurvePoint, HueRange, LevelsChannel, LutFormat, LutSource, ToneSpace, curve_map}; -use photocraft_psd::descriptor::{Descriptor, Value, VersionedDescriptor}; mod encode; diff --git a/crates/io/src/adjust_map/encode.rs b/crates/io/src/adjust_map/encode.rs index 08d3075b57..3d7b660457 100644 --- a/crates/io/src/adjust_map/encode.rs +++ b/crates/io/src/adjust_map/encode.rs @@ -1,5 +1,5 @@ +use craft_psd::descriptor::{Descriptor, Id, UnicodeString, Value, VersionedDescriptor}; use photocraft_doc::adjust::{CurvePoint, HueRange, LevelsChannel, LutFormat, LutSource, ToneSpace, curve_map_outputs}; -use photocraft_psd::descriptor::{Descriptor, Id, UnicodeString, Value, VersionedDescriptor}; use super::{BW_KEYS, BW_TINT, clamp_i16, cube_file_name, levels_write, lut_source_for, psd_lut_format, put16, q255, rgb_desc}; diff --git a/crates/io/src/adjust_map/tests.rs b/crates/io/src/adjust_map/tests.rs index ce284bea36..9462bb6fc6 100644 --- a/crates/io/src/adjust_map/tests.rs +++ b/crates/io/src/adjust_map/tests.rs @@ -274,8 +274,8 @@ fn identity_look() -> Vec { /// A `clrL` block as Photoshop writes one for a loaded LUT file. fn clrl_block(format: &str, bytes: &[u8], name: &str) -> Vec { - let en = |t: &str, val: &str| Value::Enumerated { type_id: photocraft_psd::descriptor::Id::new(t), value: photocraft_psd::descriptor::Id::new(val) }; - let text = |t: &str| Value::Text(photocraft_psd::descriptor::UnicodeString::new_nul(t)); + let en = |t: &str, val: &str| Value::Enumerated { type_id: craft_psd::descriptor::Id::new(t), value: craft_psd::descriptor::Id::new(val) }; + let text = |t: &str| Value::Text(craft_psd::descriptor::UnicodeString::new_nul(t)); let desc = Descriptor::new("null") .with("lookupType", en("colorLookupType", "3DLUT")) .with("NM ", text(name)) @@ -355,10 +355,10 @@ fn color_lookup_table_without_matching_file_is_saved_as_cube_named_to_match() { #[test] fn color_lookup_synthetic_block() { let cube = b"TITLE \"t\"\nLUT_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"; - let en = |t: &str, val: &str| Value::Enumerated { type_id: photocraft_psd::descriptor::Id::new(t), value: photocraft_psd::descriptor::Id::new(val) }; + let en = |t: &str, val: &str| Value::Enumerated { type_id: craft_psd::descriptor::Id::new(t), value: craft_psd::descriptor::Id::new(val) }; let desc = Descriptor::new("null") .with("lookupType", en("colorLookupType", "3DLUT")) - .with("NM ", Value::Text(photocraft_psd::descriptor::UnicodeString::new_nul("Id"))) + .with("NM ", Value::Text(craft_psd::descriptor::UnicodeString::new_nul("Id"))) .with("Dthr", Value::Boolean(true)) .with("LUTFormat", en("LUTFormatType", "LUTFormatCUBE")) .with("LUT3DFileData", Value::RawData(cube.to_vec())); @@ -389,10 +389,10 @@ fn color_lookup_synthetic_block() { } fn cube_lookup_block(cube: &[u8]) -> Vec { - let en = |t: &str, val: &str| Value::Enumerated { type_id: photocraft_psd::descriptor::Id::new(t), value: photocraft_psd::descriptor::Id::new(val) }; + let en = |t: &str, val: &str| Value::Enumerated { type_id: craft_psd::descriptor::Id::new(t), value: craft_psd::descriptor::Id::new(val) }; let desc = Descriptor::new("null") .with("LUTFormat", en("LUTFormatType", "LUTFormatCUBE")) - .with("LUT3DFileName", Value::Text(photocraft_psd::descriptor::UnicodeString::new_nul("domain.cube"))) + .with("LUT3DFileName", Value::Text(craft_psd::descriptor::UnicodeString::new_nul("domain.cube"))) .with("LUT3DFileData", Value::RawData(cube.to_vec())); let mut block = vec![0, 1]; block.extend(VersionedDescriptor::new(desc).to_bytes()); diff --git a/crates/io/src/annotations_map.rs b/crates/io/src/annotations_map.rs index 9c41899170..9f95f6eb3b 100644 --- a/crates/io/src/annotations_map.rs +++ b/crates/io/src/annotations_map.rs @@ -18,9 +18,9 @@ use std::sync::Arc; +use craft_psd::ImageResource; +use craft_psd::descriptor::{Descriptor, UnicodeString, Value, VersionedDescriptor}; use photocraft_doc::{Color, CountGroup, Document, MeasurementScale, Note}; -use photocraft_psd::ImageResource; -use photocraft_psd::descriptor::{Descriptor, UnicodeString, Value, VersionedDescriptor}; pub const MEASUREMENT_SCALE: u16 = 1074; pub const COUNT_INFO: u16 = 1080; @@ -310,7 +310,7 @@ pub fn write_scale_resource(s: &MeasurementScale) -> Vec { let d = Descriptor::new("null") .with("pixelLength", Value::Double(s.pixel_length)) .with("logicalLength", Value::Double(s.logical_length)) - .with("logicalUnits", Value::Text(photocraft_psd::descriptor::UnicodeString::new(&s.units))); + .with("logicalUnits", Value::Text(craft_psd::descriptor::UnicodeString::new(&s.units))); let mut v = VersionedDescriptor::new(d).to_bytes(); if v.len() % 2 == 1 { v.push(0); diff --git a/crates/io/src/asl_map.rs b/crates/io/src/asl_map.rs index 9913406441..26fd4325d5 100644 --- a/crates/io/src/asl_map.rs +++ b/crates/io/src/asl_map.rs @@ -1,6 +1,6 @@ //! Layer style libraries (`.asl`) ⇄ PhotoCraft styles. //! -//! The container is parsed by [`photocraft_psd::asl`]. Each style's `Lefx` is the same effects +//! The container is parsed by [`craft_psd::asl`]. Each style's `Lefx` is the same effects //! tree as a layer's `lfx2` block ([`crate::effects_map`]); `blendOptions` ("Include Layer //! Blending Options") carries the blend mode, opacity and fill opacity; the file's patterns feed //! the styles' Pattern Overlays, pattern strokes and bevel textures. @@ -11,10 +11,10 @@ use std::collections::{BTreeSet, HashMap, HashSet}; +use craft_psd::asl::{self, AslFile}; +use craft_psd::descriptor::{Descriptor, Id, Value}; use photocraft_color::BlendMode; use photocraft_doc::{Effect, FxPaint, Pattern}; -use photocraft_psd::asl::{self, AslFile}; -use photocraft_psd::descriptor::{Descriptor, Id, Value}; use crate::blocks::{enum_of, get_desc, num}; use crate::effects_map::{blend_from_id, blend_value, effects_descriptor, effects_from_descriptor, is_known_effects_key}; @@ -208,9 +208,9 @@ pub fn write_asl(styles: &[AslStyle], patterns: &[Pattern]) -> Result, S #[cfg(test)] mod tests { use super::*; + use craft_psd::descriptor::UnicodeString; use photocraft_color::{Color, ColorMode, PixelFormat, SampleType}; use photocraft_doc::{BevelTexture, FxCommon, StrokePosition}; - use photocraft_psd::descriptor::UnicodeString; use photocraft_raster::Surface; fn pattern(id: &str, s: SampleType) -> Pattern { @@ -376,7 +376,7 @@ mod tests { let _ = read_asl(&c); } assert!(read_asl(b"").is_err()); - assert!(read_asl(&photocraft_psd::grd::write(&[])).is_err()); + assert!(read_asl(&craft_psd::grd::write(&[])).is_err()); } #[test] diff --git a/crates/io/src/blocks.rs b/crates/io/src/blocks.rs index c280747611..2f69330b60 100644 --- a/crates/io/src/blocks.rs +++ b/crates/io/src/blocks.rs @@ -1,11 +1,11 @@ //! Tagged-block helpers: the preserved block-list encoding, fills, text, //! smart objects, locks and label colors. +use craft_psd::descriptor::{Descriptor, Id, UnicodeString, Value, VersionedDescriptor}; +use craft_psd::layer::BlendingRanges; use photocraft_color::{Color, ColorMode}; use photocraft_doc::{AdvancedBlending, BlendIf, BlendRange, Fill, GradientStyle, Knockout, LabelColor, Locks}; use photocraft_geom::Affine; -use photocraft_psd::descriptor::{Descriptor, Id, UnicodeString, Value, VersionedDescriptor}; -use photocraft_psd::layer::BlendingRanges; /// `brst` (channel blending restrictions): a list of big-endian u32 channel indices left out of /// blending → a bit mask (bit `i` = channel `i`; indices above 31 are ignored). diff --git a/crates/io/src/blocks/more_tests.rs b/crates/io/src/blocks/more_tests.rs index 3c41296134..ec3ccd6867 100644 --- a/crates/io/src/blocks/more_tests.rs +++ b/crates/io/src/blocks/more_tests.rs @@ -183,7 +183,7 @@ fn plld_unreadable_warps() { /// `SoLd` data with a 50 × 25 source, the quad `trnf` and, when given, a custom warp whose /// 16 control points are `pts`. fn sold_with_warp(trnf: [f64; 8], pts: Option<&[[f64; 2]]>) -> Vec { - use photocraft_psd::descriptor::ObjectArray; + use craft_psd::descriptor::ObjectArray; let e = |t: &str, v: &str| Value::Enumerated { type_id: Id::new(t), value: Id::new(v) }; let mut d = Descriptor::new("null") .with("Idnt", Value::Text(UnicodeString::new_nul("uuid-1"))) @@ -214,7 +214,7 @@ fn sold_with_warp(trnf: [f64; 8], pts: Option<&[[f64; 2]]>) -> Vec { /// descriptor with `deformNumCols` × `deformNumRows` control points and the patch boundaries /// in `quiltSliceX` / `quiltSliceY`. With `plain_warp`, a `warpNone` `warp` key sits next to it. fn sold_with_quilt(trnf: [f64; 8], cols: f64, rows: f64, sx: &[f64], sy: &[f64], pts: &[[f64; 2]], plain_warp: bool) -> Vec { - use photocraft_psd::descriptor::ObjectArray; + use craft_psd::descriptor::ObjectArray; let e = |t: &str, v: &str| Value::Enumerated { type_id: Id::new(t), value: Id::new(v) }; let bounds = || { Descriptor::new("Rctn") diff --git a/crates/io/src/blocks/tests.rs b/crates/io/src/blocks/tests.rs index 59193a9344..45e9ec206b 100644 --- a/crates/io/src/blocks/tests.rs +++ b/crates/io/src/blocks/tests.rs @@ -64,9 +64,9 @@ fn gradient_fill_opacity_stops_become_alpha() { /// `gs99`, which Photoshop doesn't write, so every gradient opened as Classic. #[test] fn gradient_fill_reads_the_interpolation_method() { + use craft_psd::descriptor::{Id, Value}; use photocraft_color::Color; use photocraft_doc::{Fill, GradientStyle}; - use photocraft_psd::descriptor::{Id, Value}; let f = Fill::gradient(vec![(0.0, Color::BLACK), (1.0, Color::WHITE)], 0.0, 1.0, GradientStyle::Linear, false); let (k, data) = super::write_fill(&f); let d = super::parse_prefix_versioned(&data).unwrap(); @@ -84,8 +84,8 @@ fn gradient_fill_reads_the_interpolation_method() { #[test] fn lab_descriptor_colours_round_trip() { + use craft_psd::descriptor::{Descriptor, Value}; use photocraft_color::ColorMode; - use photocraft_psd::descriptor::{Descriptor, Value}; let d = Descriptor::new("LbCl").with("Lmnc", Value::Double(19.07)).with("A ", Value::Double(52.29)).with("B ", Value::Double(-85.08)); let c = super::color_from_desc(&d).unwrap(); assert_eq!(c.mode, ColorMode::Lab); @@ -103,8 +103,8 @@ fn lab_descriptor_colours_round_trip() { /// psd-tools `stroke-color-descriptors-hsb-with-rgb-mode.psd`, used to be dropped (#6774). #[test] fn hsb_descriptor_colours_parse() { + use craft_psd::descriptor::{Descriptor, Value}; use photocraft_doc::Fill; - use photocraft_psd::descriptor::{Descriptor, Value}; let hsb = |h: f64, s: f64, b: f64| { Descriptor::new("HSBC").with("H ", Value::UnitFloat { unit: *b"#Ang", value: h }).with("Strt", Value::Double(s)).with("Brgh", Value::Double(b)) }; @@ -174,8 +174,8 @@ fn gradient_stop_alpha_round_trips_once() { #[test] fn blending_ranges_map_to_blend_if() { + use craft_psd::layer::BlendingRanges; use photocraft_doc::{BlendIf, BlendRange}; - use photocraft_psd::layer::BlendingRanges; // Full ranges (what every layer without Blend If carries) → the default setting. assert_eq!(super::blend_if_from_ranges(&BlendingRanges::full(3)), BlendIf::default()); assert_eq!(super::blend_if_from_ranges(&BlendingRanges::default()), BlendIf::default()); @@ -240,8 +240,8 @@ fn brst_round_trip() { #[test] fn placed_layer_warp_is_read() { + use craft_psd::descriptor::ObjectArray; use photocraft_geom::warp::WarpStyle; - use photocraft_psd::descriptor::ObjectArray; let e = |t: &str, v: &str| Value::Enumerated { type_id: Id::new(t), value: Id::new(v) }; let bounds = Descriptor::new("Rctn") .with("Top ", Value::UnitFloat { unit: *b"#Pxl", value: 0.0 }) diff --git a/crates/io/src/camera_raw_map.rs b/crates/io/src/camera_raw_map.rs index a53d6d40c6..5c057e173a 100644 --- a/crates/io/src/camera_raw_map.rs +++ b/crates/io/src/camera_raw_map.rs @@ -1,9 +1,9 @@ //! Camera Raw Filter descriptors observed in real PSD files (filter version 18.4, process 6). //! Verified develop controls are editable. Other processing versions //! or settings retain the entire filter through the existing opaque-filter path. +use craft_psd::descriptor::{Descriptor, UnicodeString, Value}; use photocraft_algo::camera_raw::CameraRaw; use photocraft_doc::SmartFilter; -use photocraft_psd::descriptor::{Descriptor, UnicodeString, Value}; use serde_json::{Value as J, json}; use super::{blend_options, from_hex, get_desc, int, rgbc, set, to_hex}; @@ -297,11 +297,7 @@ pub(super) fn export_item(params: &J) -> Result { if get_param(&original, param) != Some(value) { write_number(&mut d, key, value)?; if matches!(*key, "Temp" | "Tint") { - set( - &mut d, - "WBal", - Value::Enumerated { type_id: photocraft_psd::descriptor::Id::new("WBal"), value: photocraft_psd::descriptor::Id::new("Cst ") }, - ); + set(&mut d, "WBal", Value::Enumerated { type_id: craft_psd::descriptor::Id::new("WBal"), value: craft_psd::descriptor::Id::new("Cst ") }); } } } diff --git a/crates/io/src/camera_raw_map/tests.rs b/crates/io/src/camera_raw_map/tests.rs index ad40acb25d..24b3cce4fa 100644 --- a/crates/io/src/camera_raw_map/tests.rs +++ b/crates/io/src/camera_raw_map/tests.rs @@ -52,7 +52,7 @@ fn source_item() -> Descriptor { fn all_controls_item() -> Descriptor { let mut item = source_item(); let mut d = get_desc(&item, "Fltr").unwrap().clone(); - set(&mut d, "WBal", Value::Enumerated { type_id: photocraft_psd::descriptor::Id::new("WBal"), value: photocraft_psd::descriptor::Id::new("Cst ") }); + set(&mut d, "WBal", Value::Enumerated { type_id: craft_psd::descriptor::Id::new("WBal"), value: craft_psd::descriptor::Id::new("Cst ") }); set(&mut d, "ShpR", Value::Double(1.2)); for (key, value) in [ ("Temp", -26), diff --git a/crates/io/src/comps_map.rs b/crates/io/src/comps_map.rs index 3348061b34..7fc7e1c145 100644 --- a/crates/io/src/comps_map.rs +++ b/crates/io/src/comps_map.rs @@ -14,11 +14,11 @@ use std::collections::HashMap; +use craft_psd::descriptor::{Descriptor, UnicodeString, Value, VersionedDescriptor}; +use craft_psd::metadata::{MetadataItem, parse_shmd, write_shmd}; use photocraft_doc::comps::layer_position; use photocraft_doc::{Artboard, ArtboardBackground, CompLayerState, Document, Guides, Layer, LayerComp}; use photocraft_geom::Rect; -use photocraft_psd::descriptor::{Descriptor, UnicodeString, Value, VersionedDescriptor}; -use photocraft_psd::metadata::{MetadataItem, parse_shmd, write_shmd}; use crate::blocks::{color_from_desc, color_to_desc, num}; diff --git a/crates/io/src/count_resource_tests.rs b/crates/io/src/count_resource_tests.rs index fa84fc2233..ffe6a4e3c8 100644 --- a/crates/io/src/count_resource_tests.rs +++ b/crates/io/src/count_resource_tests.rs @@ -1,8 +1,8 @@ +use craft_psd::descriptor::{Descriptor, UnicodeString, Value, VersionedDescriptor}; +use craft_psd::{ColorMode as PsdMode, ImageResource, PsdFile}; use photocraft_color::{ColorMode, SampleType}; use photocraft_doc::{Color, ColorTable, CountGroup, Document}; use photocraft_geom::Size; -use photocraft_psd::descriptor::{Descriptor, UnicodeString, Value, VersionedDescriptor}; -use photocraft_psd::{ColorMode as PsdMode, ImageResource, PsdFile}; use super::{COUNT_INFO, count_groups_from_resource}; diff --git a/crates/io/src/displace_map.rs b/crates/io/src/displace_map.rs index 792c8e72df..832479567b 100644 --- a/crates/io/src/displace_map.rs +++ b/crates/io/src/displace_map.rs @@ -21,10 +21,10 @@ use std::sync::Arc; +use craft_psd::descriptor::{Descriptor, Id, Value}; use photocraft_algo::Image; use photocraft_doc::SmartFilter; use photocraft_geom::Rect; -use photocraft_psd::descriptor::{Descriptor, Id, Value}; use serde_json::{Value as J, json}; use super::{blend_options, from_hex, get_desc, int, set, to_hex}; @@ -233,7 +233,7 @@ pub fn embed_map(params: &J, map: &Image) -> Option { // and when it is written to PSD (`export_item`). let neutral = SmartFilter { command: String::new(), params: J::Null, blend: photocraft_color::BlendMode::Normal, opacity: 1.0, visible: true }; let item = Descriptor::new("filterFX") - .with("Nm ", Value::Text(photocraft_psd::descriptor::UnicodeString::new_nul("Displace..."))) + .with("Nm ", Value::Text(craft_psd::descriptor::UnicodeString::new_nul("Displace..."))) .with("blendOptions", blend_options(&neutral)) .with("enab", Value::Boolean(true)) .with("hasoptions", Value::Boolean(true)) @@ -305,7 +305,7 @@ mod tests { fn item(map: Vec) -> Descriptor { let e = |t: &str, v: &str| Value::Enumerated { type_id: Id::new(t), value: Id::new(v) }; Descriptor::new("filterFX") - .with("Nm ", Value::Text(photocraft_psd::descriptor::UnicodeString::new_nul("Displace..."))) + .with("Nm ", Value::Text(craft_psd::descriptor::UnicodeString::new_nul("Displace..."))) .with( "blendOptions", blend_options(&SmartFilter { diff --git a/crates/io/src/duotone_map.rs b/crates/io/src/duotone_map.rs index 693264adec..77d60b249f 100644 --- a/crates/io/src/duotone_map.rs +++ b/crates/io/src/duotone_map.rs @@ -21,9 +21,9 @@ //! every ink's Lab equivalent in image resource 1066 (Alternate Duotone Colors): version 1, a //! count, then one 10-byte colour per ink in the same order. +use craft_psd::aco::AcoColor; use photocraft_doc::adjust::CurvePoint; use photocraft_doc::{Duotone, DuotoneInk}; -use photocraft_psd::aco::AcoColor; /// Length of the block Photoshop writes. pub(crate) const BLOCK_LEN: usize = 524; diff --git a/crates/io/src/effects_map.rs b/crates/io/src/effects_map.rs index 0c7f60154f..357f7e6493 100644 --- a/crates/io/src/effects_map.rs +++ b/crates/io/src/effects_map.rs @@ -3,13 +3,13 @@ //! Key names follow the descriptors Photoshop writes (documented by //! reverse-engineering in MIT-licensed psd-tools / ag-psd). +use craft_psd::descriptor::{Descriptor, Id, UnicodeString, Value, VersionedDescriptor}; use photocraft_color::{BlendMode, Color}; use photocraft_doc::adjust::CurvePoint; use photocraft_doc::{ Bevel, BevelContour, BevelStyle, BevelTechnique, BevelTexture, Contour, Effect, FxCommon, FxPaint, Glow, GlowSource, GlowTechnique, Gradient, Satin, Shadow, StrokeFx, StrokePosition, }; -use photocraft_psd::descriptor::{Descriptor, Id, UnicodeString, Value, VersionedDescriptor}; use crate::blocks::{ bool_of, color_from_desc, color_to_desc, enum_of, get_desc, gradient_desc, gradient_style, gradient_style_value, num, pattern_placement, pattern_ref, diff --git a/crates/io/src/flat.rs b/crates/io/src/flat.rs index 096e32645b..96a0d69829 100644 --- a/crates/io/src/flat.rs +++ b/crates/io/src/flat.rs @@ -927,7 +927,7 @@ fn flat_document( warnings.extend(crate::unequal_resolution_warning(f64::from(x), f64::from(y))); } // Guides in the Photoshop resources a JPEG (APP13) or TIFF carries: resource 1032. - let guides = meta.photoshop_resources.as_deref().and_then(|r| photocraft_psd::tiff::resources_from_bytes(r).ok()); + let guides = meta.photoshop_resources.as_deref().and_then(|r| craft_psd::tiff::resources_from_bytes(r).ok()); if let Some(g) = guides.as_ref().and_then(|(res, _)| res.iter().find(|r| r.id == 1032)) { crate::psd_import::parse_guides(&g.data, &mut doc); } @@ -1141,7 +1141,7 @@ fn single_layer_document( warnings.extend(crate::unequal_resolution_warning(f64::from(x), f64::from(y))); } // Guides in the Photoshop resources a JPEG (APP13) or TIFF carries: resource 1032. - let guides = meta.photoshop_resources.as_deref().and_then(|r| photocraft_psd::tiff::resources_from_bytes(r).ok()); + let guides = meta.photoshop_resources.as_deref().and_then(|r| craft_psd::tiff::resources_from_bytes(r).ok()); if let Some(g) = guides.as_ref().and_then(|(res, _)| res.iter().find(|r| r.id == 1032)) { crate::psd_import::parse_guides(&g.data, &mut doc); } diff --git a/crates/io/src/hue_sat_filter_map.rs b/crates/io/src/hue_sat_filter_map.rs index 05af178c27..b7b80fe5bb 100644 --- a/crates/io/src/hue_sat_filter_map.rs +++ b/crates/io/src/hue_sat_filter_map.rs @@ -14,9 +14,9 @@ //! when the smart object does. Anything else (an unknown key, a duplicated or unknown range, a //! value Photoshop's dialog can't hold) stays an opaque [`super::UNSUPPORTED_FILTER`]. +use craft_psd::descriptor::{Descriptor, UnicodeString, Value}; use photocraft_doc::SmartFilter; use photocraft_doc::adjust::HueRange; -use photocraft_psd::descriptor::{Descriptor, UnicodeString, Value}; use serde_json::{Map, Value as J, json}; use super::{blend_options, enumv, is_meta, rgbc}; @@ -198,8 +198,8 @@ pub(super) fn export_item(f: &SmartFilter) -> Result { mod tests { use super::*; use crate::smart_map::{UNSUPPORTED_FILTER, filter_from_item, item_for_filter}; + use craft_psd::descriptor::Id; use photocraft_color::BlendMode; - use photocraft_psd::descriptor::Id; fn hst2(range: Option<(i32, [i32; 4])>, h: i32, s: i32, l: i32) -> Value { let mut d = Descriptor::new("Hst2"); diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index b5d01f8cdf..a04d933fbd 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -2,7 +2,7 @@ //! //! Import and export between [`photocraft_doc::Document`] and files: //! -//! * PSD / PSB (detected by the `8BPS` signature) via `photocraft-psd`, with +//! * PSD / PSB (detected by the `8BPS` signature) via `craft-psd`, with //! fidelity levels 1–3 (raster layers, masks, blend/opacity/fill, //! visibility, names, groups, clipping, adjustment and fill layers). //! Every layer keeps its unmodelled tagged blocks in `Layer::psd_blocks` @@ -91,11 +91,11 @@ pub mod vector_preview; mod xcf; pub mod xml; +use craft_psd::{PsdError, PsdFile}; pub use export_estimate::estimate_export_bytes; use photocraft_codecs::{CodecError, EncodeOptions}; pub use photocraft_codecs::{DdsCompression, DdsOptions, DpxBits}; use photocraft_doc::{Adjustment, Document, Layer, LayerContent}; -use photocraft_psd::{PsdError, PsdFile}; pub use adjust_map::ADJUSTMENT_KEYS; pub use flat::{ @@ -696,10 +696,7 @@ fn import_psd( ctl: &photocraft_raster::Interrupt, damaged: &mut bool, opts: &ImportOptions, - parse: impl FnOnce( - &dyn Fn(&PsdFile) -> photocraft_psd::Result, - &dyn Fn(&PsdFile) -> bool, - ) -> photocraft_psd::Result<(PsdFile, Option>>)>, + parse: impl FnOnce(&dyn Fn(&PsdFile) -> craft_psd::Result, &dyn Fn(&PsdFile) -> bool) -> craft_psd::Result<(PsdFile, Option>>)>, ) -> Result { let preflight_error = std::cell::RefCell::new(None); let admission_guard: std::cell::RefCell> = std::cell::RefCell::new(None); @@ -837,7 +834,7 @@ pub fn import_file_for_open_verified( ) -> Result<(ImportResult, bool), IoError> { let read_failed = |e: std::io::Error| IoError::Read(format!("{}: {e}", path.display())); ctl.check().map_err(|_| IoError::Cancelled)?; - let source = photocraft_psd::FileSource::open(path).map_err(read_failed)?; + let source = craft_psd::FileSource::open(path).map_err(read_failed)?; let metadata = source.file().metadata().map_err(read_failed)?; if !metadata.is_file() { return Err(IoError::Read(format!("{}: not a regular file", path.display()))); @@ -851,9 +848,9 @@ pub fn import_file_for_open_verified( // the identity from it after parsing, and never from a path that may point elsewhere now. let watch = source.file().try_clone().map_err(read_failed)?; let mut magic = [0u8; 4]; - let psd = !has_extension(name, photocraft_format::EXTENSION) && photocraft_psd::ReadAt::read_at(&source, 0, &mut magic).is_ok() && is_psd(&magic); + let psd = !has_extension(name, photocraft_format::EXTENSION) && craft_psd::ReadAt::read_at(&source, 0, &mut magic).is_ok() && is_psd(&magic); let result = if psd { - import_source_with_options(name, photocraft_psd::Source::new(source), ctl, opts) + import_source_with_options(name, craft_psd::Source::new(source), ctl, opts) } else { // Read from the handle that was just identified, not by opening the path again. let mut file = source.file(); @@ -881,7 +878,7 @@ pub fn import_file_for_open_verified( match streamed { Some(result) => Ok((result, false)), None => { - let bytes = photocraft_format::read::read_all(&mut file, photocraft_psd::ReadAt::len(&source)).map_err(read_failed)?; + let bytes = photocraft_format::read::read_all(&mut file, craft_psd::ReadAt::len(&source)).map_err(read_failed)?; import_for_open_with(name, &bytes, opts, ctl) } } @@ -898,15 +895,15 @@ pub fn import_file_for_open_verified( result } -/// [`import_file_for_open_with`] for a PSD/PSB in any [`photocraft_psd::ReadAt`] source. -pub fn import_source_for_open_with(name: &str, source: photocraft_psd::Source, ctl: &photocraft_raster::Interrupt) -> Result<(ImportResult, bool), IoError> { +/// [`import_file_for_open_with`] for a PSD/PSB in any [`craft_psd::ReadAt`] source. +pub fn import_source_for_open_with(name: &str, source: craft_psd::Source, ctl: &photocraft_raster::Interrupt) -> Result<(ImportResult, bool), IoError> { import_source_with_options(name, source, ctl, &ImportOptions::default()) } /// [`import_source_for_open_with`] with custom import options. pub fn import_source_with_options( name: &str, - source: photocraft_psd::Source, + source: craft_psd::Source, ctl: &photocraft_raster::Interrupt, opts: &ImportOptions, ) -> Result<(ImportResult, bool), IoError> { @@ -923,7 +920,7 @@ pub fn import_source_with_options( #[cfg(test)] pub(crate) fn import_source_with_limit( name: &str, - source: photocraft_psd::Source, + source: craft_psd::Source, ctl: &photocraft_raster::Interrupt, pixel_limit: u64, ) -> Result<(ImportResult, bool), IoError> { @@ -1266,7 +1263,7 @@ fn export_stages_with_renderer( match preview { // Beside the other resources Photoshop writes ahead of the version info. Ok(r) => { - let at = file.resources.iter().position(|r| r.id == photocraft_psd::resources::ids::VERSION_INFO).unwrap_or(file.resources.len()); + let at = file.resources.iter().position(|r| r.id == craft_psd::resources::ids::VERSION_INFO).unwrap_or(file.resources.len()); file.resources.insert(at, r); } Err(e) => warnings.push(format!("image preview not saved: {e}")), @@ -1347,7 +1344,7 @@ fn pcraft_full_composite_with(doc: &Document, ctl: &photocraft_raster::Interrupt pub fn merged_composite(file: &PsdFile) -> Result, IoError> { let h = &file.header; h.validate()?; - if h.color_mode == photocraft_psd::ColorMode::Rgb && file.merged_has_alpha() { + if h.color_mode == craft_psd::ColorMode::Rgb && file.merged_has_alpha() { // Preserve native sample precision before removing the white matte. let raw = file.decode_merged()?; let plane = raw.len() / usize::from(h.channels); @@ -1358,9 +1355,9 @@ pub fn merged_composite(file: &PsdFile) -> Result, IoError> { .chunks_exact(plane) .take(4) .map(|bytes| match h.depth { - 32 => Ok(photocraft_psd::pixels::samples_f32(bytes)), - 16 => Ok(photocraft_psd::pixels::samples_u16(bytes).into_iter().map(|v| f32::from(v) / 65535.0).collect()), - _ => photocraft_psd::pixels::plane_to_u8(bytes, h.depth, h.width as usize, h.height as usize) + 32 => Ok(craft_psd::pixels::samples_f32(bytes)), + 16 => Ok(craft_psd::pixels::samples_u16(bytes).into_iter().map(|v| f32::from(v) / 65535.0).collect()), + _ => craft_psd::pixels::plane_to_u8(bytes, h.depth, h.width as usize, h.height as usize) .map(|values| values.into_iter().map(|v| f32::from(v) / 255.0).collect()), }) .collect::, _>>()?; @@ -1388,7 +1385,7 @@ pub fn merged_composite(file: &PsdFile) -> Result, IoError> { let img = if h.depth == 32 { None } else { file.composite_rgba8().ok() }; // CMYK goes through the colour-managed model conversion (the PSD crate's RGBA preview is a // naive, profile-free conversion). - if let (Some(img), false) = (img, matches!(h.color_mode, photocraft_psd::ColorMode::Lab | photocraft_psd::ColorMode::Cmyk)) { + if let (Some(img), false) = (img, matches!(h.color_mode, craft_psd::ColorMode::Lab | craft_psd::ColorMode::Cmyk)) { let unmatte = file.merged_has_alpha(); return Ok(img .data @@ -1409,7 +1406,7 @@ pub fn merged_composite(file: &PsdFile) -> Result, IoError> { // Generic path (Lab, CMYK and others) via the raster model conversion. // Keep the merged-alpha marker while discarding layers: otherwise an extra // spot/alpha channel in an opaque composite is mistaken for transparency. - let layer_info = Some(photocraft_psd::LayerInfo { merged_alpha: file.merged_has_alpha(), layers: Vec::new(), padding: None }); + let layer_info = Some(craft_psd::LayerInfo { merged_alpha: file.merged_has_alpha(), layers: Vec::new(), padding: None }); let (doc, _) = psd_to_document(&PsdFile { layer_info, ..file.clone() }); // Multichannel documents keep their channels apart (no layer): composite them. if doc.layers.is_empty() && doc.mode == photocraft_color::ColorMode::Multichannel { @@ -1426,7 +1423,7 @@ pub fn merged_composite(file: &PsdFile) -> Result, IoError> { #[cfg(test)] mod tests { use super::{ImportOptions, ImportPreflight, import, import_for_open, import_preflight, is_psd_input, is_svg_input}; - use photocraft_psd::{Compression, Version, testgen}; + use craft_psd::{Compression, Version, testgen}; /// The callers that fit several imports into one budget choose their option from this /// classification, so it has to follow the same dispatch as [`super::import`]. diff --git a/crates/io/src/linked.rs b/crates/io/src/linked.rs index 62db468b33..13201ae1c1 100644 --- a/crates/io/src/linked.rs +++ b/crates/io/src/linked.rs @@ -9,8 +9,8 @@ //! Pascal uuid, Unicode file name, file type, creator, `u64` data length, open-descriptor flag (+ //! versioned descriptor), then for `liFD` the raw file bytes. +use craft_psd::descriptor::VersionedDescriptor; use photocraft_doc::Metadata; -use photocraft_psd::descriptor::VersionedDescriptor; /// An embedded file found in a linked-layer block. #[derive(Clone, Debug, PartialEq, Eq)] @@ -528,7 +528,7 @@ mod tests { assert_eq!(rebuild_block(&data, &|_| true, &[]), None); let out = rebuild_block(&data, &|u| u != "a", std::slice::from_ref(&c)).unwrap(); assert_eq!(parse_linked_files(&out), vec![b.clone(), c.clone()]); - photocraft_psd::TaggedBlock::new(*b"lnk2", out).check_structure().unwrap(); + craft_psd::TaggedBlock::new(*b"lnk2", out).check_structure().unwrap(); assert_eq!(file_type(&b.bytes), *b"8BPB"); assert_eq!(file_type(&a.bytes), *b"PNGf"); // A malformed tail is kept as is. diff --git a/crates/io/src/memory.rs b/crates/io/src/memory.rs index c4a304defa..d36eba24f3 100644 --- a/crates/io/src/memory.rs +++ b/crates/io/src/memory.rs @@ -54,7 +54,7 @@ pub const LOW_MEMORY_WARNING_MARGIN: u64 = 1 << 30; /// A layer plane, a decode buffer or a tile is one contiguous allocation, so each must fit this /// limit at the point it is allocated. An import's peak is the *sum* of many such buffers and /// may legitimately exceed this; decode paths enforce their own per-buffer limits -/// (`photocraft-psd::MAX_DECODED_BYTES`, `photocraft-codecs`' `Limits::max_alloc`) there. +/// (`craft-psd::MAX_DECODED_BYTES`, `photocraft-codecs`' `Limits::max_alloc`) there. pub const MAX_SINGLE_ALLOCATION: u64 = isize::MAX as u64; /// Architectural maximum linear memory of wasm32: 65,536 pages of 64 KiB (4 GiB). diff --git a/crates/io/src/multichannel_map.rs b/crates/io/src/multichannel_map.rs index 1681849bc0..108b1931b1 100644 --- a/crates/io/src/multichannel_map.rs +++ b/crates/io/src/multichannel_map.rs @@ -7,12 +7,12 @@ //! alpha channels of every other mode (max = selected). Channel names come from resources 1006 / //! 1045 and ink colours from DisplayInfo (1077), exactly as for spot channels in the other modes. +use craft_psd::file::{GlobalLayerMask, LayerInfoPlacement}; +use craft_psd::resources::{ImageResource, ResolutionInfo, ids, version_info_resource}; +use craft_psd::{ColorMode as PsdMode, Compression, Header, ImageData, PsdFile, TaggedBlock}; use photocraft_color::{Color, ColorMode, PixelFormat, SampleType}; use photocraft_doc::{AlphaChannel, Document}; use photocraft_geom::Rect; -use photocraft_psd::file::{GlobalLayerMask, LayerInfoPlacement}; -use photocraft_psd::resources::{ImageResource, ResolutionInfo, ids, version_info_resource}; -use photocraft_psd::{ColorMode as PsdMode, Compression, Header, ImageData, PsdFile, TaggedBlock}; use photocraft_raster::Surface; use crate::pixels::{encode_be, psd_depth}; @@ -158,7 +158,7 @@ pub(crate) fn document_to_psd( } // Alpha identifiers: one u32 per channel, only while they still match the channels. let keep = |id: u16, data: &[u8]| id != ALPHA_IDENTIFIERS || data.len() == channels.len().saturating_mul(4); - let color_mode_data = photocraft_psd::hdr::color_mode_data_for_depth(header.depth); + let color_mode_data = craft_psd::hdr::color_mode_data_for_depth(header.depth); let file = layerless_file(doc, header, image_data, color_mode_data, resources, keep, size_limit, &mut warnings, patterns); (file, warnings) } @@ -241,7 +241,7 @@ mod import_allocation_tests { #[test] fn missing_planes_remain_recoverable_without_reading_past_input() { - let file = photocraft_psd::testgen::merged_only(photocraft_psd::Version::Psd, PsdMode::Multichannel, 8, Compression::Raw, 3, 2); + let file = craft_psd::testgen::merged_only(craft_psd::Version::Psd, PsdMode::Multichannel, 8, Compression::Raw, 3, 2); let mut doc = Document::new("Partial", photocraft_geom::Size::new(3, 2), ColorMode::Multichannel, SampleType::U8); let mut warnings = Vec::new(); import(&file, &[0; 6], &mut doc, &[], &mut warnings).unwrap(); diff --git a/crates/io/src/palette_map.rs b/crates/io/src/palette_map.rs index ed2273175b..022bcaa24b 100644 --- a/crates/io/src/palette_map.rs +++ b/crates/io/src/palette_map.rs @@ -5,10 +5,10 @@ //! Data (768 bytes: 256 reds, 256 greens, 256 blues), its length in resource 1046 and the //! transparent entry in resource 1047; for Bitmap one plane of 1 bit per pixel, 1 = black. +use craft_psd::resources::{ImageResource, ResolutionInfo, ids}; +use craft_psd::{ColorMode as PsdMode, Compression, Header, ImageData, PsdFile}; use photocraft_color::ColorMode; use photocraft_doc::{ColorTable, Document}; -use photocraft_psd::resources::{ImageResource, ResolutionInfo, ids}; -use photocraft_psd::{ColorMode as PsdMode, Compression, Header, ImageData, PsdFile}; /// Indexed Color Table Count: the number of colours in the table (u16). pub(crate) const COLOR_TABLE_COUNT: u16 = 1046; diff --git a/crates/io/src/pattern_map.rs b/crates/io/src/pattern_map.rs index 53c69dc80b..1a7ba6d592 100644 --- a/crates/io/src/pattern_map.rs +++ b/crates/io/src/pattern_map.rs @@ -6,10 +6,10 @@ use std::sync::Arc; +use craft_psd::patterns::{PatternBudget, PsdPattern, block_has_user_mask, block_key, mode_channels, parse_pattern_block_with, write_pattern_block}; use photocraft_color::{ColorMode, PixelFormat, SampleType}; use photocraft_doc::{Document, Pattern}; use photocraft_geom::Rect; -use photocraft_psd::patterns::{PatternBudget, PsdPattern, block_has_user_mask, block_key, mode_channels, parse_pattern_block_with, write_pattern_block}; use photocraft_raster::Surface; use crate::pixels::{max_sample, psd_depth, sample_for_depth, try_interleave, zero_sample}; @@ -110,7 +110,7 @@ fn pattern_from_psd(p: &PsdPattern, oom: &mut Option) -> Option } let mut bytes = match try_interleave(&refs, &fill, n, s, &invert) { Ok(bytes) => bytes, - Err(photocraft_psd::PsdError::OutOfMemory(error)) => { + Err(craft_psd::PsdError::OutOfMemory(error)) => { *oom = Some(error.to_string()); return None; } @@ -230,7 +230,7 @@ pub fn try_from_global_blocks(doc: &Document) -> Result, String> { for (_, _, d) in doc.metadata.psd_global_blocks.iter().filter(|(_, k, _)| PATTERN_KEYS.contains(k)) { let patterns = match parse_pattern_block_with(d, &mut budget) { Ok(patterns) => patterns, - Err(photocraft_psd::PsdError::OutOfMemory(error)) => return Err(error.to_string()), + Err(craft_psd::PsdError::OutOfMemory(error)) => return Err(error.to_string()), Err(_) => continue, }; for p in patterns { @@ -331,14 +331,14 @@ fn repair_legacy_blocks(raw: &[photocraft_doc::PsdGlobalBlock]) -> Vec Result, String> { - let ps = photocraft_psd::patterns::parse_pat_file(bytes).map_err(|e| format!("not a readable .pat file: {e}"))?; + let ps = craft_psd::patterns::parse_pat_file(bytes).map_err(|e| format!("not a readable .pat file: {e}"))?; Ok(ps.iter().filter_map(from_psd).collect()) } /// Writes patterns as a `.pat` file. pub fn write_pat(patterns: &[Pattern]) -> Result, String> { let patterns = to_psd_patterns(patterns)?; - photocraft_psd::patterns::write_pat_file(&patterns).map_err(|e| e.to_string()) + craft_psd::patterns::write_pat_file(&patterns).map_err(|e| e.to_string()) } #[cfg(test)] diff --git a/crates/io/src/perspective_warp_map.rs b/crates/io/src/perspective_warp_map.rs index cb5815bb81..1216814f82 100644 --- a/crates/io/src/perspective_warp_map.rs +++ b/crates/io/src/perspective_warp_map.rs @@ -21,9 +21,9 @@ //! rewritten from the edited planes. Anything inconsistent (an index out of range, mismatched //! vertex counts, a non-finite or degenerate quad) stays an opaque [`super::UNSUPPORTED_FILTER`]. +use craft_psd::descriptor::{Class, Descriptor, Id, ReferenceItem, UnicodeString, Value}; use photocraft_algo::perspective::{PerspectiveMap, Plane, Straighten, straighten}; use photocraft_doc::SmartFilter; -use photocraft_psd::descriptor::{Class, Descriptor, Id, ReferenceItem, UnicodeString, Value}; use serde_json::{Value as J, json}; use super::{blend_options, from_hex, get_desc, rgbc, set, to_hex, unit}; diff --git a/crates/io/src/pixels.rs b/crates/io/src/pixels.rs index 63c6742a1c..5a3a5c46e2 100644 --- a/crates/io/src/pixels.rs +++ b/crates/io/src/pixels.rs @@ -55,11 +55,11 @@ pub fn interleave(planes: &[Option<&[u8]>], fill: &[Vec], n: usize, s: Sampl /// Fallible interleave for file imports. Validate sizes and missing-plane fills before /// workers write disjoint slices of one reserved output buffer. -pub fn try_interleave(planes: &[Option<&[u8]>], fill: &[Vec], n: usize, s: SampleType, invert: &[bool]) -> photocraft_psd::Result> { - use photocraft_psd::PsdError; +pub fn try_interleave(planes: &[Option<&[u8]>], fill: &[Vec], n: usize, s: SampleType, invert: &[bool]) -> craft_psd::Result> { + use craft_psd::PsdError; let plane_bytes = n.checked_mul(s.bytes()).ok_or(PsdError::LimitExceeded("interleave size overflow"))?; let len = plane_bytes.checked_mul(planes.len()).ok_or(PsdError::LimitExceeded("interleave size overflow"))?; - if len as u64 > photocraft_psd::compression::MAX_DECODED_BYTES { + if len as u64 > craft_psd::compression::MAX_DECODED_BYTES { return Err(PsdError::LimitExceeded("interleave exceeds MAX_DECODED_BYTES")); } if n > 0 && planes.is_empty() { diff --git a/crates/io/src/psd_budget.rs b/crates/io/src/psd_budget.rs index 9a91469add..841450116a 100644 --- a/crates/io/src/psd_budget.rs +++ b/crates/io/src/psd_budget.rs @@ -4,11 +4,11 @@ //! Reserve the sum of all possible retained tiles and the largest workspace, without credit //! for pruning or tile sharing. Reserving before any worker starts makes scheduling irrelevant. -use photocraft_geom::TILE_SIZE; -use photocraft_psd::{ +use craft_psd::{ PsdFile, layer::{CHANNEL_REAL_USER_MASK, CHANNEL_USER_MASK, Rect}, }; +use photocraft_geom::TILE_SIZE; use crate::IoError; @@ -174,7 +174,7 @@ pub fn estimate(file: &PsdFile) -> Result { let sample = u64::from(h.depth.div_ceil(8)); let cc = match h.color_mode { // Unknown modes use the RGB fallback, including its alpha channel. - photocraft_psd::ColorMode::Unknown(_) => 3, + craft_psd::ColorMode::Unknown(_) => 3, mode => u64::from(mode.color_channels().unwrap_or(3)), }; if super::psd_import::can_recover_layers(file) { @@ -219,15 +219,15 @@ pub fn estimate(file: &PsdFile) -> Result { let converted = mul(pixels, mul(channels.max(cc + 1), sample * 3 + 12)?)?; budget.scratch(converted.checked_add(inflate_scratch(merged, 1)?).ok_or_else(overflow)?); } - let mut patterns = photocraft_psd::patterns::PatternBudget::default(); + let mut patterns = craft_psd::patterns::PatternBudget::default(); for block in file.global_blocks.iter().filter(|b| crate::pattern_map::PATTERN_KEYS.contains(&b.key)) { // Earlier records can decode before a malformed later record discards the block. - let items = photocraft_psd::patterns::pattern_block_memory(&block.data, &mut patterns); + let items = craft_psd::patterns::pattern_block_memory(&block.data, &mut patterns); for p in items { let pixels = mul(u64::from(p.width), u64::from(p.height))?; let sample = u64::from(p.depth.div_ceil(8)); - let color = if p.mode == 2 { 3 } else { photocraft_psd::patterns::mode_channels(p.mode) as u64 }; - let alpha = u64::from(p.planes > photocraft_psd::patterns::mode_channels(p.mode)); + let color = if p.mode == 2 { 3 } else { craft_psd::patterns::mode_channels(p.mode) as u64 }; + let alpha = u64::from(p.planes > craft_psd::patterns::mode_channels(p.mode)); budget.surface(Rect::from_xywh(0, 0, p.width, p.height), color + alpha, sample)?; // The block parser returns all planes together. Count them as retained so // previous converted patterns and later blocks cannot reuse this allowance. diff --git a/crates/io/src/psd_budget/tests.rs b/crates/io/src/psd_budget/tests.rs index edf83b7612..9767440ccb 100644 --- a/crates/io/src/psd_budget/tests.rs +++ b/crates/io/src/psd_budget/tests.rs @@ -1,5 +1,5 @@ use super::*; -use photocraft_psd::{ColorMode, Compression, Version, testgen}; +use craft_psd::{ColorMode, Compression, Version, testgen}; #[test] fn retained_storage_is_summed_but_workspace_is_reused() { @@ -58,7 +58,7 @@ fn large_off_canvas_layer_is_rejected_without_decoding_its_tiny_data() { // Unknown memory keeps a conservative automatic allowance, so this file is refused // pending approval (or by the hard platform limit on 32-bit) before any pixel decode. assert!(matches!(crate::import("huge.psd", &bytes), Err(IoError::PsdApprovalRequired { .. } | IoError::PsdImportBudget { .. }))); - let source = photocraft_psd::Source::new(bytes); + let source = craft_psd::Source::new(bytes); assert!(matches!( crate::import_source_for_open_with("huge.psd", source, &photocraft_raster::Interrupt::NONE), Err(IoError::PsdApprovalRequired { .. } | IoError::PsdImportBudget { .. }) @@ -71,7 +71,7 @@ fn user_mask_bounds_count_even_when_the_layer_is_small() { let mut file = testgen::small(Version::Psd, Compression::Raw); let before = estimate(&file); // The generator's first record has a user mask. Modify its parsed mask in place. - if let photocraft_psd::layer::MaskData::Mask(mask) = &mut file.layers_mut()[0].mask { + if let craft_psd::layer::MaskData::Mask(mask) = &mut file.layers_mut()[0].mask { mask.rect = Rect::from_xywh(0, 0, 20_000, 20_000); } else { panic!("fixture needs a parsed mask"); @@ -133,8 +133,8 @@ fn legacy_conversion_reports_a_rejected_import_instead_of_decoding_it() { }); } -fn pattern_block(mode: u32, depth: u16, width: u32, height: u32) -> photocraft_psd::TaggedBlock { - use photocraft_psd::patterns::{PsdPattern, mode_channels, write_pattern_block}; +fn pattern_block(mode: u32, depth: u16, width: u32, height: u32) -> craft_psd::TaggedBlock { + use craft_psd::patterns::{PsdPattern, mode_channels, write_pattern_block}; let len = (width as usize * depth as usize).div_ceil(8) * height as usize; let p = PsdPattern { mode, @@ -147,7 +147,7 @@ fn pattern_block(mode: u32, depth: u16, width: u32, height: u32) -> photocraft_p channels: vec![vec![0; len]; mode_channels(mode)], alpha: None, }; - photocraft_psd::TaggedBlock::new(*b"Patt", write_pattern_block(&[p]).unwrap()) + craft_psd::TaggedBlock::new(*b"Patt", write_pattern_block(&[p]).unwrap()) } #[test] @@ -190,14 +190,14 @@ fn an_unknown_color_mode_pays_for_the_layers_it_imports_as_rgb() { let rgb = testgen::small(Version::Psd, Compression::Raw); assert!(!rgb.layers().is_empty() && !super::super::psd_import::merged_needed(&rgb), "the fixture must import its layers alone"); let mut unknown = rgb.clone(); - unknown.header.color_mode = photocraft_psd::ColorMode::Unknown(5); + unknown.header.color_mode = craft_psd::ColorMode::Unknown(5); assert!(unknown.header.validate().is_ok()); let required = estimate(&unknown); assert!(required >= estimate(&rgb), "the layers import as RGB and are counted"); let bytes = unknown.to_bytes().unwrap(); let opts = crate::ImportOptions { psd_max_pixel_bytes: required - 1, ..Default::default() }; assert!(matches!(crate::import_with_options("mode5.psd", &bytes, &opts), Err(IoError::PsdImportBudget { .. }))); - let source = photocraft_psd::Source::new(bytes); + let source = craft_psd::Source::new(bytes); let refused = crate::import_source_with_limit("mode5.psd", source, &photocraft_raster::Interrupt::NONE, required - 1); assert!(matches!(refused, Err(IoError::PsdImportBudget { .. })), "{:?}", refused.map(|(r, _)| r.warnings)); } diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index 8984de49c5..f8a01dd5af 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -1,13 +1,13 @@ //! [`Document`] → PSD/PSB. -use photocraft_color::{ColorMode, PixelFormat, SampleType}; -use photocraft_doc::{Document, Layer, LayerContent, LayerMask, SmartContentsId}; -use photocraft_psd::file::{GlobalLayerMask, LayerInfoPlacement}; -use photocraft_psd::layer::{BlendingRanges, ChannelData, LayerFlags, LayerInfo, LayerMask as PsdMask, MaskData, MaskParameters}; -use photocraft_psd::resources::{ImageResource, ResolutionInfo, ids, version_info_resource}; -use photocraft_psd::{ +use craft_psd::file::{GlobalLayerMask, LayerInfoPlacement}; +use craft_psd::layer::{BlendingRanges, ChannelData, LayerFlags, LayerInfo, LayerMask as PsdMask, MaskData, MaskParameters}; +use craft_psd::resources::{ImageResource, ResolutionInfo, ids, version_info_resource}; +use craft_psd::{ BlendMode as PsdBlend, ColorMode as PsdMode, Compression, Header, ImageData, LayerRecord, PsdFile, Rect as PsdRect, SectionType, TaggedBlock, Version, }; +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_doc::{Document, Layer, LayerContent, LayerMask, SmartContentsId}; use photocraft_raster::{Interrupt, Surface}; use crate::adjust_map; @@ -628,7 +628,7 @@ impl Ex<'_> { fn layer_xmp_block(&mut self, l: &Layer, raw: &mut Vec<([u8; 4], Vec)>) { let Some(xmp) = l.xmp.as_deref() else { return }; let pos = raw.iter().position(|(k, _)| k == b"shmd"); - match photocraft_psd::metadata::put_layer_xmp(pos.map(|i| raw[i].1.as_slice()), xmp) { + match craft_psd::metadata::put_layer_xmp(pos.map(|i| raw[i].1.as_slice()), xmp) { Ok(data) => match pos { Some(i) => raw[i].1 = data, None => raw.push((*b"shmd", data)), diff --git a/crates/io/src/psd_export/banded.rs b/crates/io/src/psd_export/banded.rs index a2aed97473..6a5ef0e955 100644 --- a/crates/io/src/psd_export/banded.rs +++ b/crates/io/src/psd_export/banded.rs @@ -15,8 +15,8 @@ use std::io::Write as _; -use photocraft_psd::compression::{PlaneLayout, encode_planes, predict}; -use photocraft_psd::{ChannelData, Compression, PsdError}; +use craft_psd::compression::{PlaneLayout, encode_planes, predict}; +use craft_psd::{ChannelData, Compression, PsdError}; use photocraft_raster::Surface; use super::Ex; @@ -76,7 +76,7 @@ impl Ex<'_> { } } - fn banded_pass(&self, s: &Surface, r: photocraft_geom::Rect, compression: Compression) -> photocraft_psd::Result> { + fn banded_pass(&self, s: &Surface, r: photocraft_geom::Rect, compression: Compression) -> craft_psd::Result> { let (w, h) = (r.width() as usize, r.height() as usize); let bpp = s.format().bytes_per_pixel(); let band_rows = (band_bytes() / (w * bpp).max(1)).max(1); @@ -115,7 +115,7 @@ impl Ex<'_> { // Alpha (planes[cc]) is channel 0, then the colour channels; each on its own thread. let work: Vec<(&mut Channel, Vec)> = channels.iter_mut().enumerate().map(|(k, c)| (c, planes[if k == 0 { self.cc } else { k - 1 }].take().unwrap_or_default())).collect(); - let done = crate::pixels::par_map(work, |(c, mut plane)| -> photocraft_psd::Result<()> { + let done = crate::pixels::par_map(work, |(c, mut plane)| -> craft_psd::Result<()> { check()?; match compression { Compression::Rle => { @@ -135,7 +135,7 @@ impl Ex<'_> { } Ok(()) }); - done.into_iter().collect::>()?; + done.into_iter().collect::>()?; #[cfg(test)] BANDS_DONE.set(BANDS_DONE.get() + 1); } diff --git a/crates/io/src/psd_export/document.rs b/crates/io/src/psd_export/document.rs index fb09a54ffc..1c8692dbc9 100644 --- a/crates/io/src/psd_export/document.rs +++ b/crates/io/src/psd_export/document.rs @@ -292,7 +292,7 @@ fn export_supported_document( // open a 32-bit file without them (#291). let color_mode_data = match duotone_inks { Some(raw) => raw, - None => photocraft_psd::hdr::color_mode_data_for_depth(header.depth), + None => craft_psd::hdr::color_mode_data_for_depth(header.depth), }; let mut file = PsdFile { header, diff --git a/crates/io/src/psd_export/estimate.rs b/crates/io/src/psd_export/estimate.rs index bb2d9b1c59..7c29bb3427 100644 --- a/crates/io/src/psd_export/estimate.rs +++ b/crates/io/src/psd_export/estimate.rs @@ -1,5 +1,5 @@ use super::{Document, PSD_SAVE_HEADROOM}; -use photocraft_psd::{PsdFile, Version, file::LayerInfoPlacement}; +use craft_psd::{PsdFile, Version, file::LayerInfoPlacement}; /// The signature a kept tagged block is written with. `8B64` exists only in PSB files: a PSD /// with an `8B64` block (kept from a PSB source, e.g. its global `FMsk` or `cinf`) is refused by @@ -10,7 +10,7 @@ use photocraft_psd::{PsdFile, Version, file::LayerInfoPlacement}; pub(crate) fn block_signature(sig: [u8; 4], key: [u8; 4], version: Version) -> [u8; 4] { match version { Version::Psd if sig == *b"8B64" => *b"8BIM", - Version::Psb if sig == *b"8BIM" && photocraft_psd::tagged::uses_long_length(version, &key) => *b"8B64", + Version::Psb if sig == *b"8BIM" && craft_psd::tagged::uses_long_length(version, &key) => *b"8B64", _ => sig, } } diff --git a/crates/io/src/psd_export/smart.rs b/crates/io/src/psd_export/smart.rs index 3bda3e3240..22d72d1760 100644 --- a/crates/io/src/psd_export/smart.rs +++ b/crates/io/src/psd_export/smart.rs @@ -63,11 +63,11 @@ pub(super) struct SmartOut { /// Source composites (document pixel format, source space) by uuid; `None` = unreadable. pub(super) composites: std::collections::HashMap, photocraft_geom::Rect)>>, /// Preserved `FEid` items (by placed id). - pub(super) old_fx: Vec, + pub(super) old_fx: Vec, /// Placed ids whose preserved `FEid` item stays. pub(super) keep_fx: std::collections::HashSet, /// Regenerated `FEid` items. - pub(super) new_fx: Vec, + pub(super) new_fx: Vec, /// The unfiltered pixels of each smart object whose filter mask shows: its layer record holds /// the filtered pixels without the mask, as Photoshop's do (`compose::filter_mask`). pub(super) unfiltered: std::collections::HashMap, @@ -82,7 +82,7 @@ pub(super) fn masked(sm: &photocraft_doc::SmartObject) -> bool { /// `FEid` data padded to 4 bytes inside the block length: Photoshop counts the padding in the /// length and can't open a file whose `FEid` is padded outside it. -fn padded_fx(fx: &photocraft_psd::filter_effects::FilterEffects) -> Vec { +fn padded_fx(fx: &craft_psd::filter_effects::FilterEffects) -> Vec { let mut d = fx.to_bytes(); d.resize(d.len().next_multiple_of(4), 0); d @@ -99,7 +99,7 @@ impl SmartOut { let old_fx = globals .iter() .filter(|(_, k, _)| FX_KEYS.contains(&k)) - .filter_map(|(_, _, d)| photocraft_psd::filter_effects::FilterEffects::parse(d).ok()) + .filter_map(|(_, _, d)| craft_psd::filter_effects::FilterEffects::parse(d).ok()) .flat_map(|fx| fx.items) .collect(); let mut linked_contents = std::collections::HashMap::new(); @@ -158,7 +158,7 @@ impl SmartOut { Some(d) => out.push((sig, key, Arc::new(d))), } } else if FX_KEYS.contains(&&key) { - let Ok(mut fx) = photocraft_psd::filter_effects::FilterEffects::parse(&data) else { + let Ok(mut fx) = craft_psd::filter_effects::FilterEffects::parse(&data) else { out.push((sig, key, data)); continue; }; @@ -190,7 +190,7 @@ impl SmartOut { } } if !fx_placed && !self.new_fx.is_empty() { - let fx = photocraft_psd::filter_effects::FilterEffects { version: 3, items: self.new_fx.clone() }; + let fx = craft_psd::filter_effects::FilterEffects { version: 3, items: self.new_fx.clone() }; out.push((*b"8BIM", *b"FEid", Arc::new(padded_fx(&fx)))); if !out.iter().any(|(_, k, _)| k == b"FMsk") { // Filter mask overlay: RGB red at 50 %, Photoshop's default. diff --git a/crates/io/src/psd_export/stage.rs b/crates/io/src/psd_export/stage.rs index 869208e319..15d3613f09 100644 --- a/crates/io/src/psd_export/stage.rs +++ b/crates/io/src/psd_export/stage.rs @@ -6,13 +6,13 @@ //! [`PsdExportOptions::stage_on_scratch_from`](super::PsdExportOptions::stage_on_scratch_from) //! set and spilling on, each channel is appended to a [`StageFile`] in the scratch directory as //! soon as it is encoded, and the model keeps only a [`Stored`] range: the writer then copies it -//! through in short reads (`photocraft_psd`'s `put_encoded`). A channel that cannot be staged +//! through in short reads (`craft_psd`'s `put_encoded`). A channel that cannot be staged //! (the disk is full, say) stays in memory: staging only ever saves memory. use std::sync::Arc; -use photocraft_psd::source::{ReadAt, Source, Stored}; -use photocraft_psd::{ChannelData, ImageData, PsdError}; +use craft_psd::source::{ReadAt, Source, Stored}; +use craft_psd::{ChannelData, ImageData, PsdError}; use photocraft_raster::stage_file::StageFile; /// Encoded data shorter than this stays in memory: a range costs more than it saves. @@ -34,7 +34,7 @@ impl ReadAt for Backing { self.0.len() } - fn read_at(&self, offset: u64, buf: &mut [u8]) -> photocraft_psd::Result<()> { + fn read_at(&self, offset: u64, buf: &mut [u8]) -> craft_psd::Result<()> { self.0.read_exact_at(buf, offset).map_err(|e| PsdError::Read(format!("staged export data, {} bytes at offset {offset}: {e}", buf.len()))) } } @@ -157,7 +157,7 @@ impl Parts { } /// The channel: `head` (of the length given to [`Parts::new`]) and then the parts. - pub(crate) fn finish(mut self, id: i16, compression: photocraft_psd::Compression, head: &[u8]) -> photocraft_psd::Result { + pub(crate) fn finish(mut self, id: i16, compression: craft_psd::Compression, head: &[u8]) -> craft_psd::Result { if head.len() != self.head_len { return Err(PsdError::Invalid("banded channel head has the wrong length".into())); } @@ -198,7 +198,7 @@ impl ReadAt for Joined { self.len } - fn read_at(&self, offset: u64, buf: &mut [u8]) -> photocraft_psd::Result<()> { + fn read_at(&self, offset: u64, buf: &mut [u8]) -> craft_psd::Result<()> { if offset.checked_add(buf.len() as u64).is_none_or(|end| end > self.len) { return Err(PsdError::Read(format!("staged export data, {} bytes at offset {offset} past its {} bytes", buf.len(), self.len))); } diff --git a/crates/io/src/psd_export/tests.rs b/crates/io/src/psd_export/tests.rs index a4044255be..13f11fca7b 100644 --- a/crates/io/src/psd_export/tests.rs +++ b/crates/io/src/psd_export/tests.rs @@ -37,7 +37,7 @@ fn a_background_is_written_without_a_transparency_channel() { /// PhotoCraft, and it is written back as one (no transparency channel). #[test] fn a_flat_bitmap_file_opens_and_saves_as_a_background() { - let file = photocraft_psd::testgen::merged_only(Version::Psd, PsdMode::Bitmap, 1, Compression::Raw, 8, 4); + let file = craft_psd::testgen::merged_only(Version::Psd, PsdMode::Bitmap, 1, Compression::Raw, 8, 4); let doc = crate::import("flat.psd", &file.to_bytes().unwrap()).unwrap().document; let bg = &doc.layers[0]; assert!(bg.name == "Background" && bg.locks.transparency && bg.locks.position, "{:?}", bg.locks); @@ -236,7 +236,7 @@ fn an_opaque_save_drops_a_kept_merged_transparency_marker() { #[test] fn unreadable_lr16_warns_on_import_and_is_not_saved_twice() { // #2700: a 16-bit file whose Lr16 block claims five layers but holds none. - let mut file = photocraft_psd::testgen::merged_only(Version::Psd, PsdMode::Rgb, 16, Compression::Raw, 8, 6); + let mut file = craft_psd::testgen::merged_only(Version::Psd, PsdMode::Rgb, 16, Compression::Raw, 8, 6); file.global_layer_mask = Some(GlobalLayerMask::default()); file.global_blocks.push(TaggedBlock::new(*b"Lr16", vec![0, 5, 0, 0])); let imported = crate::import("broken.psd", &file.to_bytes().unwrap()).unwrap(); @@ -320,7 +320,7 @@ fn photo_filter_version_3_is_kept_until_edited() { fn light_adjustment_is_kept_verbatim_and_warned() { // #6542: Photoshop 2026's Light layer is a `brit` block whose `CgEd` descriptor carries // more than Brightness/Contrast. Synthetic block: the extra key names are placeholders. - use photocraft_psd::descriptor::{Descriptor, Value, VersionedDescriptor}; + use craft_psd::descriptor::{Descriptor, Value, VersionedDescriptor}; let brit = vec![0, 0, 0xff, 0xfe, 0, 127, 0, 0]; let d = Descriptor::new("null") .with("Vrsn", Value::Integer(1)) diff --git a/crates/io/src/psd_import.rs b/crates/io/src/psd_import.rs index 3306a3f4c5..ac44ac6659 100644 --- a/crates/io/src/psd_import.rs +++ b/crates/io/src/psd_import.rs @@ -2,16 +2,16 @@ use std::sync::Arc; +use craft_psd::layer::{CHANNEL_REAL_USER_MASK, CHANNEL_TRANSPARENCY, CHANNEL_USER_MASK}; +use craft_psd::resources::ids; +use craft_psd::tagged::BlockData; +use craft_psd::{ColorMode as PsdMode, LayerNode, LayerRecord, PsdFile, TaggedBlock}; use photocraft_color::{BlendMode, ColorMode, PixelFormat, SampleType}; use photocraft_doc::{ AlphaChannel, Document, Effects, FillCache, Group, Layer, LayerContent, LayerMask, PsdGlobalBlock, ShapeLayer, SmartContentsId, SmartObject, SmartSource, TextLayer, }; use photocraft_geom::{Rect, Size, TILE_SIZE}; -use photocraft_psd::layer::{CHANNEL_REAL_USER_MASK, CHANNEL_TRANSPARENCY, CHANNEL_USER_MASK}; -use photocraft_psd::resources::ids; -use photocraft_psd::tagged::BlockData; -use photocraft_psd::{ColorMode as PsdMode, LayerNode, LayerRecord, PsdFile, TaggedBlock}; use photocraft_raster::Surface; use crate::adjust_map::{self, ADJUSTMENT_KEYS}; @@ -63,7 +63,7 @@ pub(crate) struct Ctx<'a> { /// The parsed `Txt2` block (type settings EngineData lacks, e.g. optical kerning). pub txt2: Option, /// Smart-filter caches from the global `FEid`/`FXid` blocks (filter masks, by placed id). - pub filter_effects: Vec, + pub filter_effects: Vec, /// Filter masks already decoded, by placed id: every layer of a source reads the same item, and /// a surface clones by sharing its tiles. pub filter_masks: std::collections::HashMap>, @@ -139,7 +139,7 @@ fn doc_mode(m: PsdMode) -> Option { } // Real-mask metadata without a -3 channel still selects the synthetic -2 mask. -fn selected_real_mask(rec: &LayerRecord) -> Option { +fn selected_real_mask(rec: &LayerRecord) -> Option { rec.channel(CHANNEL_REAL_USER_MASK)?; rec.layer_mask()?.real } @@ -176,7 +176,7 @@ fn soft_shape_fill(rec: &LayerRecord, has_vector: bool, fill_key: Option<&[u8; 4 #[derive(Clone, Copy)] struct DecodedImport<'a> { file: &'a PsdFile, - merged: &'a Option>>, + merged: &'a Option>>, sizes: Option<(usize, usize)>, canvas: Rect, layered: bool, @@ -215,12 +215,12 @@ impl Ctx<'_> { /// A channel that failed to decode: a failed read of the file is kept to fail the open /// (`true`); damaged data is warned about, its layer left empty (`false`). - fn channel_failed(&mut self, name: &str, id: i16, e: &photocraft_psd::PsdError) -> bool { - if let photocraft_psd::PsdError::Read(msg) = e { + fn channel_failed(&mut self, name: &str, id: i16, e: &craft_psd::PsdError) -> bool { + if let craft_psd::PsdError::Read(msg) = e { self.fatal_error.get_or_insert_with(|| crate::IoError::Read(format!("layer \"{name}\": {msg}"))); return true; } - if let photocraft_psd::PsdError::OutOfMemory(_) = e { + if let craft_psd::PsdError::OutOfMemory(_) = e { self.allocation_failed(format!("layer \"{name}\" channel {id}: {e}")); return true; } @@ -275,7 +275,7 @@ impl Ctx<'_> { // tiles, so no buffer of the whole layer (planes or interleaved) is ever held. // par_map reuses the crate's worker pool (one OS thread per channel per layer would // spawn thousands on a file with many layers). - let decoders: Vec>>> = + let decoders: Vec>>> = crate::pixels::par_map(ids.to_vec(), |id| rec.channel(id).map(|ch| ch.row_decoder(w, h, depth, version))); let mut failed = false; for (&id, d) in ids.iter().zip(&decoders) { @@ -301,7 +301,7 @@ impl Ctx<'_> { invert.push(false); let mut surf = Surface::new(self.fmt); for (b0, b1) in band_rows(r.top, h, w * (self.cc + 1) * s.bytes()) { - let planes: Vec>>> = + let planes: Vec>>> = crate::pixels::par_map(decoders.iter().collect(), |d| d.as_ref().and_then(|d| d.as_ref().ok()).map(|d| d.rows(b0, b1))); let mut refs: Vec> = Vec::with_capacity(planes.len()); for (&id, p) in ids.iter().zip(&planes) { @@ -420,7 +420,7 @@ impl Ctx<'_> { mask } - fn apply_common(&mut self, l: &mut Layer, rec: &LayerRecord, blend_override: Option) { + fn apply_common(&mut self, l: &mut Layer, rec: &LayerRecord, blend_override: Option) { if l.is_group() && let Some(data) = rec.block(b"pcFr").filter(|b| b.data.len() <= 8 << 20) { @@ -490,7 +490,7 @@ impl Ctx<'_> { // The layer's XMP packet (`shmd` › `cust` › `layerXMP`) becomes the field; the rest of // `shmd` stays in `psd_blocks`, where export puts the packet back. if let Some(i) = l.psd_blocks.iter().position(|(k, _)| k == b"shmd") - && let Some((xmp, rest)) = photocraft_psd::metadata::take_layer_xmp(&l.psd_blocks[i].1) + && let Some((xmp, rest)) = craft_psd::metadata::take_layer_xmp(&l.psd_blocks[i].1) { l.xmp = Some(Arc::from(xmp)); match rest { @@ -776,7 +776,7 @@ impl Ctx<'_> { self.build(children, depth + 1) }; let sd = rec.section_divider(); - let expanded = sd.is_none_or(|s| s.kind != photocraft_psd::SectionType::ClosedFolder); + let expanded = sd.is_none_or(|s| s.kind != craft_psd::SectionType::ClosedFolder); let artboard = crate::comps_map::ARTBOARD_KEYS.iter().find_map(|k| rec.block(k)).and_then(|b| crate::comps_map::parse_artboard(&b.data)); // A Photoshop Frame's stroke: `vstk` on the frame group, in a shape's layout. let frame_stroke = rec.block(b"vstk").and_then(|b| crate::vector_map::parse_vstk(&b.data, self.dpi)).and_then(|v| v.stroke); @@ -850,7 +850,7 @@ pub(crate) fn check_group_depth(file: &PsdFile) -> Result<(), crate::IoError> { /// Deepest group nesting of the file's layer records (a layer inside this many groups is the /// deepest), computed like `layer_tree`'s stack — iteratively, so no file can make it recurse. pub(crate) fn group_depth(file: &PsdFile) -> usize { - use photocraft_psd::tagged::SectionType; + use craft_psd::tagged::SectionType; let (mut open, mut depth) = (0usize, 0usize); for rec in file.layers() { match rec.section_type() { @@ -899,7 +899,7 @@ fn preserved_blocks(rec: &LayerRecord) -> Vec<([u8; 4], Arc>)> { rec.blocks.iter().filter(|b| !REGENERATED.contains(&&b.key)).map(|b| (b.key, Arc::new(b.data.clone()))).collect() } -fn be_to_ne_plane(plane: &[u8], s: SampleType, n: usize) -> photocraft_psd::Result> { +fn be_to_ne_plane(plane: &[u8], s: SampleType, n: usize) -> craft_psd::Result> { crate::pixels::try_interleave(&[Some(plane)], &[zero_sample(s)], n, s, &[false]) } @@ -928,12 +928,8 @@ fn merged_plane(all: &[u8], i: usize, plane: usize) -> Option<&[u8]> { /// The merged image of a file the layered path does not open (Bitmap, Indexed, Duotone; 1-bit /// files) as RGBA8. What `composite_rgba8` can't read (Multichannel) shows its first channels as /// RGB. An output over `cap` bytes is refused, never allocated (#5200). -fn flat_composite( - file: &PsdFile, - merged: Option<&Result, photocraft_psd::PsdError>>, - cap: u64, -) -> Result { - use photocraft_psd::PsdError; +fn flat_composite(file: &PsdFile, merged: Option<&Result, craft_psd::PsdError>>, cap: u64) -> Result { + use craft_psd::PsdError; match file.composite_rgba8() { Ok(img) => return Ok(img), // Over the output budget: the fallback below would make the same allocation. @@ -958,12 +954,12 @@ fn flat_composite( data.resize(len, 255u8); for c in 0..3.min(usize::from(h.channels)) { let bytes = merged_plane(all, c, plane).ok_or_else(|| PsdError::Invalid("channel size mismatch".into()))?; - let p = photocraft_psd::pixels::plane_to_u8(bytes, h.depth, w, hh)?; + let p = craft_psd::pixels::plane_to_u8(bytes, h.depth, w, hh)?; for (px, v) in data.as_chunks_mut::<4>().0.iter_mut().zip(&p) { px[c] = *v; } } - Ok(photocraft_psd::RgbaImage { left: 0, top: 0, width: h.width, height: h.height, data }) + Ok(craft_psd::RgbaImage { left: 0, top: 0, width: h.width, height: h.height, data }) } /// Writes RGBA8 `data` covering `canvas` into `s`, a band of tile rows at a time (no full-size @@ -1065,7 +1061,7 @@ pub fn psd_to_document_with(file: &PsdFile, ctl: &photocraft_raster::Interrupt) pub(crate) fn psd_to_document_owned( mut file: PsdFile, ctl: &photocraft_raster::Interrupt, - decoded: Option>>, + decoded: Option>>, ) -> Option<(Document, Vec, bool, Option)> { let global = file .global_blocks @@ -1105,8 +1101,8 @@ pub(crate) fn psd_to_document_owned( /// reads every file in Photoshop's polarity, at the cost of inverting the spot ink of PSDs saved /// by those versions. pub(crate) fn earlier_photocraft_spot_planes(file: &PsdFile) -> bool { - use photocraft_psd::descriptor::UnicodeString; - use photocraft_psd::resources::VersionInfo; + use craft_psd::descriptor::UnicodeString; + use craft_psd::resources::VersionInfo; let marks_spot = file.resource(crate::channel_map::DISPLAY_INFO).is_some_and(|r| crate::channel_map::display_info_marks_spot(&r.data)); let identifies_every_channel = match file.header.color_mode.color_channels() { @@ -1174,7 +1170,7 @@ fn read_initial_metadata( for resource in &file.resources { match resource.id { ids::RESOLUTION_INFO => { - if let Ok(info) = photocraft_psd::ResolutionInfo::from_bytes(&resource.data) { + if let Ok(info) = craft_psd::ResolutionInfo::from_bytes(&resource.data) { let (x, y) = (info.h_res(), info.v_res()); doc.resolution_dpi = x as f32; warnings.extend(crate::unequal_resolution_warning(x, y)); @@ -1250,7 +1246,7 @@ fn read_initial_metadata( pub(crate) fn psd_to_document_decoded( file: &PsdFile, ctl: &photocraft_raster::Interrupt, - decoded: Option>>, + decoded: Option>>, ) -> Option<(Document, Vec)> { psd_to_document_decoded_integrity(file, ctl, decoded).map(|(document, warnings, _)| (document, warnings)) } @@ -1261,7 +1257,7 @@ pub(crate) fn psd_to_document_decoded( pub(crate) fn psd_to_document_decoded_integrity( file: &PsdFile, ctl: &photocraft_raster::Interrupt, - decoded: Option>>, + decoded: Option>>, ) -> Option<(Document, Vec, bool)> { let global = file.global_blocks.iter().map(|b| (b.signature, b.key, Arc::new(b.data.clone()))).collect(); psd_to_document_global(file, ctl, decoded, global, false).map(|(doc, warnings, damaged, _)| (doc, warnings, damaged)) @@ -1294,7 +1290,7 @@ fn legacy_admission(est: &crate::psd_budget::PsdMemoryEstimate, ctl: &photocraft fn psd_to_document_global( file: &PsdFile, ctl: &photocraft_raster::Interrupt, - decoded: Option>>, + decoded: Option>>, global: Vec, preflighted: bool, ) -> Option<(Document, Vec, bool, Option)> { @@ -1364,7 +1360,7 @@ fn psd_to_document_global( if ctl.cancelled() { return None; } - match photocraft_psd::filter_effects::FilterEffects::parse(data) { + match craft_psd::filter_effects::FilterEffects::parse(data) { Ok(fx) => cx.filter_effects.extend(fx.items), Err(e) => cx.warn(format!("smart filter masks ({}) could not be read: {e}", String::from_utf8_lossy(key))), } @@ -1399,10 +1395,10 @@ fn finish_import(mut doc: Document, mut cx: Ctx<'_>, ctl: &photocraft_raster::In fn decode_merged( file: &PsdFile, - decoded: Option>>, + decoded: Option>>, ctl: &photocraft_raster::Interrupt, cx: &mut Ctx<'_>, -) -> Option>>> { +) -> Option>>> { if merged_needed(file) { let merged = decoded.unwrap_or_else(|| file.decode_merged()); if ctl.cancelled() { @@ -1410,7 +1406,7 @@ fn decode_merged( } ctl.progress(0.1); if let Err(error) = &merged { - if matches!(error, photocraft_psd::PsdError::OutOfMemory(_)) { + if matches!(error, craft_psd::PsdError::OutOfMemory(_)) { cx.allocation_failed(format!("merged image: {error}")); } cx.warn_unreadable_data(format!("merged image could not be decoded: {error}")); @@ -1445,7 +1441,7 @@ fn import_decoded_content(import: DecodedImport<'_>, doc: &mut Document, cx: &mu } } -fn import_multichannel(file: &PsdFile, merged: &Option>>, doc: &mut Document, cx: &mut Ctx<'_>) { +fn import_multichannel(file: &PsdFile, merged: &Option>>, doc: &mut Document, cx: &mut Ctx<'_>) { if let Some(Ok(all)) = merged { let names = file.alpha_channel_names(); if let Err(error) = crate::multichannel_map::import(file, all, doc, &names, &mut cx.warnings) { @@ -1464,8 +1460,8 @@ fn import_layered(import: DecodedImport<'_>, doc: &mut Document, cx: &mut Ctx<'_ let space = photocraft_compose::cmyk_space(doc); doc.layers = photocraft_color::convert::with_cmyk_space(space.as_ref(), || cx.build(&tree, 0)); mark_background(file, &mut doc.layers); - if let Some(Ok(photocraft_psd::resources::ResourceData::LayerGroupInfo(groups))) = - file.resources.iter().find(|r| r.id == ids::LAYER_GROUP_INFO).and_then(photocraft_psd::resources::ImageResource::parsed) + if let Some(Ok(craft_psd::resources::ResourceData::LayerGroupInfo(groups))) = + file.resources.iter().find(|r| r.id == ids::LAYER_GROUP_INFO).and_then(craft_psd::resources::ImageResource::parsed) { apply_link_groups(&tree, &mut doc.layers, file.layers(), &groups); } @@ -1523,7 +1519,7 @@ fn import_layered(import: DecodedImport<'_>, doc: &mut Document, cx: &mut Ctx<'_ fn import_flattened( file: &PsdFile, - merged: &Option>>, + merged: &Option>>, sizes: Option<(usize, usize)>, canvas: Rect, fmt: PixelFormat, @@ -1555,7 +1551,7 @@ fn import_flattened( } else if !file.color_mode_data.is_empty() { cx.warn("color mode data is not preserved"); } - match flat_composite(file, merged.as_ref(), photocraft_psd::compression::MAX_DECODED_BYTES) { + match flat_composite(file, merged.as_ref(), craft_psd::compression::MAX_DECODED_BYTES) { Ok(mut img) => { if h.color_mode == PsdMode::Indexed && let Some(t) = doc.color_table.as_ref().and_then(|t| t.transparent) @@ -1579,10 +1575,10 @@ fn import_flattened( Err(error) => cx.allocation_failed(format!("flattened image: {error}")), } } - Err(error @ photocraft_psd::PsdError::OutOfMemory(_)) => cx.allocation_failed(format!("flattened image: {error}")), + Err(error @ craft_psd::PsdError::OutOfMemory(_)) => cx.allocation_failed(format!("flattened image: {error}")), // A flattened file has nothing but its merged image: a failed source read fails the open // rather than opening it without the image. - Err(photocraft_psd::PsdError::Read(message)) => { + Err(craft_psd::PsdError::Read(message)) => { cx.fatal_error.get_or_insert(crate::IoError::Read(message)); } Err(_) if matches!(merged, Some(Err(_))) => {} diff --git a/crates/io/src/psd_import/tests.rs b/crates/io/src/psd_import/tests.rs index 3a0b751002..f34c81b8f6 100644 --- a/crates/io/src/psd_import/tests.rs +++ b/crates/io/src/psd_import/tests.rs @@ -1,7 +1,7 @@ use super::*; -use photocraft_psd::descriptor::UnicodeString; -use photocraft_psd::resources::VersionInfo; -use photocraft_psd::{Compression, ImageData, ImageResource, PsdError, Version, testgen}; +use craft_psd::descriptor::UnicodeString; +use craft_psd::resources::VersionInfo; +use craft_psd::{Compression, ImageData, ImageResource, PsdError, Version, testgen}; /// #5200: the Multichannel fallback checks its RGBA output against the budget before /// allocating it (here a 2x2 image, 16 bytes, against caps of 15 and 16 bytes). @@ -26,7 +26,7 @@ fn flat_fallback_does_not_retry_an_over_budget_composite() { file.header.width = 60_000; file.header.height = 60_000; let merged = Ok(vec![0u8; 16]); - let cap = photocraft_psd::compression::MAX_DECODED_BYTES; + let cap = craft_psd::compression::MAX_DECODED_BYTES; assert!(matches!(flat_composite(&file, Some(&merged), cap), Err(PsdError::LimitExceeded(_)))); } @@ -118,7 +118,7 @@ fn version_info_cs6(has_real_merged_data: bool) -> Vec { /// The 1057 PhotoCraft writes since 0.8.2: Photoshop 2026's bytes, no NUL. fn version_info_2026(has_real_merged_data: bool) -> Vec { - photocraft_psd::resources::version_info_resource(has_real_merged_data).data + craft_psd::resources::version_info_resource(has_real_merged_data).data } /// An opaque 4 × 1 RGB document with the Photoshop spot oracles' channels in their order: @@ -390,11 +390,11 @@ fn layer_bands_end_on_tile_rows_and_cover_the_layer() { } // Tall, narrow masks cross tile bands while keeping these cases only a few KiB. -fn banded_mask_file(depth: u16, compression: Compression, version: Version, real: bool) -> (PsdFile, Vec, photocraft_psd::Rect) { - use photocraft_psd::{ChannelData, LayerMask as PsdMask, MaskData, MaskParameters, RealMask}; +fn banded_mask_file(depth: u16, compression: Compression, version: Version, real: bool) -> (PsdFile, Vec, craft_psd::Rect) { + use craft_psd::{ChannelData, LayerMask as PsdMask, MaskData, MaskParameters, RealMask}; let mut file = testgen::layered(version, PsdMode::Rgb, depth, compression); file.layer_info.as_mut().unwrap().layers.truncate(1); - let rect = photocraft_psd::Rect::from_xywh(-3, -17, 5, 600); + let rect = craft_psd::Rect::from_xywh(-3, -17, 5, 600); let mut plane = Vec::new(); for i in 0..3000 { match depth { @@ -411,7 +411,7 @@ fn banded_mask_file(depth: u16, compression: Compression, version: Version, real record.channels.retain(|c| c.id != CHANNEL_USER_MASK && c.id != CHANNEL_REAL_USER_MASK); let id = if real { // Different synthetic bounds/defaults ensure -3's own metadata is selected. - mask.rect = photocraft_psd::Rect::from_xywh(1, 1, 1, 1); + mask.rect = craft_psd::Rect::from_xywh(1, 1, 1, 1); mask.real = Some(RealMask { flags: 3, background: 255, rect }); record.channels.push(ChannelData::encode(CHANNEL_USER_MASK, compression, &vec![0; usize::from(depth / 8)], 1, 1, depth, version).unwrap()); CHANNEL_REAL_USER_MASK @@ -423,7 +423,7 @@ fn banded_mask_file(depth: u16, compression: Compression, version: Version, real (file, plane, rect) } -fn assert_banded_mask(file: &PsdFile, plane: &[u8], rect: photocraft_psd::Rect, real: bool) { +fn assert_banded_mask(file: &PsdFile, plane: &[u8], rect: craft_psd::Rect, real: bool) { let (doc, warnings, damaged) = psd_to_document_decoded_integrity(file, &photocraft_raster::Interrupt::NONE, None).unwrap(); assert!(!damaged, "{warnings:?}"); let mask = doc.layers[0].mask.as_ref().unwrap(); @@ -447,7 +447,7 @@ fn mask_bands_preserve_samples_bounds_defaults_and_parameters() { assert_banded_mask(&file, &plane, rect, real); let bytes = file.to_bytes().unwrap(); let eager = PsdFile::from_bytes(&bytes).unwrap(); - let lazy = PsdFile::from_source(photocraft_psd::Source::new(bytes)).unwrap(); + let lazy = PsdFile::from_source(craft_psd::Source::new(bytes)).unwrap(); assert_banded_mask(&eager, &plane, rect, real); assert_banded_mask(&lazy, &plane, rect, real); } @@ -458,7 +458,7 @@ fn mask_bands_preserve_samples_bounds_defaults_and_parameters() { #[test] fn raw_and_rle_mask_sources_are_read_in_bands() { - use photocraft_psd::{ReadAt, Source, Stored}; + use craft_psd::{ReadAt, Source, Stored}; use std::sync::{Arc, Mutex}; struct Reads { bytes: Vec, @@ -468,7 +468,7 @@ fn raw_and_rle_mask_sources_are_read_in_bands() { fn len(&self) -> u64 { self.bytes.len() as u64 } - fn read_at(&self, offset: u64, buf: &mut [u8]) -> photocraft_psd::Result<()> { + fn read_at(&self, offset: u64, buf: &mut [u8]) -> craft_psd::Result<()> { self.sizes.lock().unwrap().push(buf.len()); self.bytes.read_at(offset, buf) } @@ -524,7 +524,7 @@ fn a_damaged_later_mask_band_discards_earlier_bands() { /// channel, asked for after the import was already reported as failing. #[test] fn a_fatal_layer_error_skips_the_extra_channel_conversion() { - use photocraft_psd::ReadAt; + use craft_psd::ReadAt; struct Failing { bytes: Vec, data: Vec<(u64, u64)>, @@ -534,7 +534,7 @@ fn a_fatal_layer_error_skips_the_extra_channel_conversion() { fn len(&self) -> u64 { self.bytes.len() as u64 } - fn read_at(&self, offset: u64, buf: &mut [u8]) -> photocraft_psd::Result<()> { + fn read_at(&self, offset: u64, buf: &mut [u8]) -> craft_psd::Result<()> { let end = offset + buf.len() as u64; if self.armed.load(std::sync::atomic::Ordering::SeqCst) && self.data.iter().any(|&(a, b)| offset < b && end > a) { return Err(PsdError::Read("the drive went away".into())); @@ -548,13 +548,13 @@ fn a_fatal_layer_error_skips_the_extra_channel_conversion() { let converted = psd_to_document_global(&whole, &photocraft_raster::Interrupt::NONE, None, Vec::new(), false).unwrap(); assert!(converted.3.is_none() && converted.0.channels.len() == 3, "the control file converts its channels"); - let lazy = PsdFile::from_source(photocraft_psd::Source::new(bytes.clone())).unwrap(); + let lazy = PsdFile::from_source(craft_psd::Source::new(bytes.clone())).unwrap(); let data: Vec<(u64, u64)> = lazy.layers().iter().flat_map(|l| &l.channels).filter_map(|c| c.stored.as_ref()).filter(|s| s.len > 0).map(|s| (s.offset, s.offset + s.len)).collect(); assert!(!data.is_empty()); // Reads fail only once the file is parsed: the layers decode lazily from the source. let armed = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); - let file = PsdFile::from_source(photocraft_psd::Source::new(Failing { bytes, data, armed: armed.clone() })).unwrap(); + let file = PsdFile::from_source(craft_psd::Source::new(Failing { bytes, data, armed: armed.clone() })).unwrap(); armed.store(true, std::sync::atomic::Ordering::SeqCst); let (doc, _, _, fatal) = psd_to_document_global(&file, &photocraft_raster::Interrupt::NONE, None, Vec::new(), false).unwrap(); assert!(matches!(fatal, Some(crate::IoError::Read(_))), "{fatal:?}"); @@ -608,7 +608,7 @@ fn only_allocation_failures_are_fatal_to_a_layer_channel() { fn legacy_conversion_refuses_before_waiting_for_the_decode_gate() { let mut file = testgen::small(Version::Psd, Compression::Zip); // 30,000² tiles are several GiB: far past the hard budget of the 4 GiB machine below. - file.layers_mut()[0].rect = photocraft_psd::Rect::from_xywh(0, 0, 30_000, 30_000); + file.layers_mut()[0].rect = craft_psd::Rect::from_xywh(0, 0, 30_000, 30_000); let snapshot = crate::memory::MemorySnapshot { available_ram: Some(4 << 30), swap_free: None, commit_headroom: None }; let ctl = photocraft_raster::Interrupt::NONE; let _guard = crate::psd_gate::acquire_decode_admission(&ctl).expect("active decode"); diff --git a/crates/io/src/psd_thumbnail.rs b/crates/io/src/psd_thumbnail.rs index 25b8c7821c..83cbf3318e 100644 --- a/crates/io/src/psd_thumbnail.rs +++ b/crates/io/src/psd_thumbnail.rs @@ -6,11 +6,11 @@ //! height, compressed size, bits per pixel 24, planes 1) followed by the JFIF stream. Photoshop //! keeps the long side at 160 px or less. +use craft_psd::resources::{ImageResource, ids}; use photocraft_cms::{Builtin, ColorSpace, Intent, Profile, Transform}; use photocraft_codecs::{ChannelLayout, EncodeOptions, Format, Image, SampleType as CSample}; use photocraft_color::ColorMode; use photocraft_doc::Document; -use photocraft_psd::resources::{ImageResource, ids}; use crate::IoError; diff --git a/crates/io/src/puppet_map.rs b/crates/io/src/puppet_map.rs index f1015af30b..cbadbf59a9 100644 --- a/crates/io/src/puppet_map.rs +++ b/crates/io/src/puppet_map.rs @@ -18,8 +18,8 @@ //! alone feed PhotoCraft's own solver once they are edited; the mode, density and rotation //! settings aren't mapped (their stored values aren't identified), so those stay at defaults. +use craft_psd::descriptor::{Descriptor, Value}; use photocraft_doc::SmartFilter; -use photocraft_psd::descriptor::{Descriptor, Value}; use serde_json::{Value as J, json}; use super::{blend_options, from_hex, get_desc, set, to_hex}; @@ -159,7 +159,7 @@ pub(super) fn export_item(params: &J) -> Result { mod tests { use super::*; use crate::smart_map::{UNSUPPORTED_FILTER, filter_from_item, item_for_filter, parse_sold}; - use photocraft_psd::descriptor::{UnicodeString, VersionedDescriptor}; + use craft_psd::descriptor::{UnicodeString, VersionedDescriptor}; const QUAD: [f64; 8] = [10.0, 20.0, 110.0, 22.0, 108.0, 220.0, 12.0, 218.0]; diff --git a/crates/io/src/slices_map.rs b/crates/io/src/slices_map.rs index 8069c63e53..d2ec5b3cb9 100644 --- a/crates/io/src/slices_map.rs +++ b/crates/io/src/slices_map.rs @@ -1,4 +1,4 @@ -//! Document slices ↔ PSD resource 1050 (see `photocraft_psd::slices`). +//! Document slices ↔ PSD resource 1050 (see `craft_psd::slices`). //! //! Import keeps user and layer-based slices (auto slices are regenerated from them) and links //! layer-based slices to the layer whose `lyid` they name. Export writes the preserved resource @@ -7,12 +7,12 @@ use std::collections::HashMap; +use craft_psd::slices::{SliceRecord, SlicesResource}; use photocraft_doc::slices::{self, Slice, SliceKind, SliceOrigin, Slices}; use photocraft_doc::{Document, LayerId, Rect}; -use photocraft_psd::slices::{SliceRecord, SlicesResource}; /// Image resource id of the slices. -pub const SLICES: u16 = photocraft_psd::slices::SLICES; +pub const SLICES: u16 = craft_psd::slices::SLICES; fn raw(doc: &Document) -> Option<&[u8]> { doc.metadata.psd_resources.iter().find(|(id, _, _)| *id == SLICES).map(|(_, _, d)| d.as_slice()) diff --git a/crates/io/src/smart_map.rs b/crates/io/src/smart_map.rs index 53c0c258e3..49a0901b90 100644 --- a/crates/io/src/smart_map.rs +++ b/crates/io/src/smart_map.rs @@ -24,14 +24,14 @@ //! Warp (`perspective_warp_map.rs`), whose quads become the planes of its command. Hue/Saturation //! (`HStr`) maps to its adjustment command in `hue_sat_filter_map.rs`. +use craft_psd::Compression; +use craft_psd::Rect as PsdRect; +use craft_psd::descriptor::{Class, Descriptor, Id, ObjectArray, ReferenceItem, UnicodeString, Value, VersionedDescriptor}; +use craft_psd::filter_effects::{EffectsPlane, FilterEffectsItem}; use photocraft_color::{BlendMode, ColorMode, PixelFormat, SampleType}; use photocraft_doc::{LayerMask, SmartFilter, SmartObject}; use photocraft_geom::warp::{BezierMesh, Warp, WarpStyle}; use photocraft_geom::{Affine, Rect as GeomRect}; -use photocraft_psd::Compression; -use photocraft_psd::Rect as PsdRect; -use photocraft_psd::descriptor::{Class, Descriptor, Id, ObjectArray, ReferenceItem, UnicodeString, Value, VersionedDescriptor}; -use photocraft_psd::filter_effects::{EffectsPlane, FilterEffectsItem}; use photocraft_raster::Surface; use serde_json::{Map, Value as J, json}; diff --git a/crates/io/src/smart_map/tests.rs b/crates/io/src/smart_map/tests.rs index 22808b08a2..0332b5558a 100644 --- a/crates/io/src/smart_map/tests.rs +++ b/crates/io/src/smart_map/tests.rs @@ -1752,9 +1752,9 @@ fn placed_blocks_parse_back() { assert!((a - b).abs() < 1e-9); } assert_eq!(p.stack, Some(stack)); - let tb = photocraft_psd::TaggedBlock::new(*b"SoLd", sold.clone()); + let tb = craft_psd::TaggedBlock::new(*b"SoLd", sold.clone()); tb.check_structure().unwrap(); - let pl = photocraft_psd::TaggedBlock::new(*b"PlLd", plld_bytes(&spec, &mut w)); + let pl = craft_psd::TaggedBlock::new(*b"PlLd", plld_bytes(&spec, &mut w)); pl.check_structure().unwrap(); // A template keeps its other keys; an unmoved transform keeps its exact quad. let mut tmpl = sold_descriptor(&sold).unwrap(); diff --git a/crates/io/src/tiff_layers.rs b/crates/io/src/tiff_layers.rs index 413fee396e..6b210f8752 100644 --- a/crates/io/src/tiff_layers.rs +++ b/crates/io/src/tiff_layers.rs @@ -1,5 +1,5 @@ //! Layered TIFF: Photoshop layer data carried in the TIFF tags 37724 (`ImageSourceData`) and -//! 34377 (image resources), see [`photocraft_psd::tiff`]. +//! 34377 (image resources), see [`craft_psd::tiff`]. //! //! Import rebuilds a PSD model from the TIFF's pixels plus those two tags and hands it to the //! PSD importer, so everything the PSD path understands (groups, masks, effects, type, shapes, @@ -10,13 +10,13 @@ use std::sync::Arc; +use craft_psd::resources::ids; +use craft_psd::tiff::{ByteOrder, ImageSourceData, resources_from_bytes, resources_to_bytes}; +use craft_psd::{ColorMode as PsdMode, Compression, Header, ImageData, ImageResource, ResolutionInfo}; use photocraft_codecs::{self as codecs, ChannelLayout, Format, Image, SampleType as CSample}; use photocraft_color::{ColorMode, PixelFormat, SampleType}; use photocraft_doc::{AlphaChannel, Document}; use photocraft_geom::Rect; -use photocraft_psd::resources::ids; -use photocraft_psd::tiff::{ByteOrder, ImageSourceData, resources_from_bytes, resources_to_bytes}; -use photocraft_psd::{ColorMode as PsdMode, Compression, Header, ImageData, ImageResource, ResolutionInfo}; use photocraft_raster::Surface; use crate::flat::{csample, encode_image, image_to_document, layout_for, single_layer, try_buffer}; @@ -72,7 +72,7 @@ pub(crate) struct LayeredPlan { struct LayeredModel { /// The PSD model with an empty merged image: the TIFF's pixels replace it after decoding. - file: photocraft_psd::PsdFile, + file: craft_psd::PsdFile, } impl LayeredPlan { @@ -389,7 +389,7 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions, ctl: &photocr // The layers move out of the model rather than being copied, and are freed once written // (#6607); the model keeps its header, resources and composite for the image below. let (layers, w) = match tiff_image_source(doc, &mut file, save_transparency && has_alpha).into_bytes(order) { - Err(photocraft_psd::PsdError::LimitExceeded(_)) if !force_psb => { + Err(craft_psd::PsdError::LimitExceeded(_)) if !force_psb => { let psb_opts = PsdExportOptions { force_psb: true, ..psd_opts }; let (psb, w) = crate::with_span(ctl, 0.0, TIFF_MODEL_DONE, |ctl| crate::psd_export::document_to_psd_interruptible(doc, &psb_opts, ctl)); ctl.check().map_err(|_| IoError::Cancelled)?; @@ -489,7 +489,7 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions, ctl: &photocr /// Photoshop writes it. The layers, global mask and global blocks move out of `file` (the /// export's own model), which keeps its header, resources and merged image. `ifd_alpha`: the /// composite in the IFD keeps its transparency as the first extra sample (Save Transparency). -fn tiff_image_source(doc: &Document, file: &mut photocraft_psd::PsdFile, ifd_alpha: bool) -> ImageSourceData { +fn tiff_image_source(doc: &Document, file: &mut craft_psd::PsdFile, ifd_alpha: bool) -> ImageSourceData { // Photoshop pairs alpha-name resources with samples past the colour channels and ignores // the ExtraSamples kind. Layer transparency stays in each layer's channel −1. let spot_extras = file.header.channels as usize > { @@ -522,7 +522,7 @@ fn tiff_image_source(doc: &Document, file: &mut photocraft_psd::PsdFile, ifd_alp // Photoshop sets flag bit 3 on every record it writes, group dividers included (299 of // the 305 dividers in the corpus's Photoshop files). PhotoCraft's PSDs set it on // layers only, unchanged here. - layer.flags.0 |= photocraft_psd::layer::LayerFlags::BIT4_USEFUL; + layer.flags.0 |= craft_psd::layer::LayerFlags::BIT4_USEFUL; photoshop_layer_blocks(&mut layer.blocks); } } @@ -532,17 +532,17 @@ fn tiff_image_source(doc: &Document, file: &mut photocraft_psd::PsdFile, ifd_alp if let Some(mask) = &mut isd.global_layer_mask && mask.data.len() < 14 { - *mask = photocraft_psd::GlobalLayerMask::photoshop_default(); + *mask = craft_psd::GlobalLayerMask::photoshop_default(); } if !isd.global_blocks.iter().any(|b| matches!(&b.key, b"Patt" | b"Pat2" | b"Pat3")) { - isd.global_blocks.insert(0, photocraft_psd::TaggedBlock::new(*b"Patt", Vec::new())); + isd.global_blocks.insert(0, craft_psd::TaggedBlock::new(*b"Patt", Vec::new())); } if !isd.global_blocks.iter().any(|b| &b.key == b"FMsk") { - isd.global_blocks.push(photocraft_psd::TaggedBlock::new(*b"FMsk", FILTER_MASK_DEFAULT.to_vec())); + isd.global_blocks.push(craft_psd::TaggedBlock::new(*b"FMsk", FILTER_MASK_DEFAULT.to_vec())); } // Photoshop writes the marker first, ahead of `Patt`. if ifd_alpha && spot_extras && !isd.global_blocks.iter().any(|b| b.is_merged_transparency_marker()) { - isd.global_blocks.insert(0, photocraft_psd::TaggedBlock::new(*b"MTrn", Vec::new())); + isd.global_blocks.insert(0, craft_psd::TaggedBlock::new(*b"MTrn", Vec::new())); } isd } @@ -628,32 +628,32 @@ fn drop_first_display_record(data: &[u8]) -> Vec { } /// Filter-mask block (`FMsk`) Photoshop writes next to a layered TIFF: the same red 50% -/// overlay as [`photocraft_psd::GlobalLayerMask::photoshop_default`], without the kind byte. +/// overlay as [`craft_psd::GlobalLayerMask::photoshop_default`], without the kind byte. const FILTER_MASK_DEFAULT: &[u8] = &[0, 0, 0xff, 0xff, 0, 0, 0, 0, 0, 0, 0, 50]; /// Blocks Photoshop writes on every layer of a layered TIFF, when the layer does not /// already carry them. Defaults match a plain pixel layer (clipped-as-group on, nothing /// locked, no label). -fn photoshop_layer_blocks(blocks: &mut Vec) { +fn photoshop_layer_blocks(blocks: &mut Vec) { let has = |k: &[u8; 4]| blocks.iter().any(|b| &b.key == k); let mut add = Vec::new(); if !has(b"lnsr") { - add.push(photocraft_psd::TaggedBlock::name_source(*b"layr")); + add.push(craft_psd::TaggedBlock::name_source(*b"layr")); } if !has(b"clbl") { - add.push(photocraft_psd::TaggedBlock::blend_clipped_as_group(true)); + add.push(craft_psd::TaggedBlock::blend_clipped_as_group(true)); } if !has(b"infx") { - add.push(photocraft_psd::TaggedBlock::blend_interior_elements(false)); + add.push(craft_psd::TaggedBlock::blend_interior_elements(false)); } if !has(b"knko") { - add.push(photocraft_psd::TaggedBlock::knockout(0)); + add.push(craft_psd::TaggedBlock::knockout(0)); } if !has(b"lspf") { - add.push(photocraft_psd::TaggedBlock::protection(0)); + add.push(craft_psd::TaggedBlock::protection(0)); } if !has(b"lclr") { - add.push(photocraft_psd::TaggedBlock::sheet_color(0)); + add.push(craft_psd::TaggedBlock::sheet_color(0)); } let at = blocks .iter() @@ -667,12 +667,12 @@ fn photoshop_layer_blocks(blocks: &mut Vec) { } /// Removes the blocks a document kept verbatim from a little-endian TIFF because their layout -/// is unknown ([`photocraft_psd::TaggedBlock::is_foreign_order`]) before `file` is written as a +/// is unknown ([`craft_psd::TaggedBlock::is_foreign_order`]) before `file` is written as a /// PSD: their bytes are in the wrong order for a PSD and cannot be converted. Returns a warning -/// per block dropped. A save back to TIFF keeps them (see [`photocraft_psd::tiff`]). -pub fn strip_foreign_order_blocks(file: &mut photocraft_psd::PsdFile) -> Vec { +/// per block dropped. A save back to TIFF keeps them (see [`craft_psd::tiff`]). +pub fn strip_foreign_order_blocks(file: &mut craft_psd::PsdFile) -> Vec { let mut warnings = Vec::new(); - let mut strip = |blocks: &mut Vec, owner: &str| { + let mut strip = |blocks: &mut Vec, owner: &str| { blocks.retain(|b| { let keep = !b.is_foreign_order(); if !keep { @@ -1245,7 +1245,7 @@ mod tests { let r = export(&doc, "x.tif", &ExportOptions { tiff_layers: true, ..Default::default() }).unwrap(); let (_, layers) = photocraft_codecs::tiff_photoshop_tags(&r.bytes); let layers = layers.unwrap(); - assert!(layers.starts_with(photocraft_psd::tiff::SIGNATURE), "{:?}", String::from_utf8_lossy(&layers[..36.min(layers.len())])); + assert!(layers.starts_with(craft_psd::tiff::SIGNATURE), "{:?}", String::from_utf8_lossy(&layers[..36.min(layers.len())])); assert!(!r.warnings.iter().any(|w| w.contains("PSB")), "{:?}", r.warnings); let back = crate::import("x.tif", &r.bytes).unwrap().document; assert_eq!(back.layers.len(), doc.layers.len()); @@ -1260,7 +1260,7 @@ mod tests { let doc = many_cheap_fills(); let r = export(&doc, "x.tif", &ExportOptions { tiff_layers: true, force_psb: true, ..Default::default() }).unwrap(); let (_, layers) = photocraft_codecs::tiff_photoshop_tags(&r.bytes); - assert!(layers.unwrap().starts_with(photocraft_psd::tiff::SIGNATURE_PSB)); + assert!(layers.unwrap().starts_with(craft_psd::tiff::SIGNATURE_PSB)); } /// The adaptive budget, not only the caller's pixel limit, decides a layered TIFF: a known diff --git a/crates/io/src/vector_map.rs b/crates/io/src/vector_map.rs index d7667d408b..331d3edda7 100644 --- a/crates/io/src/vector_map.rs +++ b/crates/io/src/vector_map.rs @@ -6,10 +6,10 @@ //! a preserved block is written back byte-identical while it still decodes to the layer's //! current model; otherwise it is regenerated from the model. +use craft_psd::descriptor::{Descriptor, Id, Value, VersionedDescriptor}; +use craft_psd::path::{PathData, PsdKnot, PsdSubpath, VectorMaskBlock}; use photocraft_doc::{Fill, FillRule, Knot, LineCap, LineJoin, LiveShape, Path, PathOp, ShapeLayer, ShapeStroke, StrokeAlign, Subpath, VectorMask}; use photocraft_geom::Point; -use photocraft_psd::descriptor::{Descriptor, Id, Value, VersionedDescriptor}; -use photocraft_psd::path::{PathData, PsdKnot, PsdSubpath, VectorMaskBlock}; use crate::blocks::{bool_of, enum_of, fill_from_desc, fill_to_desc, get_desc, num, parse_prefix_versioned}; diff --git a/crates/io/src/vector_preview.rs b/crates/io/src/vector_preview.rs index 646d8c73f1..2d1186e7a3 100644 --- a/crates/io/src/vector_preview.rs +++ b/crates/io/src/vector_preview.rs @@ -384,7 +384,7 @@ mod tests { smart.vector_preview = Some(preview.clone()); doc.layers.push(Layer::new("native", LayerContent::Smart(smart))); let out = crate::export(&doc, "psd", &Default::default()).unwrap(); - let mut file = photocraft_psd::PsdFile::from_bytes(&out.bytes).unwrap(); + let mut file = craft_psd::PsdFile::from_bytes(&out.bytes).unwrap(); let record = file.layers()[0].clone(); *file.layers_mut() = vec![record; 100]; let before = HASHED_SOURCE_BYTES.with(std::cell::Cell::get); @@ -450,7 +450,7 @@ mod tests { assert_eq!(after.0 - before.0, source.len(), "the shared native source must not be hashed per layer or per use"); assert_eq!(after.1 - before.1, 1, "the shared preview must not be rendered per layer or per use"); assert_eq!(after.2 - before.2, source.len(), "the conversion cache must not re-hash the shared Embedded owner per layer"); - let file = photocraft_psd::PsdFile::from_bytes(&out.bytes).unwrap(); + let file = craft_psd::PsdFile::from_bytes(&out.bytes).unwrap(); assert!(file.layers().iter().all(|layer| layer.block(&PSD_KEY).is_some())); let back = check(&out.bytes); diff --git a/crates/io/tests/it/adjust_blend_roundtrip.rs b/crates/io/tests/it/adjust_blend_roundtrip.rs index f6e255c6dd..81e4cc9611 100644 --- a/crates/io/tests/it/adjust_blend_roundtrip.rs +++ b/crates/io/tests/it/adjust_blend_roundtrip.rs @@ -9,13 +9,13 @@ use crate::common; use std::sync::Arc; use common::*; +use craft_psd::PsdFile; use photocraft_color::{BlendMode, ColorMode, SampleType}; use photocraft_doc::adjust::{CurvePoint, LevelsChannel}; use photocraft_doc::adjust::{HueRange, ToneSpace}; use photocraft_doc::*; use photocraft_geom::Rect; use photocraft_io::*; -use photocraft_psd::PsdFile; const TOL: f32 = 1.0 / 255.0 + 1e-5; @@ -280,7 +280,7 @@ fn every_blend_mode_round_trips() { let adj = Layer::new("inside", LayerContent::Adjustment(Adjustment::Invert)); d.layers.push(Layer::group("pass-through adj", vec![raster("px", fmt, Rect::new(0, 0, 6, 6), 6, true), adj])); let (back, _, file) = export_import(&d); - assert!(file.layers().iter().any(|l| l.blend_mode == photocraft_psd::BlendMode::PassThrough), "pass key written"); + assert!(file.layers().iter().any(|l| l.blend_mode == craft_psd::BlendMode::PassThrough), "pass key written"); assert_docs_eq(&d, &back); assert_composite_eq(&d, &back, "pass-through"); continue; diff --git a/crates/io/tests/it/adjust_warnings.rs b/crates/io/tests/it/adjust_warnings.rs index 0e4e822527..55220bb251 100644 --- a/crates/io/tests/it/adjust_warnings.rs +++ b/crates/io/tests/it/adjust_warnings.rs @@ -1,12 +1,12 @@ //! Opening a file whose adjustment layer can't be read warns, naming the layer and the reason, //! instead of opening it silently without its effect (#1763). +use craft_psd::descriptor::{Descriptor, Id, UnicodeString, Value, VersionedDescriptor}; +use craft_psd::{Compression, LayerFlags, LayerRecord, PsdFile, TaggedBlock, Version, testgen}; use photocraft_color::{ColorMode, SampleType}; use photocraft_doc::{Adjustment, Document, Layer, LayerContent}; use photocraft_geom::Size; use photocraft_io::*; -use photocraft_psd::descriptor::{Descriptor, Id, UnicodeString, Value, VersionedDescriptor}; -use photocraft_psd::{Compression, LayerFlags, LayerRecord, PsdFile, TaggedBlock, Version, testgen}; use photocraft_raster::Surface; fn reopen(adj: Adjustment, path: &str, opts: &ExportOptions) -> (Document, Vec) { @@ -73,7 +73,7 @@ fn identity_cube(domain: &str) -> Vec { fn synthetic_lookup_psd(version: Version, depth: u16, cube: &[u8]) -> Vec { const LAYER_NAME: &str = "Custom-domain LUT"; - let mut file = testgen::layered(version, photocraft_psd::ColorMode::Rgb, depth, Compression::Rle); + let mut file = testgen::layered(version, craft_psd::ColorMode::Rgb, depth, Compression::Rle); let mut flags = LayerFlags::default(); flags.set_pixel_data_irrelevant(true); // Photoshop stores layer-block alignment inside the block length. Make the source payload diff --git a/crates/io/tests/it/annotations.rs b/crates/io/tests/it/annotations.rs index 2f50b82909..a3984e9cc3 100644 --- a/crates/io/tests/it/annotations.rs +++ b/crates/io/tests/it/annotations.rs @@ -4,12 +4,12 @@ #[cfg(feature = "corpus")] use std::path::PathBuf; +use craft_psd::PsdFile; use photocraft_color::{ColorMode, SampleType}; use photocraft_doc::{Document, MeasurementScale, Note}; use photocraft_geom::Size; use photocraft_io::annotations_map::MEASUREMENT_SCALE; use photocraft_io::{ExportOptions, export, import}; -use photocraft_psd::PsdFile; #[cfg(feature = "corpus")] fn corpus(rel: &str) -> Vec { @@ -103,7 +103,7 @@ fn psd_with_anno(anno: Vec) -> Vec { let mut doc = Document::new("notes", Size::new(16, 16), ColorMode::Rgb, SampleType::U8); doc.layers.push(photocraft_doc::Layer::raster("Background", doc.pixel_format())); let mut file = PsdFile::from_bytes(&to_psd(&doc)).unwrap(); - let mut tb = photocraft_psd::TaggedBlock::new(*b"Anno", anno.clone()); + let mut tb = craft_psd::TaggedBlock::new(*b"Anno", anno.clone()); tb.padding = Some(vec![0; (4 - anno.len() % 4) % 4]); file.global_blocks.push(tb); file.to_bytes().unwrap() diff --git a/crates/io/tests/it/artboard_guide_order.rs b/crates/io/tests/it/artboard_guide_order.rs index a9adcad9b4..514c7e0b56 100644 --- a/crates/io/tests/it/artboard_guide_order.rs +++ b/crates/io/tests/it/artboard_guide_order.rs @@ -43,7 +43,7 @@ fn exported_artboard_indices_match_guides_resource() { use photocraft_io::ExportOptions; let d = doc_with_board(Rect::new(10, 0, 30, 50)); let bytes = export(&d, "x.psd", &ExportOptions::default()).expect("export").bytes; - let f = photocraft_psd::PsdFile::from_bytes(&bytes).expect("parse"); + let f = craft_psd::PsdFile::from_bytes(&bytes).expect("parse"); let res = f.resources.iter().find(|r| r.id == 1032).expect("guides resource"); // 1032: version(4) hRes(4) vRes(4) count(4), then 5 bytes per guide: loc(4) dir(1). let dta = &res.data; diff --git a/crates/io/tests/it/blend_modes_corpus.rs b/crates/io/tests/it/blend_modes_corpus.rs index 72980bed5b..df666a1bf5 100644 --- a/crates/io/tests/it/blend_modes_corpus.rs +++ b/crates/io/tests/it/blend_modes_corpus.rs @@ -2,10 +2,10 @@ //! one 64 × 64 cell per mode, a duck over a rainbow backdrop. #![cfg(feature = "corpus")] +use craft_psd::PsdFile; use photocraft_color::BlendMode; use photocraft_geom::Rect; use photocraft_io::{import, merged_composite}; -use photocraft_psd::PsdFile; const CELL: i32 = 64; diff --git a/crates/io/tests/it/cancel.rs b/crates/io/tests/it/cancel.rs index efd2b665b8..b42c5c1316 100644 --- a/crates/io/tests/it/cancel.rs +++ b/crates/io/tests/it/cancel.rs @@ -47,7 +47,7 @@ fn import_reports_monotonic_progress_per_layer() { #[test] fn cancelled_brush_import_fails() { - use photocraft_psd::abr::{LegacyBrush, LegacyTip, write_v12}; + use craft_psd::abr::{LegacyBrush, LegacyTip, write_v12}; let brush = |name: &str| LegacyBrush { name: name.into(), spacing: 10, diff --git a/crates/io/tests/it/channels.rs b/crates/io/tests/it/channels.rs index 72accd5a2c..0171d298ee 100644 --- a/crates/io/tests/it/channels.rs +++ b/crates/io/tests/it/channels.rs @@ -47,7 +47,7 @@ fn channel_options_spot_and_quick_mask_roundtrip() { full.quick_mask = Some(AlphaChannel::new("Quick Mask", Surface::new(PixelFormat::GRAY8))); let out = photocraft_io::export(&full, "full.psd", &Default::default()).unwrap(); assert!(out.warnings.iter().any(|warning| warning.contains("Quick Mask")), "{:?}", out.warnings); - let file = photocraft_psd::PsdFile::from_bytes(&out.bytes).unwrap(); + let file = craft_psd::PsdFile::from_bytes(&out.bytes).unwrap(); assert_eq!(file.header.channels, 56); assert!(file.resources.iter().all(|resource| resource.id != 1022)); } diff --git a/crates/io/tests/it/cmyk_composite_export.rs b/crates/io/tests/it/cmyk_composite_export.rs index 77f35a62d2..c37748f0da 100644 --- a/crates/io/tests/it/cmyk_composite_export.rs +++ b/crates/io/tests/it/cmyk_composite_export.rs @@ -357,7 +357,7 @@ fn merged_composite_of_a_tagged_cmyk_file_reads_through_its_profile() { } doc.layers.push(Layer::new("Background", LayerContent::Raster(surface))); let psd = export(&doc, "psd", &ExportOptions::default()).unwrap(); - let file = photocraft_psd::PsdFile::from_bytes(&psd.bytes).unwrap(); + let file = craft_psd::PsdFile::from_bytes(&psd.bytes).unwrap(); let merged = photocraft_io::merged_composite(&file).unwrap(); // The merged image's own inks (planar, 255 = no ink), shown through the file's profile. let raw = file.decode_merged().unwrap(); diff --git a/crates/io/tests/it/cmyk_export.rs b/crates/io/tests/it/cmyk_export.rs index 043da677f2..59daa5efab 100644 --- a/crates/io/tests/it/cmyk_export.rs +++ b/crates/io/tests/it/cmyk_export.rs @@ -222,7 +222,7 @@ fn psd_merged_image_keeps_exact_inks() { m.blend = BlendMode::Multiply; d.layers.push(m); let r = export(&d, "a.psd", &ExportOptions::default()).unwrap_or_else(|e| panic!("{e}")); - let f = photocraft_psd::PsdFile::from_bytes(&r.bytes).unwrap_or_else(|e| panic!("{e}")); + let f = craft_psd::PsdFile::from_bytes(&r.bytes).unwrap_or_else(|e| panic!("{e}")); let planes = f.decode_merged().unwrap_or_else(|e| panic!("{e}")); let n = 12 * 8; // Planar, inverted (255 = no ink). diff --git a/crates/io/tests/it/color.rs b/crates/io/tests/it/color.rs index 5da5df1c7e..a3458d2ed9 100644 --- a/crates/io/tests/it/color.rs +++ b/crates/io/tests/it/color.rs @@ -184,8 +184,8 @@ fn rgb_fill_colours_separate_through_the_document_cmyk_profile() { fn cmyk_merged_composite_reads_through_the_embedded_profile() { // The merged image of a CMYK file tagged with a non-default profile is turned into RGB through // that profile, as the layers are, or every comparison against it is off (#3778). + use craft_psd::{Compression, ImageResource, Version, testgen}; use photocraft_cms::{Intent, Transform}; - use photocraft_psd::{Compression, ImageResource, Version, testgen}; let profile = photocraft_cms::synth::cmyk_profile(&photocraft_cms::synth::CmykParams { tvi: [0.26, 0.26, 0.26, 0.3], grid_a2b: 5, @@ -199,7 +199,7 @@ fn cmyk_merged_composite_reads_through_the_embedded_profile() { [o[0].clamp(0.0, 1.0), o[1].clamp(0.0, 1.0), o[2].clamp(0.0, 1.0)] }; for depth in [8u16, 16] { - let mut f = testgen::merged_only(Version::Psd, photocraft_psd::ColorMode::Cmyk, depth, Compression::Raw, 4, 1); + let mut f = testgen::merged_only(Version::Psd, craft_psd::ColorMode::Cmyk, depth, Compression::Raw, 4, 1); let inks: [[f32; 4]; 4] = [[0.1, 0.6, 0.7, 0.05], [0.8, 0.2, 0.1, 0.0], [0.3, 0.3, 0.3, 0.5], [0.0, 0.0, 0.0, 0.0]]; let max = if depth == 8 { 255.0 } else { 65535.0 }; let mut data = Vec::new(); @@ -210,7 +210,7 @@ fn cmyk_merged_composite_reads_through_the_embedded_profile() { if depth == 8 { data.push(v as u8) } else { data.extend((v as u16).to_be_bytes()) } } } - f.image_data = photocraft_psd::ImageData { compression: Compression::Raw, data, stored: None }; + f.image_data = craft_psd::ImageData { compression: Compression::Raw, data, stored: None }; f.resources.push(ImageResource::new(1039, profile.to_bytes().to_vec())); let merged = merged_composite(&f).unwrap(); let mut seen_far = false; @@ -254,7 +254,7 @@ fn grdm_block(a: (u16, [u16; 4]), b: (u16, [u16; 4])) -> Vec { } fn saved_grdm(bytes: &[u8]) -> Vec { - let f = photocraft_psd::PsdFile::from_bytes(bytes).unwrap(); + let f = craft_psd::PsdFile::from_bytes(bytes).unwrap(); f.layers().iter().find_map(|l| l.block(b"grdm")).map(|b| b.data.clone()).expect("grdm block") } diff --git a/crates/io/tests/it/common/mod.rs b/crates/io/tests/it/common/mod.rs index 7bb010f1be..093c7985b1 100644 --- a/crates/io/tests/it/common/mod.rs +++ b/crates/io/tests/it/common/mod.rs @@ -360,7 +360,7 @@ fn tone_space(mode: ColorMode) -> photocraft_doc::adjust::ToneSpace { /// - blocks with inner lengths (`PlLd`, `SoLd`, `SoLE`, `lnk2`/`lnk3`/`lnkD`, `lfx2`) /// re-parse exactly (`TaggedBlock::check_structure`). pub fn strict_block_errors(bytes: &[u8]) -> Vec { - let file = match photocraft_psd::PsdFile::from_bytes(bytes) { + let file = match craft_psd::PsdFile::from_bytes(bytes) { Ok(f) => f, Err(e) => return vec![format!("parse: {e}")], }; @@ -374,8 +374,8 @@ pub fn strict_block_errors(bytes: &[u8]) -> Vec { let pad = li.padding.as_ref().map_or(body % 2, |p| p.len() as u64); // In a global `Lr16`/`Lr32` block the padding to 4 follows the block, outside its length. let after = match &file.layer_info_placement { - photocraft_psd::LayerInfoPlacement::GlobalBlock { padding, .. } => padding.as_ref().map_or((4 - (body + pad) % 4) % 4, |p| p.len() as u64), - photocraft_psd::LayerInfoPlacement::Section => 0, + craft_psd::LayerInfoPlacement::GlobalBlock { padding, .. } => padding.as_ref().map_or((4 - (body + pad) % 4) % 4, |p| p.len() as u64), + craft_psd::LayerInfoPlacement::Section => 0, }; if (body + pad + after) % 4 != 0 { errs.push(format!("layer info: {body} bytes + {pad} padding is not a multiple of 4")); diff --git a/crates/io/tests/it/composite.rs b/crates/io/tests/it/composite.rs index 5ab6ccace6..9e6e37e035 100644 --- a/crates/io/tests/it/composite.rs +++ b/crates/io/tests/it/composite.rs @@ -4,9 +4,9 @@ use crate::common; use common::*; +use craft_psd::{PixelData, PsdBuilder, PsdFile}; use photocraft_color::{BlendMode, ColorMode, SampleType}; use photocraft_io::*; -use photocraft_psd::{PixelData, PsdBuilder, PsdFile}; const TOL: f32 = 1.0 / 255.0 + 1e-4; @@ -72,8 +72,8 @@ fn builder_oracle(depth: u16, mode: ColorMode) { } }; let mut b = PsdBuilder::new(d.size.width, d.size.height).depth(depth).color_mode(match mode { - ColorMode::Grayscale => photocraft_psd::ColorMode::Grayscale, - _ => photocraft_psd::ColorMode::Rgb, + ColorMode::Grayscale => craft_psd::ColorMode::Grayscale, + _ => craft_psd::ColorMode::Rgb, }); for l in &d.layers { let s = l.surface().unwrap(); @@ -84,8 +84,8 @@ fn builder_oracle(depth: u16, mode: ColorMode) { let fmt = s.format(); s.read_region(r).chunks_exact(fmt.channels()).map(|p| photocraft_raster::to_rgba(&fmt, p)).collect() }; - let mut spec = photocraft_psd::LayerSpec::new(l.name.clone(), r.x0, r.y0, r.width(), r.height(), to_pixels(&buf)); - spec.blend_mode = photocraft_psd::BlendMode::from_key(l.blend.psd_key()); + let mut spec = craft_psd::LayerSpec::new(l.name.clone(), r.x0, r.y0, r.width(), r.height(), to_pixels(&buf)); + spec.blend_mode = craft_psd::BlendMode::from_key(l.blend.psd_key()); spec.opacity = (l.opacity * 255.0).round() as u8; spec.fill_opacity = Some((l.fill_opacity * 255.0).round() as u8); spec.visible = l.visible; @@ -93,8 +93,8 @@ fn builder_oracle(depth: u16, mode: ColorMode) { if let Some(m) = &l.mask { let mr = m.surface.content_bounds(); let data = m.surface.read_region(mr).iter().map(|v| (v * 255.0).round() as u8).collect(); - spec.mask = Some(photocraft_psd::MaskSpec { - rect: photocraft_psd::Rect { top: mr.y0, left: mr.x0, bottom: mr.y1, right: mr.x1 }, + spec.mask = Some(craft_psd::MaskSpec { + rect: craft_psd::Rect { top: mr.y0, left: mr.x0, bottom: mr.y1, right: mr.x1 }, data, default_color: (m.surface.default_pixel()[0] * 255.0).round() as u8, disabled: !m.enabled, diff --git a/crates/io/tests/it/comps_artboards.rs b/crates/io/tests/it/comps_artboards.rs index a5b553f5a8..3476560d1c 100644 --- a/crates/io/tests/it/comps_artboards.rs +++ b/crates/io/tests/it/comps_artboards.rs @@ -4,13 +4,13 @@ #[cfg(feature = "corpus")] use std::path::PathBuf; +use craft_psd::PsdFile; use photocraft_color::{BlendMode, Color, ColorMode, PixelFormat, SampleType}; use photocraft_doc::comps::capture_states; use photocraft_doc::{Artboard, ArtboardBackground, Document, Layer, LayerComp, LayerContent}; use photocraft_geom::{Rect, Size}; use photocraft_io::comps_map::LAYER_COMPS; use photocraft_io::{ExportOptions, export, import}; -use photocraft_psd::PsdFile; #[cfg(feature = "corpus")] fn corpus(rel: &str) -> Vec { @@ -71,7 +71,7 @@ fn corpus_layer_comps_import_and_verbatim_export() { assert_eq!(back.last_applied_comp, edited.last_applied_comp); // Other metadata items in shmd (`cust`) survive regeneration. let r = out2.layers().iter().find(|r| r.block(b"shmd").is_some()).unwrap(); - let items = photocraft_psd::metadata::parse_shmd(&r.block(b"shmd").unwrap().data).unwrap(); + let items = craft_psd::metadata::parse_shmd(&r.block(b"shmd").unwrap().data).unwrap(); assert!(items.iter().any(|i| &i.key == b"cust")); assert!(items.iter().any(|i| &i.key == b"cmls")); @@ -84,7 +84,7 @@ fn corpus_layer_comps_import_and_verbatim_export() { assert!(out3.resource(LAYER_COMPS).is_none()); for r in out3.layers() { if let Some(b) = r.block(b"shmd") { - assert!(photocraft_psd::metadata::parse_shmd(&b.data).unwrap().iter().all(|i| &i.key != b"cmls")); + assert!(craft_psd::metadata::parse_shmd(&b.data).unwrap().iter().all(|i| &i.key != b"cmls")); } } } @@ -251,8 +251,8 @@ fn synthetic_pcraft_roundtrip_keeps_everything() { /// A layer at (4, 4) whose preserved `shmd` block offsets it by `(dx, 0)` in comp 1, plus the /// matching resource 1065: what a PSD with that comp data imports to before decoding comps. fn doc_with_raw_comp_offset(dx: i32) -> Document { - use photocraft_psd::descriptor::{Descriptor, UnicodeString, Value, VersionedDescriptor}; - use photocraft_psd::metadata::{MetadataItem, write_shmd}; + use craft_psd::descriptor::{Descriptor, UnicodeString, Value, VersionedDescriptor}; + use craft_psd::metadata::{MetadataItem, write_shmd}; let obj = |class: &str| Descriptor { name: UnicodeString(vec![0]), ..Descriptor::new(class) }; let mut d = Document::new("s", Size::new(32, 32), ColorMode::Rgb, SampleType::U8); let mut l = Layer::raster("A", PixelFormat::RGBA8); diff --git a/crates/io/tests/it/corpus.rs b/crates/io/tests/it/corpus.rs index 3c8ca3288a..fee3626fcb 100644 --- a/crates/io/tests/it/corpus.rs +++ b/crates/io/tests/it/corpus.rs @@ -50,8 +50,8 @@ use std::panic::{AssertUnwindSafe, catch_unwind}; use std::path::{Path, PathBuf}; use std::time::Instant; +use craft_psd::PsdFile; use photocraft_io::*; -use photocraft_psd::PsdFile; fn collect(dir: &Path, out: &mut Vec) { let Ok(rd) = std::fs::read_dir(dir) else { return }; @@ -191,7 +191,7 @@ const THUMB_BAD_FRAC: f32 = 0.02; /// Photoshop's embedded thumbnail (resource 1036: a 28-byte header, then JFIF) as RGB floats. fn thumbnail(file: &PsdFile) -> Option<(u32, u32, Vec<[f32; 3]>)> { - let r = file.resource(photocraft_psd::resources::ids::THUMBNAIL)?; + let r = file.resource(craft_psd::resources::ids::THUMBNAIL)?; let format = u32::from_be_bytes(r.data.get(..4)?.try_into().ok()?); if format != 1 { return None; @@ -325,7 +325,7 @@ fn check_file(name: &str, bytes: &[u8]) -> (Outcome, Option) { // whole image, decoded independently by the psd crate). When Photoshop wrote no real merged // image (Maximize Compatibility off) or it decodes through our own model code (Multichannel), // its embedded thumbnail is the oracle at thumbnail size. - let use_thumb = file.has_real_merged_data() == Some(false) || file.header.color_mode == photocraft_psd::ColorMode::Multichannel; + let use_thumb = file.has_real_merged_data() == Some(false) || file.header.color_mode == craft_psd::ColorMode::Multichannel; let merged = if use_thumb { None } else { merged_composite(&file).ok() }; let Some(merged) = merged else { let notes: Vec<&str> = imp.warnings.iter().map(String::as_str).take(2).collect(); diff --git a/crates/io/tests/it/corpus_regressions.rs b/crates/io/tests/it/corpus_regressions.rs index 26266c9f07..067ef79dcc 100644 --- a/crates/io/tests/it/corpus_regressions.rs +++ b/crates/io/tests/it/corpus_regressions.rs @@ -3,12 +3,12 @@ //! files themselves are never committed: each test rebuilds the relevant //! structure from our own documents. +use craft_psd::layer::CHANNEL_REAL_USER_MASK; +use craft_psd::{ChannelData, Compression, MaskData, PsdFile, RealMask, Version}; use photocraft_color::{Color, ColorMode, SampleType}; use photocraft_doc::{Document, Fill, Layer, LayerContent, LayerMask, Path, ShapeLayer, Size, Subpath, VectorMask}; use photocraft_geom::Rect; use photocraft_io::*; -use photocraft_psd::layer::CHANNEL_REAL_USER_MASK; -use photocraft_psd::{ChannelData, Compression, MaskData, PsdFile, RealMask, Version}; fn doc(depth: SampleType) -> Document { Document::with_background("t", Size::new(32, 32), ColorMode::Rgb, depth, Color::WHITE) @@ -100,7 +100,7 @@ fn real_user_mask_with_parameters_is_decoded() { let rec = file.layers_mut().last_mut().unwrap(); let MaskData::Mask(pm) = &mut rec.mask else { panic!("mask record") }; assert!(pm.parameters.is_some()); - let real_rect = photocraft_psd::Rect { top: 0, left: 0, bottom: 32, right: 16 }; + let real_rect = craft_psd::Rect { top: 0, left: 0, bottom: 32, right: 16 }; pm.real = Some(RealMask { flags: 0, background: 255, rect: real_rect }); pm.trailing.clear(); rec.channels.push(ChannelData::encode(CHANNEL_REAL_USER_MASK, Compression::Rle, &[0u8; 16 * 32], 16, 32, 8, Version::Psd).unwrap()); @@ -130,7 +130,7 @@ fn shape_with_vector_mask_density_imports_as_soft_shape() { // Photoshop stores the shape alone: replace the record's pixels with the path's coverage. let rec = file.layers_mut().last_mut().unwrap(); assert!(rec.block(b"SoCo").is_some() && rec.block(b"vmsk").is_some()); - rec.rect = photocraft_psd::Rect { top: 8, left: 8, bottom: 24, right: 24 }; + rec.rect = craft_psd::Rect { top: 8, left: 8, bottom: 24, right: 24 }; for ch in &mut rec.channels { if ch.id >= -1 { let v = if ch.id == -1 { 255 } else { 0 }; diff --git a/crates/io/tests/it/cylinder_warp.rs b/crates/io/tests/it/cylinder_warp.rs index 8ed1caa916..b52a786c50 100644 --- a/crates/io/tests/it/cylinder_warp.rs +++ b/crates/io/tests/it/cylinder_warp.rs @@ -3,12 +3,12 @@ use std::sync::Arc; +use craft_psd::PsdFile; use photocraft_color::{ColorMode, SampleType}; use photocraft_doc::{Document, Layer, LayerContent, SmartObject, SmartSource}; use photocraft_geom::warp::{Warp, WarpStyle}; use photocraft_geom::{Affine, Rect, Size}; use photocraft_io::{ExportOptions, export, import, smart_map}; -use photocraft_psd::PsdFile; use photocraft_raster::Surface; /// Silhouettes at x 10 and 90, edges at y 20 and 80, ellipses 0.3 deep, wrapped as little as @@ -79,8 +79,8 @@ fn a_cylinder_survives_psd_and_wraps_the_contents_again() { let (file, back) = round_trip(&doc); let layer = file.layers().iter().find(|l| l.name() == "label").unwrap(); let sold = smart_map::parse_sold(&layer.block(b"SoLd").unwrap().data).unwrap(); - let Some(photocraft_psd::descriptor::Value::Descriptor(w)) = sold.descriptor.get("warp") else { panic!("no warp") }; - assert!(matches!(w.get("warpStyle"), Some(photocraft_psd::descriptor::Value::Enumerated { value, .. }) if value.as_bytes() == b"warpCylinder")); + let Some(craft_psd::descriptor::Value::Descriptor(w)) = sold.descriptor.get("warp") else { panic!("no warp") }; + assert!(matches!(w.get("warpStyle"), Some(craft_psd::descriptor::Value::Enumerated { value, .. }) if value.as_bytes() == b"warpCylinder")); let sm = smart(&back); assert_eq!(sm.warp.as_ref().map(|w| w.style), Some(WarpStyle::Cylinder)); assert_eq!(sm.warp.as_ref(), Some(&warp)); diff --git a/crates/io/tests/it/deflate_compatibility.rs b/crates/io/tests/it/deflate_compatibility.rs index b6f4331b07..16f066703d 100644 --- a/crates/io/tests/it/deflate_compatibility.rs +++ b/crates/io/tests/it/deflate_compatibility.rs @@ -3,13 +3,13 @@ use crate::common; use std::io::Read; +use craft_psd::Compression; use photocraft_codecs::{ChannelLayout, Format, Image, SampleType as CodecSample}; use photocraft_color::{ColorMode, SampleType}; use photocraft_doc::{Document, Layer, LayerContent}; use photocraft_format::{SaveOptions, load_from_bytes, read_thumbnail, save_to_bytes}; use photocraft_geom::Size; use photocraft_io::{PsdExportOptions, document_to_psd_with, import}; -use photocraft_psd::Compression; use photocraft_raster::Rgba8Image; fn document(depth: SampleType, size: Size) -> Document { @@ -77,17 +77,17 @@ fn png_psd_and_pcraft_decode_identically() { let mut file = document_to_psd_with(&doc, &opts).0; // The IO exporter normally chooses Raw for float composites; also cover ZIP here. if depth == SampleType::F32 { - file.image_data = photocraft_psd::ImageData::encode(compression, &merged, &file.header).unwrap(); + file.image_data = craft_psd::ImageData::encode(compression, &merged, &file.header).unwrap(); } let bytes = file.to_bytes().unwrap(); - let parsed = photocraft_psd::PsdFile::from_bytes(&bytes).unwrap(); + let parsed = craft_psd::PsdFile::from_bytes(&bytes).unwrap(); assert_eq!(parsed.image_data.compression, compression); let (inflated, legacy) = old_and_new_inflate(&parsed.image_data.data); // The PSD streaming helper uses a different flate2 reader from plane decoding. - assert_eq!(photocraft_psd::compression::zip_decompress(&legacy, inflated.len()).unwrap(), inflated); + assert_eq!(craft_psd::compression::zip_decompress(&legacy, inflated.len()).unwrap(), inflated); file.image_data.data = legacy; for fixture in [bytes, file.to_bytes().unwrap()] { - let parsed = photocraft_psd::PsdFile::from_bytes(&fixture).unwrap(); + let parsed = craft_psd::PsdFile::from_bytes(&fixture).unwrap(); assert_eq!(parsed.image_data.decode(&parsed.header).unwrap(), merged); let back = import("compatibility.psd", &fixture).unwrap().document; assert_eq!(back.layers[0].surface().unwrap().read_region(doc.bounds()), pixels); diff --git a/crates/io/tests/it/flat.rs b/crates/io/tests/it/flat.rs index c40c76a12b..af9a29f782 100644 --- a/crates/io/tests/it/flat.rs +++ b/crates/io/tests/it/flat.rs @@ -490,7 +490,7 @@ fn psd_to_png_to_psd_chain() { let d3 = import("a.png", &png.bytes).unwrap().document; assert_eq!(d3.depth, SampleType::U16); let psd2 = export(&d3, "b.psd", &ExportOptions::default()).unwrap(); - assert!(photocraft_psd::PsdFile::from_bytes(&psd2.bytes).is_ok()); + assert!(craft_psd::PsdFile::from_bytes(&psd2.bytes).is_ok()); } /// A one-layer document of 16-pixel-wide columns, each one straight colour (colour channels, then alpha). diff --git a/crates/io/tests/it/guides_v2.rs b/crates/io/tests/it/guides_v2.rs index 651c814c89..031b5f2581 100644 --- a/crates/io/tests/it/guides_v2.rs +++ b/crates/io/tests/it/guides_v2.rs @@ -1,9 +1,9 @@ //! Image resources 1092 (guides with colours) and 1097 (guide GUIDs) next to 1032 (#5966). +use craft_psd::testgen; +use craft_psd::{Compression, ImageResource, PsdFile, Version}; use photocraft_io::guides_map::{DEFAULT_GUIDE_RGB, GUIDE_IDS, GUIDES_V2}; use photocraft_io::*; -use photocraft_psd::testgen; -use photocraft_psd::{Compression, ImageResource, PsdFile, Version}; const RED: [u8; 3] = [0xff, 0x00, 0x00]; diff --git a/crates/io/tests/it/lab16.rs b/crates/io/tests/it/lab16.rs index ff0707c11b..e4c4b7e39c 100644 --- a/crates/io/tests/it/lab16.rs +++ b/crates/io/tests/it/lab16.rs @@ -2,11 +2,11 @@ //! on the full 0..65535 range. Layer pixels, the merged image and patterns use that scale, and //! the documents' a*/b* stay on the 8-bit scale `(a + 128) / 255` at every depth. +use craft_psd::PsdFile; use photocraft_color::{ColorMode, PixelFormat, SampleType}; use photocraft_doc::{Document, Layer, LayerContent}; use photocraft_geom::{Rect, Size}; use photocraft_io::*; -use photocraft_psd::PsdFile; use photocraft_raster::Surface; /// a* = 52.29, b* = −85.08 (a gradient stop of psd-tools 4x4_16bit_lab) on the document scale. diff --git a/crates/io/tests/it/large_files.rs b/crates/io/tests/it/large_files.rs index be4d5eeab7..55b774b49f 100644 --- a/crates/io/tests/it/large_files.rs +++ b/crates/io/tests/it/large_files.rs @@ -1,4 +1,4 @@ -//! Large-file fixtures (#216, #5107), generated at test time by `photocraft_psd::fixtures`: a 20 MP +//! Large-file fixtures (#216, #5107), generated at test time by `craft_psd::fixtures`: a 20 MP //! layered PSD, a 150-layer layout, a PSD just under Photoshop's 2 GB cap and a 4.4 GB PSB. Each //! is opened through `photocraft_io`, checked (size, layers, masks, effects, merged pixels), saved //! again and re-opened. @@ -9,10 +9,10 @@ use std::path::PathBuf; +use craft_psd::Version; +use craft_psd::fixtures::{Grid, LAYERED_20MP, LAYOUT_150, PSB_4GB, PSD_2GB, Streamed}; use photocraft_doc::{Document, LayerContent}; use photocraft_geom::Rect; -use photocraft_psd::Version; -use photocraft_psd::fixtures::{Grid, LAYERED_20MP, LAYOUT_150, PSB_4GB, PSD_2GB, Streamed}; /// Raster layers, groups, masked layers, layers with effects and hidden layers in `doc`. fn census(doc: &Document) -> [usize; 5] { @@ -137,7 +137,7 @@ fn streamed_roundtrip(case: &Streamed) { drop(res); // Version 2 = PSB: a re-save past 2 GB must switch to PSB; a smaller one stays PSD. let psb = out.bytes.get(4..6) == Some(&[0, 2]); - assert!(psb || (case.version == Version::Psd && (out.bytes.len() as u64) < photocraft_psd::fixtures::PSD_MAX_FILE_BYTES), "{}", case.name); + assert!(psb || (case.version == Version::Psd && (out.bytes.len() as u64) < craft_psd::fixtures::PSD_MAX_FILE_BYTES), "{}", case.name); eprintln!( "{}: saved {:.2} GB ({}) in {:.1} s, warnings {:?}", case.name, diff --git a/crates/io/tests/it/large_psb.rs b/crates/io/tests/it/large_psb.rs index da9f6ca9a1..67f969994d 100644 --- a/crates/io/tests/it/large_psb.rs +++ b/crates/io/tests/it/large_psb.rs @@ -1,9 +1,9 @@ //! Large PSB files (#375): planes past 2 GiB decode, and a channel that can't be decoded leaves //! its layer empty instead of painting it opaque black. +use craft_psd::testgen; +use craft_psd::{ColorMode, Compression, LayerSpec, PixelData, PsdBuilder, Version}; use photocraft_io::psd_to_document; -use photocraft_psd::testgen; -use photocraft_psd::{ColorMode, Compression, LayerSpec, PixelData, PsdBuilder, Version}; /// A layer whose red channel is damaged: the warning says "treated as empty", and the layer must /// be empty, not opaque black (the transparency channel is still fine). diff --git a/crates/io/tests/it/layer_labels.rs b/crates/io/tests/it/layer_labels.rs index 792d2d621f..8fbd02ecf2 100644 --- a/crates/io/tests/it/layer_labels.rs +++ b/crates/io/tests/it/layer_labels.rs @@ -48,6 +48,6 @@ fn layer_labels_psd_roundtrip_all_colors_and_depths() { #[test] fn layer_labels_truncated_sheet_color_is_not_a_label() { - let block = photocraft_psd::TaggedBlock::new(*b"lclr", vec![0]); + let block = craft_psd::TaggedBlock::new(*b"lclr", vec![0]); assert!(matches!(block.parsed(), Some(Err(_)))); } diff --git a/crates/io/tests/it/layer_xmp.rs b/crates/io/tests/it/layer_xmp.rs index 87a02b0876..88fbb60dd0 100644 --- a/crates/io/tests/it/layer_xmp.rs +++ b/crates/io/tests/it/layer_xmp.rs @@ -3,11 +3,11 @@ use std::sync::Arc; +use craft_psd::PsdFile; +use craft_psd::metadata::{layer_xmp, put_layer_xmp}; use photocraft_color::{ColorMode, SampleType}; use photocraft_doc::{Document, Layer, Size}; use photocraft_io::{ExportOptions, export, import}; -use photocraft_psd::PsdFile; -use photocraft_psd::metadata::{layer_xmp, put_layer_xmp}; const A: &str = r#"120"#; const G: &str = r#"Café 🎨"#; diff --git a/crates/io/tests/it/multichannel.rs b/crates/io/tests/it/multichannel.rs index 66f216368b..36aad15df5 100644 --- a/crates/io/tests/it/multichannel.rs +++ b/crates/io/tests/it/multichannel.rs @@ -1,10 +1,10 @@ //! Multichannel PSDs: alpha-type channels and the document's resources and global blocks survive //! an import → export → import round trip. +use craft_psd::resources::ImageResource; +use craft_psd::{ColorMode, Compression, PsdFile, TaggedBlock, Version, testgen}; use photocraft_doc::{ColorIndicates, Knot, NamedPath, Path, PathOp, Subpath}; use photocraft_io::*; -use photocraft_psd::resources::ImageResource; -use photocraft_psd::{ColorMode, Compression, PsdFile, TaggedBlock, Version, testgen}; /// DisplayInfo (1077) record: RGB colour, opacity (0..100) and kind (0/1 alpha, 2 spot). fn display_record(out: &mut Vec, rgb: [u16; 3], opacity: u16, kind: u8) { @@ -103,7 +103,7 @@ fn ink_and_alpha_file(depth: u16) -> PsdFile { let mut f = testgen::merged_only(Version::Psd, ColorMode::Multichannel, depth, Compression::Rle, 4, 1); let mut planes = plane(&[0.0, 1.0, 0.0, 1.0], depth); planes.extend(plane(&[1.0, 1.0, 0.0, 0.0], depth)); - f.image_data = photocraft_psd::ImageData::encode(Compression::Rle, &planes, &f.header).unwrap(); + f.image_data = craft_psd::ImageData::encode(Compression::Rle, &planes, &f.header).unwrap(); let mut info = 1u32.to_be_bytes().to_vec(); display_record(&mut info, [0, 65535, 65535], 0, 2); display_record(&mut info, [65535, 0, 0], 50, 0); diff --git a/crates/io/tests/it/native_vector_preview.rs b/crates/io/tests/it/native_vector_preview.rs index d1a8964eb9..a4009141ef 100644 --- a/crates/io/tests/it/native_vector_preview.rs +++ b/crates/io/tests/it/native_vector_preview.rs @@ -2,11 +2,11 @@ use std::sync::Arc; +use craft_psd::PsdFile; use photocraft_color::{ColorMode, SampleType}; use photocraft_doc::{Document, Layer, LayerContent, SmartObject, SmartSource, VectorPreview}; use photocraft_geom::{Affine, Size}; use photocraft_io::{ExportOptions, export, import, linked, smart_map, vector_preview}; -use photocraft_psd::PsdFile; use photocraft_raster::Surface; const PDF: &[u8] = b"%PDF-1.7\nopaque native source bytes\n%%EOF\n"; diff --git a/crates/io/tests/it/orientation.rs b/crates/io/tests/it/orientation.rs index 308041aefe..d976bb79c4 100644 --- a/crates/io/tests/it/orientation.rs +++ b/crates/io/tests/it/orientation.rs @@ -109,7 +109,7 @@ fn stale_orientation_in_document_metadata_is_never_exported() { assert_upright(&back, "jpg"); let psd = export(&d, "psd", &ExportOptions::default()).unwrap().bytes; - let file = photocraft_psd::PsdFile::from_bytes(&psd).unwrap(); + let file = craft_psd::PsdFile::from_bytes(&psd).unwrap(); let e = file.resources.iter().find(|r| r.id == 1058).expect("EXIF resource"); assert_eq!(exif_orientation(&e.data), 1); let x = file.resources.iter().find(|r| r.id == 1060).expect("XMP resource"); diff --git a/crates/io/tests/it/psd16_merged_transparency.rs b/crates/io/tests/it/psd16_merged_transparency.rs index 03030e557c..49208bbe27 100644 --- a/crates/io/tests/it/psd16_merged_transparency.rs +++ b/crates/io/tests/it/psd16_merged_transparency.rs @@ -8,11 +8,11 @@ use crate::common; use common::raster; +use craft_psd::PsdFile; use photocraft_color::{ColorMode, SampleType}; use photocraft_doc::Document; use photocraft_geom::{Rect, Size}; use photocraft_io::*; -use photocraft_psd::PsdFile; /// The unicode alpha channel names (resource 1045), empty without it. fn names(file: &PsdFile) -> Vec { @@ -47,7 +47,7 @@ fn resave_keeps_a_name_marked_merged_transparency() { assert!(!ps.merged_has_alpha(), "{case}: nothing marks it yet"); let name: Vec = [0u8, 0, 0, 13].into_iter().chain("Transparency\0".encode_utf16().flat_map(u16::to_be_bytes)).collect(); ps.resources.retain(|r| !matches!(r.id, 1006 | 1045)); - ps.resources.push(photocraft_psd::ImageResource::new(1045, name)); + ps.resources.push(craft_psd::ImageResource::new(1045, name)); assert!(ps.merged_has_alpha(), "{case}: the name marks the merged transparency"); let opened = import(path, &ps.to_bytes().expect("write")).expect("import").document; diff --git a/crates/io/tests/it/psd32_export.rs b/crates/io/tests/it/psd32_export.rs index a267b8ab8f..ec890ef922 100644 --- a/crates/io/tests/it/psd32_export.rs +++ b/crates/io/tests/it/psd32_export.rs @@ -9,11 +9,11 @@ use crate::common; use common::Features; +use craft_psd::{Compression, LayerInfoPlacement, PsdFile, hdr}; use photocraft_color::{ColorMode, SampleType}; use photocraft_doc::Document; use photocraft_geom::Size; use photocraft_io::*; -use photocraft_psd::{Compression, LayerInfoPlacement, PsdFile, hdr}; fn be32(b: &[u8], at: usize) -> u32 { u32::from_be_bytes(b[at..at + 4].try_into().unwrap()) @@ -300,7 +300,7 @@ fn merged_oracle_does_not_treat_extra_channels_as_transparency() { let mut file = PsdFile::from_bytes(&export_psd(&doc)).unwrap(); let color_channels = if mode == ColorMode::Rgb { 3 } else { 1 }; file.header.channels = color_channels + 1; - file.layer_info = Some(photocraft_psd::LayerInfo { merged_alpha: false, layers: Vec::new(), padding: None }); + file.layer_info = Some(craft_psd::LayerInfo { merged_alpha: false, layers: Vec::new(), padding: None }); file.image_data.compression = Compression::Raw; file.image_data.data = (0..color_channels).flat_map(|_| 2.0_f32.to_be_bytes()).chain(0.0_f32.to_be_bytes()).collect(); assert!(!file.merged_has_alpha()); @@ -332,9 +332,9 @@ fn mt32_marks_the_merged_transparency() { let name: Vec = [0u8, 0, 0, 13].into_iter().chain("Transparency\0".encode_utf16().flat_map(u16::to_be_bytes)).collect(); file.resources.retain(|r| !matches!(r.id, 1006 | 1045)); assert!(!file.merged_has_alpha(), "a positive layer count alone"); - file.global_blocks.push(photocraft_psd::TaggedBlock::new(*b"Mt32", Vec::new())); + file.global_blocks.push(craft_psd::TaggedBlock::new(*b"Mt32", Vec::new())); assert!(file.merged_has_alpha()); - file.resources.push(photocraft_psd::ImageResource::new(1045, name)); + file.resources.push(craft_psd::ImageResource::new(1045, name)); let merged = merged_composite(&file).unwrap(); assert_eq!(merged.iter().map(|p| p[3]).collect::>(), vec![1.0, 0.0]); let opened = import("transparent.psd", &file.to_bytes().unwrap()).unwrap(); diff --git a/crates/io/tests/it/psd_background.rs b/crates/io/tests/it/psd_background.rs index d24d24409e..699c78643f 100644 --- a/crates/io/tests/it/psd_background.rs +++ b/crates/io/tests/it/psd_background.rs @@ -2,8 +2,8 @@ //! stored without a transparency channel), whatever its name in the Photoshop language that //! saved the file. +use craft_psd::{ColorMode, Compression, PsdFile, TaggedBlock, Version, testgen}; use photocraft_io::psd_to_document; -use photocraft_psd::{ColorMode, Compression, PsdFile, TaggedBlock, Version, testgen}; /// `testgen::layered` with its bottom layer turned into a Photoshop Background named `name`. fn file(name: &str, transparency_channel: bool) -> PsdFile { diff --git a/crates/io/tests/it/psd_block_alignment.rs b/crates/io/tests/it/psd_block_alignment.rs index 3d9df8f191..30a8f2f08a 100644 --- a/crates/io/tests/it/psd_block_alignment.rs +++ b/crates/io/tests/it/psd_block_alignment.rs @@ -5,11 +5,11 @@ //! layer record's extra data unaligned: a Pattern Fill's `PtFl` came out 2 bytes short of the //! boundary for half of all pattern names. +use craft_psd::PsdFile; use photocraft_color::{ColorMode, PixelFormat, SampleType}; use photocraft_doc::{Adjustment, Document, Fill, Layer, LayerContent, Pattern}; use photocraft_geom::{Rect, Size}; use photocraft_io::*; -use photocraft_psd::PsdFile; use photocraft_raster::Surface; fn pattern(name: &str) -> Pattern { diff --git a/crates/io/tests/it/psd_block_layout.rs b/crates/io/tests/it/psd_block_layout.rs index d081bfbc43..c0f037895e 100644 --- a/crates/io/tests/it/psd_block_layout.rs +++ b/crates/io/tests/it/psd_block_layout.rs @@ -5,10 +5,10 @@ use crate::common; +use craft_psd::descriptor::{Descriptor, VersionedDescriptor}; +use craft_psd::testgen; +use craft_psd::{Compression, LayerRecord, PsdFile, TaggedBlock, Version}; use photocraft_io::*; -use photocraft_psd::descriptor::{Descriptor, VersionedDescriptor}; -use photocraft_psd::testgen; -use photocraft_psd::{Compression, LayerRecord, PsdFile, TaggedBlock, Version}; /// `PlLd` data per the Adobe spec ("Placed Layer"): `plcL`, version 3, Pascal uuid, page, /// total pages, anti-alias policy, layer type, 8 transform doubles, warp version, warp @@ -37,10 +37,10 @@ fn plld(uuid: &str) -> Vec { fn sold(uuid: &str) -> Vec { let mut d = b"soLD".to_vec(); d.extend(4u32.to_be_bytes()); - use photocraft_psd::descriptor::Value; + use craft_psd::descriptor::Value; // The strict check wants what Photoshop needs to place the layer: its file id and transform. let quad = [0.0, 0.0, 4.0, 0.0, 4.0, 4.0, 0.0, 4.0].map(Value::Double).to_vec(); - let desc = Descriptor::new("null").with("Idnt", Value::Text(photocraft_psd::descriptor::UnicodeString::new_nul(uuid))).with("Trnf", Value::List(quad)); + let desc = Descriptor::new("null").with("Idnt", Value::Text(craft_psd::descriptor::UnicodeString::new_nul(uuid))).with("Trnf", Value::List(quad)); d.extend(VersionedDescriptor::new(desc).to_bytes()); while !d.len().is_multiple_of(4) { d.push(0); diff --git a/crates/io/tests/it/psd_damaged_merged.rs b/crates/io/tests/it/psd_damaged_merged.rs index 51121d59b7..7661477a7c 100644 --- a/crates/io/tests/it/psd_damaged_merged.rs +++ b/crates/io/tests/it/psd_damaged_merged.rs @@ -1,10 +1,10 @@ //! #6152: intact layers can be recovered when only the merged preview is damaged. +use craft_psd::{Compression, GroupSpec, PsdBuilder, PsdFile}; use photocraft_color::{ColorMode, SampleType}; use photocraft_doc::{Document, Layer}; use photocraft_geom::Size; use photocraft_io::{ExportOptions, export, import}; -use photocraft_psd::{Compression, GroupSpec, PsdBuilder, PsdFile}; fn layered(depth: SampleType, extension: &str) -> PsdFile { layered_in_mode(ColorMode::Rgb, depth, extension) @@ -34,7 +34,7 @@ fn truncated_merged_image_keeps_layer_pixels_at_every_depth_and_compression() { let intact = import(extension, &file.to_bytes().unwrap()).unwrap(); let expected = photocraft_compose::flatten(&intact.document).into_px(); let pixels = file.decode_merged().unwrap(); - file.image_data = photocraft_psd::ImageData::encode(compression, &pixels, &file.header).unwrap(); + file.image_data = craft_psd::ImageData::encode(compression, &pixels, &file.header).unwrap(); file.image_data.data.truncate(file.image_data.data.len() / 2); let bytes = file.to_bytes().unwrap(); assert!(PsdFile::from_bytes(&bytes).is_err(), "strict parsing stays strict"); @@ -55,7 +55,7 @@ fn truncated_merged_image_keeps_layer_pixels_at_every_depth_and_compression() { #[test] fn corrupt_zip_merged_image_keeps_the_layers() { let mut file = layered(SampleType::U8, "x.psd"); - file.image_data = photocraft_psd::ImageData::encode(Compression::Zip, &file.decode_merged().unwrap(), &file.header).unwrap(); + file.image_data = craft_psd::ImageData::encode(Compression::Zip, &file.decode_merged().unwrap(), &file.header).unwrap(); *file.image_data.data.last_mut().unwrap() ^= 0xff; let result = import("x.psd", &file.to_bytes().unwrap()).unwrap(); assert_eq!(result.document.layer_count(), 1); @@ -86,10 +86,10 @@ fn a_flattened_file_with_no_recoverable_layers_still_fails() { #[test] fn modes_imported_flattened_do_not_recover_from_unusable_layer_records() { - for mode in [photocraft_psd::ColorMode::Indexed, photocraft_psd::ColorMode::Multichannel, photocraft_psd::ColorMode::Duotone] { + for mode in [craft_psd::ColorMode::Indexed, craft_psd::ColorMode::Multichannel, craft_psd::ColorMode::Duotone] { let mut file = layered(SampleType::U8, "x.psd"); file.header.color_mode = mode; - file.image_data = photocraft_psd::ImageData::new(Compression::Raw, Vec::new()); + file.image_data = craft_psd::ImageData::new(Compression::Raw, Vec::new()); assert!(import("flat-mode.psd", &file.to_bytes().unwrap()).is_err(), "{mode:?}"); } } @@ -98,7 +98,7 @@ fn modes_imported_flattened_do_not_recover_from_unusable_layer_records() { fn lost_extra_channels_are_reported_while_layers_remain_usable() { let mut file = layered(SampleType::U8, "x.psd"); file.header.channels = 5; - file.image_data = photocraft_psd::ImageData::new(Compression::Raw, Vec::new()); + file.image_data = craft_psd::ImageData::new(Compression::Raw, Vec::new()); let result = import("extra-channel.psd", &file.to_bytes().unwrap()).unwrap(); assert_eq!(result.document.layer_count(), 1); assert!(result.document.channels.is_empty()); diff --git a/crates/io/tests/it/psd_empty_channels.rs b/crates/io/tests/it/psd_empty_channels.rs index 610bb145cc..def18d8ed4 100644 --- a/crates/io/tests/it/psd_empty_channels.rs +++ b/crates/io/tests/it/psd_empty_channels.rs @@ -1,8 +1,8 @@ use crate::common; use common::Features; +use craft_psd::{Compression, PsdFile}; use photocraft_color::{ColorMode, SampleType}; use photocraft_io::{ExportOptions, export}; -use photocraft_psd::{Compression, PsdFile}; #[test] fn empty_layer_and_mask_channels_use_raw_without_a_payload() { diff --git a/crates/io/tests/it/psd_flat_spot_channels.rs b/crates/io/tests/it/psd_flat_spot_channels.rs index 606f662fc0..c41c5bbf1c 100644 --- a/crates/io/tests/it/psd_flat_spot_channels.rs +++ b/crates/io/tests/it/psd_flat_spot_channels.rs @@ -6,12 +6,12 @@ #[cfg(feature = "corpus")] use std::path::PathBuf; +use craft_psd::PsdFile; +use craft_psd::resources::ImageResource; use photocraft_color::{Color, ColorMode, PixelFormat, SampleType}; use photocraft_doc::{AlphaChannel, Document}; use photocraft_geom::{Rect, Size}; use photocraft_io::{ExportOptions, export, import}; -use photocraft_psd::PsdFile; -use photocraft_psd::resources::ImageResource; use photocraft_raster::Surface; const W: u32 = 8; diff --git a/crates/io/tests/it/psd_frame_stroke.rs b/crates/io/tests/it/psd_frame_stroke.rs index 9b4b5267d7..da0d024ce9 100644 --- a/crates/io/tests/it/psd_frame_stroke.rs +++ b/crates/io/tests/it/psd_frame_stroke.rs @@ -2,11 +2,11 @@ //! vector mask (`vsms`) and its Properties › Stroke as a shape-style `vstk`. The stroke draws //! along the outline even when nothing is placed in the frame (#4381). +use craft_psd::{PsdFile, SectionType, TaggedBlock}; use photocraft_color::{Color, ColorMode, PixelFormat, SampleType}; use photocraft_doc::{Document, Fill, Group, Layer, LayerContent, Path, ShapeStroke, Size, StrokeAlign, Subpath, VectorMask}; use photocraft_io::vector_map::{parse_vstk, vstk_bytes}; use photocraft_io::*; -use photocraft_psd::{PsdFile, SectionType, TaggedBlock}; fn red_inside(width: f32) -> ShapeStroke { ShapeStroke { width, align: StrokeAlign::Inside, paint: Fill::Solid(Color::rgb(1.0, 0.0, 0.0)), ..ShapeStroke::default() } @@ -36,7 +36,7 @@ fn px(d: &Document, x: i32, y: i32) -> [f32; 4] { } /// The group's own record (its top, `lsct` 1 or 2) in a PSD file. -fn frame_record(f: &mut PsdFile) -> &mut photocraft_psd::LayerRecord { +fn frame_record(f: &mut PsdFile) -> &mut craft_psd::LayerRecord { f.layers_mut() .iter_mut() .find(|r| r.section_divider().is_some_and(|s| matches!(s.kind, SectionType::OpenFolder | SectionType::ClosedFolder))) diff --git a/crates/io/tests/it/psd_import_file.rs b/crates/io/tests/it/psd_import_file.rs index cd1b08761a..f04e69b353 100644 --- a/crates/io/tests/it/psd_import_file.rs +++ b/crates/io/tests/it/psd_import_file.rs @@ -1,8 +1,8 @@ //! `import_file` parses a PSD/PSB from the open file (channel data read as it is decoded) and must //! open the same document, with the same warnings, as importing the file's bytes. +use craft_psd::testgen; use photocraft_io::{ImportResult, import, import_file}; -use photocraft_psd::testgen; use photocraft_raster::Interrupt; struct TempDir(std::path::PathBuf); @@ -107,7 +107,7 @@ fn import_file_reads_other_formats_whole() { fn import_file_fails_cleanly() { let dir = TempDir::new("fail"); assert!(open("missing.psd", &dir.0.join("missing.psd")).is_err()); - let bytes = testgen::small(photocraft_psd::Version::Psd, photocraft_psd::Compression::Rle).to_bytes().unwrap(); + let bytes = testgen::small(craft_psd::Version::Psd, craft_psd::Compression::Rle).to_bytes().unwrap(); // Cut inside the layer records, inside the channel data, and inside the merged image. for cut in [10, 60, bytes.len() / 2, bytes.len() - 5] { let name = format!("cut{cut}.psd"); @@ -125,7 +125,7 @@ fn import_file_fails_cleanly() { #[test] fn a_cancelled_import_file_reports_cancelled() { let dir = TempDir::new("cancel"); - let bytes = testgen::small(photocraft_psd::Version::Psd, photocraft_psd::Compression::Zip).to_bytes().unwrap(); + let bytes = testgen::small(craft_psd::Version::Psd, craft_psd::Compression::Zip).to_bytes().unwrap(); let path = dir.write("c.psd", &bytes); let cancel = || true; let ctl = Interrupt::cancel_only(&cancel); @@ -136,7 +136,7 @@ fn a_cancelled_import_file_reports_cancelled() { /// its drive goes away) fails the open: no layer is left empty for a save to lose. #[test] fn a_read_failing_while_layers_decode_fails_the_open() { - use photocraft_psd::ReadAt; + use craft_psd::ReadAt; /// The file's bytes, failing every read of channel data (as a dropped network share or a /// truncated file would) except the 2-byte compression fields the parse reads. struct Failing { @@ -147,18 +147,18 @@ fn a_read_failing_while_layers_decode_fails_the_open() { fn len(&self) -> u64 { self.bytes.len() as u64 } - fn read_at(&self, offset: u64, buf: &mut [u8]) -> photocraft_psd::Result<()> { + fn read_at(&self, offset: u64, buf: &mut [u8]) -> craft_psd::Result<()> { let end = offset + buf.len() as u64; if self.data.iter().any(|&(a, b)| offset < b && end > a) { - return Err(photocraft_psd::PsdError::Read("the drive went away".into())); + return Err(craft_psd::PsdError::Read("the drive went away".into())); } self.bytes.as_slice().read_at(offset, buf) } } - for c in [photocraft_psd::Compression::Rle, photocraft_psd::Compression::Raw, photocraft_psd::Compression::Zip] { - let file = testgen::layered(photocraft_psd::Version::Psd, photocraft_psd::ColorMode::Rgb, 8, c); + for c in [craft_psd::Compression::Rle, craft_psd::Compression::Raw, craft_psd::Compression::Zip] { + let file = testgen::layered(craft_psd::Version::Psd, craft_psd::ColorMode::Rgb, 8, c); let bytes = file.to_bytes().unwrap(); - let lazy = photocraft_psd::PsdFile::from_source(photocraft_psd::Source::new(bytes.clone())).unwrap(); + let lazy = craft_psd::PsdFile::from_source(craft_psd::Source::new(bytes.clone())).unwrap(); let data: Vec<(u64, u64)> = lazy .layers() .iter() @@ -170,7 +170,7 @@ fn a_read_failing_while_layers_decode_fails_the_open() { assert!(!data.is_empty()); // Opening the same file whole succeeds: only the failed reads fail the open. assert!(import("x.psd", &bytes).is_ok()); - let r = photocraft_io::import_source_for_open_with("x.psd", photocraft_psd::Source::new(Failing { bytes, data }), &Interrupt::NONE); + let r = photocraft_io::import_source_for_open_with("x.psd", craft_psd::Source::new(Failing { bytes, data }), &Interrupt::NONE); match r { Err(photocraft_io::IoError::Read(msg)) => assert!(msg.contains("the drive went away"), "{c:?}: {msg}"), r => panic!("{c:?}: the open didn't fail: {:?}", r.map(|(r, damaged)| (r.warnings, damaged))), diff --git a/crates/io/tests/it/psd_kerning.rs b/crates/io/tests/it/psd_kerning.rs index a7c2313e24..7afbfe945b 100644 --- a/crates/io/tests/it/psd_kerning.rs +++ b/crates/io/tests/it/psd_kerning.rs @@ -114,7 +114,7 @@ fn fresh_metrics_text_writes_no_txt2_block() { photocraft_text::TextEngine::new().render_layer(&mut r, doc.resolution_dpi, doc.pixel_format()); doc.layers.push(Layer::new("t", LayerContent::Text(r))); let bytes = photocraft_io::export(&doc, "k.psd", &Default::default()).unwrap().bytes; - photocraft_psd::PsdFile::from_bytes(&bytes).unwrap().global_blocks.iter().any(|b| &b.key == b"Txt2") + craft_psd::PsdFile::from_bytes(&bytes).unwrap().global_blocks.iter().any(|b| &b.key == b"Txt2") }; assert!(!saves_txt2(Kerning::Metrics), "a plain Metrics layer must not write the empty Txt2 stub"); assert!(saves_txt2(Kerning::Optical), "Optical lives only in Txt2"); @@ -140,7 +140,7 @@ fn fresh_stylistic_sets_round_trip_through_psd() { photocraft_text::TextEngine::new().render_layer(&mut r, doc.resolution_dpi, doc.pixel_format()); doc.layers.push(Layer::new("t", LayerContent::Text(r))); let bytes = photocraft_io::export(&doc, "k.psd", &Default::default()).unwrap().bytes; - let file = photocraft_psd::PsdFile::from_bytes(&bytes).unwrap(); + let file = craft_psd::PsdFile::from_bytes(&bytes).unwrap(); let txt2 = file.global_blocks.iter().find(|b| &b.key == b"Txt2").and_then(|b| photocraft_text::psd::parse_txt2(&b.data)).expect("Txt2 written for stylistic sets"); let style = txt2 @@ -188,7 +188,7 @@ fn duplicate_preserved_text_indices_get_distinct_txt2_objects() { doc.layers.push(Layer::new(name, LayerContent::Text(t))); } let bytes = photocraft_io::export(&doc, "k.psd", &Default::default()).unwrap().bytes; - let file = photocraft_psd::PsdFile::from_bytes(&bytes).unwrap(); + let file = craft_psd::PsdFile::from_bytes(&bytes).unwrap(); let txt2 = file.global_blocks.iter().find(|b| &b.key == b"Txt2").and_then(|b| photocraft_text::psd::parse_txt2(&b.data)).unwrap(); assert_eq!(txt2.path(&["1", "1"]).and_then(E::as_array).unwrap().len(), 2); let back = photocraft_io::import("k.psd", &bytes).unwrap().document; @@ -278,7 +278,7 @@ fn add_type_layer(objects: &[(&str, &str)], kept: &[(&str, i32)], at: usize) -> t.psd_raw = Some(std::sync::Arc::new(photocraft_text::psd::build_tysh(&t, doc.resolution_dpi, None))); doc.layers.insert(at, Layer::new("Hi", LayerContent::Text(t))); let bytes = photocraft_io::export(&doc, "k.psd", &Default::default()).unwrap().bytes; - let file = photocraft_psd::PsdFile::from_bytes(&bytes).unwrap(); + let file = craft_psd::PsdFile::from_bytes(&bytes).unwrap(); let txt2 = file.global_blocks.iter().find(|b| &b.key == b"Txt2").and_then(|b| photocraft_text::psd::parse_txt2(&b.data)).unwrap(); let back = photocraft_io::import("k.psd", &bytes).unwrap().document; let layers: Vec<_> = back @@ -399,7 +399,7 @@ fn hostile_text_index_never_sizes_the_save() { r.psd_raw = Some(std::sync::Arc::new(photocraft_text::psd::set_text_index(&photocraft_text::psd::build_tysh(&r, 72.0, None), i32::MAX).unwrap())); doc.layers.push(Layer::new("hostile", LayerContent::Text(r))); let bytes = photocraft_io::export(&doc, "k.psd", &Default::default()).unwrap().bytes; - let file = photocraft_psd::PsdFile::from_bytes(&bytes).unwrap(); + let file = craft_psd::PsdFile::from_bytes(&bytes).unwrap(); let txt2 = file.global_blocks.iter().find(|b| &b.key == b"Txt2").and_then(|b| photocraft_text::psd::parse_txt2(&b.data)).unwrap(); let slots = txt2.path(&["1", "1"]).and_then(E::as_array).unwrap(); assert!(slots.len() <= 8, "a file-controlled TextIndex sized the save: {} slots", slots.len()); @@ -434,7 +434,7 @@ fn txt2_extras_survive_a_resave_of_unchanged_text() { doc.layers.push(Layer::new("kept", LayerContent::Text(r))); doc.metadata.psd_global_blocks.push((*b"8BIM", *b"Txt2", std::sync::Arc::new(prev.clone()))); let bytes = photocraft_io::export(&doc, "k.psd", &Default::default()).unwrap().bytes; - let file = photocraft_psd::PsdFile::from_bytes(&bytes).unwrap(); + let file = craft_psd::PsdFile::from_bytes(&bytes).unwrap(); photocraft_text::psd::parse_txt2(&file.global_blocks.iter().find(|b| &b.key == b"Txt2").unwrap().data).unwrap() }; let kept = save("AV"); @@ -507,7 +507,7 @@ fn photoshop_kerning_oracles() { ]; for (name, want, same_fields, worst_max, mean_max) in cases { let bytes = std::fs::read(dir.join(name)).unwrap_or_else(|e| panic!("{name}: {e}")); - let file = photocraft_psd::PsdFile::from_bytes(&bytes).unwrap(); + let file = craft_psd::PsdFile::from_bytes(&bytes).unwrap(); let txt2 = file.global_blocks.iter().find(|b| &b.key == b"Txt2").and_then(|b| photocraft_text::psd::parse_txt2(&b.data)).unwrap(); let doc = photocraft_io::import(name, &bytes).unwrap().document; let t = doc @@ -537,7 +537,7 @@ fn photoshop_kerning_oracles() { continue; } let index = match &tysh.text.get("TextIndex") { - Some(photocraft_psd::descriptor::Value::Integer(i)) => *i as usize, + Some(craft_psd::descriptor::Value::Integer(i)) => *i as usize, _ => panic!("{name}: no TextIndex"), }; let ps = photoshop_positions(&txt2, index).unwrap(); diff --git a/crates/io/tests/it/psd_layer_block_length.rs b/crates/io/tests/it/psd_layer_block_length.rs index 71f8e2552d..60c33ce7b2 100644 --- a/crates/io/tests/it/psd_layer_block_length.rs +++ b/crates/io/tests/it/psd_layer_block_length.rs @@ -7,9 +7,9 @@ use crate::common; use common::Features; +use craft_psd::PsdFile; use photocraft_color::{ColorMode, SampleType}; use photocraft_io::*; -use photocraft_psd::PsdFile; fn be(b: &[u8], at: usize, long: bool) -> usize { if long { u64::from_be_bytes(b[at..at + 8].try_into().unwrap()) as usize } else { u32::from_be_bytes(b[at..at + 4].try_into().unwrap()) as usize } diff --git a/crates/io/tests/it/psd_maximize_compatibility.rs b/crates/io/tests/it/psd_maximize_compatibility.rs index c8f277f6ee..825499b624 100644 --- a/crates/io/tests/it/psd_maximize_compatibility.rs +++ b/crates/io/tests/it/psd_maximize_compatibility.rs @@ -3,11 +3,11 @@ //! resource 1057 (`has_real_merged_data = false`). Layers, alpha channels and the Quick Mask are //! written as usual, so the file reopens the same. +use craft_psd::PsdFile; use photocraft_color::{ColorMode, PixelFormat, SampleType}; use photocraft_doc::{AlphaChannel, Document, Layer}; use photocraft_geom::{Rect, Size}; use photocraft_io::{ExportOptions, export, import}; -use photocraft_psd::PsdFile; use photocraft_raster::Surface; fn compat_off() -> ExportOptions { diff --git a/crates/io/tests/it/psd_noise_fill_roundtrip.rs b/crates/io/tests/it/psd_noise_fill_roundtrip.rs index e8fb38b88d..0dd55b1ec3 100644 --- a/crates/io/tests/it/psd_noise_fill_roundtrip.rs +++ b/crates/io/tests/it/psd_noise_fill_roundtrip.rs @@ -5,11 +5,11 @@ //! imported as pixels and the export dropped the `GdFl` (`RASTER_STALE_KEYS`), so opening and //! re-saving a file silently turned the fill layer into a plain pixel layer. +use craft_psd::descriptor::{Descriptor, Id, UnicodeString, Value, VersionedDescriptor}; +use craft_psd::testgen; +use craft_psd::{Compression, LayerRecord, PsdFile, TaggedBlock, Version}; use photocraft_doc::{Fill, LayerContent}; use photocraft_io::*; -use photocraft_psd::descriptor::{Descriptor, Id, UnicodeString, Value, VersionedDescriptor}; -use photocraft_psd::testgen; -use photocraft_psd::{Compression, LayerRecord, PsdFile, TaggedBlock, Version}; /// Photoshop's default Noise gradient (RGB, min 0/0/0, max 100/100/100) as `GdFl` block data. fn default_noise_gdfl() -> Vec { diff --git a/crates/io/tests/it/psd_recovery_generated.rs b/crates/io/tests/it/psd_recovery_generated.rs index 7b9e4bb2e7..51a7de340b 100644 --- a/crates/io/tests/it/psd_recovery_generated.rs +++ b/crates/io/tests/it/psd_recovery_generated.rs @@ -7,8 +7,8 @@ #[path = "../support/recovery_psd.rs"] mod recovery_psd; +use craft_psd::PsdFile; use photocraft_io::{ImportOptions, import, import_for_open}; -use photocraft_psd::PsdFile; /// Verifies a generated, two-layer PSD opens without source restrictions or damage prompts. #[test] @@ -68,7 +68,7 @@ fn damaged_composite_keeps_layers_and_requests_interactive_confirmation() { /// Verifies mismatched resolution metadata produces a warning but no damage prompt. #[test] fn ordinary_compatibility_warning_does_not_mean_damaged() { - use photocraft_psd::{ImageResource, ResolutionInfo, resources::ids}; + use craft_psd::{ImageResource, ResolutionInfo, resources::ids}; let mut file = PsdFile::from_bytes(&recovery_psd::make_psd(false)).unwrap(); let resolution = ResolutionInfo { v_res_fixed: ResolutionInfo::from_dpi(150.0).v_res_fixed, ..ResolutionInfo::from_dpi(300.0) }; file.resources.push(ImageResource::new(ids::RESOLUTION_INFO, resolution.to_bytes())); diff --git a/crates/io/tests/it/psd_roundtrip_bytes.rs b/crates/io/tests/it/psd_roundtrip_bytes.rs index dccd57856d..eb5dd2b45e 100644 --- a/crates/io/tests/it/psd_roundtrip_bytes.rs +++ b/crates/io/tests/it/psd_roundtrip_bytes.rs @@ -3,8 +3,8 @@ //! (verified by the corpus round-trip floors in `corpus.rs`; here the byte-equality of the //! codec layer itself is pinned). -use photocraft_psd::compression::{Compression, PlaneLayout, decode_planes, encode_planes}; -use photocraft_psd::header::Version; +use craft_psd::compression::{Compression, PlaneLayout, decode_planes, encode_planes}; +use craft_psd::header::Version; #[test] fn encode_decode_planes_round_trip_exactly() { diff --git a/crates/io/tests/it/psd_structure.rs b/crates/io/tests/it/psd_structure.rs index 900294af0f..104ab49c15 100644 --- a/crates/io/tests/it/psd_structure.rs +++ b/crates/io/tests/it/psd_structure.rs @@ -1,9 +1,9 @@ //! PSD (testgen / builder) → Document → PSD structural equality. +use craft_psd::testgen; +use craft_psd::{ColorMode, Compression, LayerNode, PsdFile, SectionType, Version}; use photocraft_doc::LayerContent; use photocraft_io::*; -use photocraft_psd::testgen; -use photocraft_psd::{ColorMode, Compression, LayerNode, PsdFile, SectionType, Version}; /// Sample of a layer channel in document coordinates (0 outside the rect). fn sample(f: &PsdFile, li: usize, id: i16, x: i32, y: i32, cache: &mut std::collections::HashMap<(usize, i16), Vec>) -> Vec { @@ -56,7 +56,7 @@ fn assert_structurally_equal(a: &PsdFile, b: &PsdFile) { let (ra, rb) = (&a.layers()[ia], &b.layers()[ib]); let n = ra.name(); assert_eq!(n, rb.name()); - let blend = if matches!(ra.blend_mode, photocraft_psd::BlendMode::Unknown(_)) { photocraft_psd::BlendMode::Normal } else { ra.blend_mode }; + let blend = if matches!(ra.blend_mode, craft_psd::BlendMode::Unknown(_)) { craft_psd::BlendMode::Normal } else { ra.blend_mode }; if ra.section_type().is_folder() { assert_eq!(ra.section_divider().and_then(|s| s.blend_mode), rb.section_divider().and_then(|s| s.blend_mode), "{n}"); } else { @@ -71,7 +71,7 @@ fn assert_structurally_equal(a: &PsdFile, b: &PsdFile) { continue; } // Pixels: compare every channel over the union of both rects. - let u = |r: photocraft_psd::Rect, s: photocraft_psd::Rect| (r.left.min(s.left), r.top.min(s.top), r.right.max(s.right), r.bottom.max(s.bottom)); + let u = |r: craft_psd::Rect, s: craft_psd::Rect| (r.left.min(s.left), r.top.min(s.top), r.right.max(s.right), r.bottom.max(s.bottom)); let mut ids: Vec = (-1..cc).collect(); if ra.layer_mask().is_some() && (ra.channel(-2).is_some() || ra.channel(-3).is_some()) { ids.push(-2); @@ -283,7 +283,7 @@ fn a_colour_book_ink_shows_its_lab_equivalent_or_the_grey_plate() { assert!(w.iter().any(|w| w.contains("shown as grayscale")), "{w:?}"); // With Photoshop's Lab equivalents (black, then the PANTONE teal) it shows in colour. let lab = vec![0, 1, 0, 2, 0, 7, 0x03, 0xB9, 0x01, 0xF2, 0x01, 0xA9, 0, 0, 0, 7, 0x11, 0xC5, 0xE6, 0x38, 0xFE, 0x0C, 0, 0]; - f.resources.push(photocraft_psd::ImageResource::new(1066, lab)); + f.resources.push(craft_psd::ImageResource::new(1066, lab)); let (d, w) = psd_to_document(&f); assert!(w.iter().all(|w| !w.contains("duotone")), "{w:?}"); let duo = d.duotone.as_ref().unwrap(); @@ -502,7 +502,7 @@ fn flattened_psd_becomes_background() { #[test] fn builder_psd_imports() { - use photocraft_psd::{GroupSpec, LayerSpec, MaskSpec, PixelData, PsdBuilder, Rect}; + use craft_psd::{GroupSpec, LayerSpec, MaskSpec, PixelData, PsdBuilder, Rect}; let mut b = PsdBuilder::new(4, 4); let mut s = LayerSpec::new("a", 0, 0, 2, 2, PixelData::Rgba8(vec![255, 0, 0, 255, 0, 255, 0, 128, 0, 0, 255, 0, 9, 9, 9, 9])); s.mask = Some(MaskSpec { rect: Rect::from_xywh(0, 0, 1, 1), data: vec![100], default_color: 255, disabled: true }); @@ -527,7 +527,7 @@ fn builder_psd_imports() { #[test] fn group_nesting_past_the_cap_is_rejected() { - use photocraft_psd::{GroupSpec, LayerSpec, PixelData, PsdBuilder}; + use craft_psd::{GroupSpec, LayerSpec, PixelData, PsdBuilder}; let mut b = PsdBuilder::new(4, 4); for _ in 0..101 { b.begin_group(GroupSpec::new("g")); @@ -541,14 +541,14 @@ fn group_nesting_past_the_cap_is_rejected() { assert!(err.to_string().contains("nested deeper than 100"), "{err}"); // The never-fail converter still returns a document — capped, with a warning // about the part that was not imported (it used to recurse to the file's depth). - let (d, warnings) = psd_to_document(&photocraft_psd::PsdFile::from_bytes(&bytes).unwrap()); + let (d, warnings) = psd_to_document(&craft_psd::PsdFile::from_bytes(&bytes).unwrap()); assert_eq!(d.max_group_depth(), 100); assert!(warnings.iter().any(|w| w.contains("deeper than 100")), "{warnings:?}"); } #[test] fn group_nesting_at_the_cap_imports() { - use photocraft_psd::{GroupSpec, LayerSpec, PixelData, PsdBuilder}; + use craft_psd::{GroupSpec, LayerSpec, PixelData, PsdBuilder}; let mut b = PsdBuilder::new(4, 4); for _ in 0..100 { b.begin_group(GroupSpec::new("g")); @@ -564,7 +564,7 @@ fn group_nesting_at_the_cap_imports() { #[test] fn layered_import_skips_the_unused_merged_composite() { - use photocraft_psd::{LayerSpec, PixelData, PsdBuilder}; + use craft_psd::{LayerSpec, PixelData, PsdBuilder}; // A layered file whose merged composite carries nothing behind the colour channels never // decodes it - that composite is roughly half a Photoshop save's bytes. Here its data is // garbage: the layered import does not touch it, while a file with an extra channel does @@ -572,7 +572,7 @@ fn layered_import_skips_the_unused_merged_composite() { let mut b = PsdBuilder::new(4, 4); b.push_layer(LayerSpec::new("a", 0, 0, 2, 2, PixelData::Rgba8(vec![7u8; 16]))); b.composite(PixelData::Rgba8(vec![128; 64])); - let mut f = photocraft_psd::PsdFile::from_bytes(&b.to_bytes().unwrap()).unwrap(); + let mut f = craft_psd::PsdFile::from_bytes(&b.to_bytes().unwrap()).unwrap(); f.image_data.data.clear(); f.layer_info.as_mut().unwrap().merged_alpha = true; let (d, warnings) = psd_to_document(&f); @@ -587,7 +587,7 @@ fn layered_import_skips_the_unused_merged_composite() { #[test] fn locks_and_labels_from_psd() { - use photocraft_psd::TaggedBlock; + use craft_psd::TaggedBlock; let mut f = testgen::small(Version::Psd, Compression::Raw); f.layers_mut()[0].blocks.push(TaggedBlock::protection(0b101)); f.layers_mut()[0].blocks.push(TaggedBlock::sheet_color(3)); @@ -602,7 +602,7 @@ fn artboard_lock_round_trips_byte_for_byte() { // Reference files write a Background layer's locks as lspf = 0x0D (transparency + position + // the bit-3 lock). PhotoCraft read the artboard lock at bit 4, so 0x0D came back as 0x05 // and the lock bytes drifted on every re-save. - use photocraft_psd::TaggedBlock; + use craft_psd::TaggedBlock; let mut f = testgen::small(Version::Psd, Compression::Raw); f.layers_mut()[0].blocks.push(TaggedBlock::protection(0x0D)); let g = via_doc(&f); @@ -612,7 +612,7 @@ fn artboard_lock_round_trips_byte_for_byte() { #[test] fn adjustment_and_fill_layers_from_psd() { - use photocraft_psd::{LayerRecord, TaggedBlock}; + use craft_psd::{LayerRecord, TaggedBlock}; let mut f = testgen::small(Version::Psd, Compression::Raw); let mk = |name: &str, key: &[u8; 4], data: Vec| LayerRecord { name: name.as_bytes().to_vec(), @@ -620,7 +620,7 @@ fn adjustment_and_fill_layers_from_psd() { ..Default::default() }; let soco = { - use photocraft_psd::descriptor::*; + use craft_psd::descriptor::*; let c = Descriptor::new("RGBC").with("Rd ", Value::Double(255.0)).with("Grn ", Value::Double(0.0)).with("Bl ", Value::Double(0.0)); VersionedDescriptor::new(Descriptor::new("null").with("Clr ", Value::Descriptor(c))).to_bytes() }; @@ -639,7 +639,7 @@ fn adjustment_and_fill_layers_from_psd() { #[test] fn unmodelled_blocks_are_preserved() { - use photocraft_psd::TaggedBlock; + use craft_psd::TaggedBlock; let mut f = testgen::small(Version::Psd, Compression::Raw); f.layers_mut()[0].blocks.push(TaggedBlock::new(*b"vmsk", vec![0; 8])); let (d, _) = psd_to_document(&f); @@ -650,10 +650,10 @@ fn unmodelled_blocks_are_preserved() { #[test] fn fill_layer_keeps_photoshop_pixels() { - use photocraft_psd::{ChannelData, LayerRecord, TaggedBlock}; + use craft_psd::{ChannelData, LayerRecord, TaggedBlock}; let mut f = testgen::small(Version::Psd, Compression::Raw); let soco = { - use photocraft_psd::descriptor::*; + use craft_psd::descriptor::*; let c = Descriptor::new("RGBC").with("Rd ", Value::Double(255.0)).with("Grn ", Value::Double(0.0)).with("Bl ", Value::Double(0.0)); VersionedDescriptor::new(Descriptor::new("null").with("Clr ", Value::Descriptor(c))).to_bytes() }; @@ -664,7 +664,7 @@ fn fill_layer_keeps_photoshop_pixels() { c.id = i as i16 - 1; } f.layers_mut().push(LayerRecord { - rect: photocraft_psd::Rect::from_xywh(0, 0, 2, 2), + rect: craft_psd::Rect::from_xywh(0, 0, 2, 2), name: b"fill".to_vec(), channels: chans, blocks: vec![TaggedBlock::unicode_name("fill"), TaggedBlock::new(*b"SoCo", soco)], @@ -689,7 +689,7 @@ fn fill_layer_keeps_photoshop_pixels() { #[test] fn text_and_smart_detected() { - use photocraft_psd::{LayerRecord, TaggedBlock}; + use craft_psd::{LayerRecord, TaggedBlock}; let mut f = testgen::small(Version::Psd, Compression::Raw); for (name, key) in [("t", b"TySh"), ("s", b"PlLd"), ("sh", b"vscg")] { // Type identification needs readable type data, not an eight-byte malformed stub. @@ -734,10 +734,10 @@ fn guides_and_alpha_names_roundtrip() { #[test] fn resolution_info_cm_display_unit_stays_pixels_per_inch() { let mut f = testgen::small(Version::Psd, Compression::Raw); - let mut ri = photocraft_psd::ResolutionInfo::from_dpi(300.0); + let mut ri = craft_psd::ResolutionInfo::from_dpi(300.0); ri.h_res_unit = 2; ri.v_res_unit = 2; - f.resources[0] = photocraft_psd::ImageResource::new(1005, ri.to_bytes()); + f.resources[0] = craft_psd::ImageResource::new(1005, ri.to_bytes()); let (d, _) = psd_to_document(&f); assert!((d.resolution_dpi - 300.0).abs() < 1e-3, "{}", d.resolution_dpi); let out = document_to_psd(&d).unwrap(); @@ -750,7 +750,7 @@ fn resolution_info_cm_display_unit_stays_pixels_per_inch() { fn stale_index_resources_dropped() { let mut f = testgen::small(Version::Psd, Compression::Raw); for id in [1024u16, 1026, 1036, 1057, 1069, 1072] { - f.resources.push(photocraft_psd::ImageResource::new(id, vec![0, 0])); + f.resources.push(craft_psd::ImageResource::new(id, vec![0, 0])); } let (d, _) = psd_to_document(&f); assert!(d.metadata.psd_resources.iter().all(|r| ![1024u16, 1026, 1036, 1057, 1069, 1072].contains(&r.0))); @@ -762,7 +762,7 @@ fn merged_composite_unmattes_photoshop_white() { let mut f = testgen::merged_only(Version::Psd, ColorMode::Rgb, 8, Compression::Raw, 1, 1); f.header.channels = 4; // straight red at alpha 0.5, matted: r = 1, g = b = 0.5 - f.image_data = photocraft_psd::ImageData::new(Compression::Raw, vec![255, 128, 128, 128]); + f.image_data = craft_psd::ImageData::new(Compression::Raw, vec![255, 128, 128, 128]); let m = merged_composite(&f).unwrap(); assert!((m[0][0] - 1.0).abs() < 0.01 && m[0][1] < 0.01 && m[0][2] < 0.01, "{:?}", m[0]); let (d, _) = psd_to_document(&f); @@ -772,7 +772,7 @@ fn merged_composite_unmattes_photoshop_white() { // HDR and negative straight samples must survive the actual F32 PSD import boundary. let mut hdr = testgen::merged_only(Version::Psd, ColorMode::Rgb, 32, Compression::Raw, 1, 1); hdr.header.channels = 4; - hdr.image_data = photocraft_psd::ImageData::new(Compression::Raw, [1.5_f32, 0.25, 0.5, 0.5].into_iter().flat_map(f32::to_be_bytes).collect()); + hdr.image_data = craft_psd::ImageData::new(Compression::Raw, [1.5_f32, 0.25, 0.5, 0.5].into_iter().flat_map(f32::to_be_bytes).collect()); assert_eq!(merged_composite(&hdr).unwrap(), vec![[2.0, -0.5, 0.0, 0.5]]); let mut deep = testgen::merged_only(Version::Psd, ColorMode::Rgb, 16, Compression::Raw, 1, 1); deep.header.channels = 4; @@ -785,14 +785,14 @@ fn merged_composite_unmattes_photoshop_white() { #[test] fn vector_rendered_mask_not_doubled_on_shapes() { - use photocraft_psd::{LayerRecord, TaggedBlock}; + use craft_psd::{LayerRecord, TaggedBlock}; let mut f = testgen::small(Version::Psd, Compression::Raw); - let mut mask = photocraft_psd::LayerMask::new(photocraft_psd::Rect::default(), 0, 8); + let mut mask = craft_psd::LayerMask::new(craft_psd::Rect::default(), 0, 8); mask.flags = 8; f.layers_mut().push(LayerRecord { name: b"shape".to_vec(), - mask: photocraft_psd::MaskData::Mask(mask), - channels: vec![photocraft_psd::ChannelData::new(-2, Some(Compression::Raw), vec![])], + mask: craft_psd::MaskData::Mask(mask), + channels: vec![craft_psd::ChannelData::new(-2, Some(Compression::Raw), vec![])], blocks: vec![TaggedBlock::unicode_name("shape"), TaggedBlock::new(*b"SoCo", vec![]), TaggedBlock::new(*b"vmsk", vec![0; 8])], ..Default::default() }); @@ -802,9 +802,9 @@ fn vector_rendered_mask_not_doubled_on_shapes() { assert!(l.mask.is_none()); let record = f.layers_mut().last_mut().unwrap(); - let photocraft_psd::MaskData::Mask(mask) = &mut record.mask else { panic!("fixture has a mask") }; - mask.real = Some(photocraft_psd::RealMask { flags: 0, background: 255, rect: photocraft_psd::Rect { top: 0, left: 0, bottom: 1, right: 1 } }); - record.channels.push(photocraft_psd::ChannelData::new(-3, Some(Compression::Raw), vec![64])); + let craft_psd::MaskData::Mask(mask) = &mut record.mask else { panic!("fixture has a mask") }; + mask.real = Some(craft_psd::RealMask { flags: 0, background: 255, rect: craft_psd::Rect { top: 0, left: 0, bottom: 1, right: 1 } }); + record.channels.push(craft_psd::ChannelData::new(-3, Some(Compression::Raw), vec![64])); let (imported, _) = psd_to_document(&f); assert_eq!(imported.layers.last().unwrap().content.kind_name(), "Shape"); let mask = imported.layers.last().unwrap().mask.as_ref().expect("selected real mask must survive synthetic cleanup"); @@ -817,8 +817,8 @@ fn vector_rendered_mask_not_doubled_on_shapes() { #[test] fn every_blend_mode_maps_both_ways() { for m in std::iter::once(photocraft_color::BlendMode::PassThrough).chain(photocraft_color::BlendMode::LAYER_MODES) { - let k = photocraft_psd::BlendMode::from_key(m.psd_key()); - assert!(!matches!(k, photocraft_psd::BlendMode::Unknown(_)), "{m:?}"); + let k = craft_psd::BlendMode::from_key(m.psd_key()); + assert!(!matches!(k, craft_psd::BlendMode::Unknown(_)), "{m:?}"); assert_eq!(photocraft_color::BlendMode::from_psd_key(k.key()), Some(m)); } } @@ -827,9 +827,9 @@ fn every_blend_mode_maps_both_ways() { fn layer_bounds_without_channel_data_allocate_nothing() { // A record can declare a 300000x300000 rectangle in a few bytes. With no channel data to // back it, import used to interleave a ~360 GB buffer and abort on the allocation (#755). - use photocraft_psd::LayerRecord; + use craft_psd::LayerRecord; let mut f = testgen::small(Version::Psd, Compression::Raw); - f.layers_mut().push(LayerRecord { rect: photocraft_psd::Rect::from_xywh(0, 0, 300_000, 300_000), name: b"huge".to_vec(), ..Default::default() }); + f.layers_mut().push(LayerRecord { rect: craft_psd::Rect::from_xywh(0, 0, 300_000, 300_000), name: b"huge".to_vec(), ..Default::default() }); let (d, warnings) = psd_to_document(&f); let l = d.layers.last().unwrap(); assert!(matches!(&l.content, LayerContent::Raster(s) if s.tile_count() == 0)); diff --git a/crates/io/tests/it/psd_style_sheets.rs b/crates/io/tests/it/psd_style_sheets.rs index 0634dd3f9c..292a4f8bb1 100644 --- a/crates/io/tests/it/psd_style_sheets.rs +++ b/crates/io/tests/it/psd_style_sheets.rs @@ -5,9 +5,9 @@ //! Reading only the Normal sheets left a centred paragraph style read as Left, so starting an //! edit re-laid the text out half a line to the side. +use craft_psd::descriptor::{Descriptor, Id, UnicodeString, Value as D}; use photocraft_doc::text::TextAlign; use photocraft_geom::Affine; -use photocraft_psd::descriptor::{Descriptor, Id, UnicodeString, Value as D}; use photocraft_text::engine_data::Value as E; use photocraft_text::psd::{TySh, text_layer_from_tysh, write_tysh}; diff --git a/crates/io/tests/it/psd_thumbnail.rs b/crates/io/tests/it/psd_thumbnail.rs index be6fbd360b..acfecfb05b 100644 --- a/crates/io/tests/it/psd_thumbnail.rs +++ b/crates/io/tests/it/psd_thumbnail.rs @@ -1,12 +1,12 @@ //! Preferences › File Handling › Image Previews: PSD/PSB saves embed Photoshop's thumbnail //! resource (1036: 28-byte header + JFIF, ≤ 160 px) unless the previews are turned off. +use craft_psd::PsdFile; +use craft_psd::resources::ids; use photocraft_color::{ColorMode, SampleType}; use photocraft_doc::{Document, Layer}; use photocraft_geom::Size; use photocraft_io::{ExportOptions, export, import}; -use photocraft_psd::PsdFile; -use photocraft_psd::resources::ids; /// A `w`×`h` document of `mode`/`depth` holding one layer of `rgba` (given as straight sRGB-ish /// RGBA and converted to the document's model), covering the left `cover` fraction. diff --git a/crates/io/tests/it/psd_type_bounds.rs b/crates/io/tests/it/psd_type_bounds.rs index 43ce38c674..571279efba 100644 --- a/crates/io/tests/it/psd_type_bounds.rs +++ b/crates/io/tests/it/psd_type_bounds.rs @@ -12,11 +12,11 @@ use crate::common; +use craft_psd::descriptor::{Descriptor, Id, UnicodeString, Value as D}; use photocraft_color::{ColorMode, SampleType}; use photocraft_doc::text::TextShape; use photocraft_doc::{Document, Layer, LayerContent, Size, TextLayer}; use photocraft_geom::{Affine, Rect}; -use photocraft_psd::descriptor::{Descriptor, Id, UnicodeString, Value as D}; use photocraft_raster::Surface; use photocraft_text::engine_data::Value as E; use photocraft_text::psd::{TySh, write_tysh}; @@ -246,7 +246,7 @@ fn import_case(c: &Case) -> (Rect, TextLayer, Document, Vec<[f32; 4]>) { t.psd_raw = Some(std::sync::Arc::new(data)); doc.layers.push(Layer::new(c.name, LayerContent::Text(t))); let bytes = photocraft_io::export(&doc, "t.psd", &Default::default()).unwrap().bytes; - let file = photocraft_psd::PsdFile::from_bytes(&bytes).unwrap(); + let file = craft_psd::PsdFile::from_bytes(&bytes).unwrap(); let merged = photocraft_io::merged_composite(&file).unwrap(); let back = photocraft_io::import("t.psd", &bytes).unwrap().document; let LayerContent::Text(bt) = &back.layers[0].content else { panic!("{}: not a type layer", c.name) }; @@ -370,10 +370,10 @@ fn hostile_type_sizes_without_pixels_import_without_a_huge_render() { let Some(mut t) = photocraft_text::psd::text_layer_from_tysh(&data, 72.0) else { // Unsafe affine data is now rejected at the boundary (#4758). Exercise the // actual file import too: with no saved pixels it must remain empty and safe. - let mut file = photocraft_psd::testgen::small(photocraft_psd::Version::Psd, photocraft_psd::Compression::Raw); - file.layers_mut().push(photocraft_psd::LayerRecord { + let mut file = craft_psd::testgen::small(craft_psd::Version::Psd, craft_psd::Compression::Raw); + file.layers_mut().push(craft_psd::LayerRecord { name: b"hostile".to_vec(), - blocks: vec![photocraft_psd::TaggedBlock::new(*b"TySh", data)], + blocks: vec![craft_psd::TaggedBlock::new(*b"TySh", data)], ..Default::default() }); let back = photocraft_io::import("t.psd", &file.to_bytes().unwrap()).unwrap(); diff --git a/crates/io/tests/it/psd_type_photoshop_bytes.rs b/crates/io/tests/it/psd_type_photoshop_bytes.rs index 98524892c4..1c8d642b37 100644 --- a/crates/io/tests/it/psd_type_photoshop_bytes.rs +++ b/crates/io/tests/it/psd_type_photoshop_bytes.rs @@ -9,8 +9,8 @@ use std::path::{Path, PathBuf}; -use photocraft_psd::PsdFile; -use photocraft_psd::descriptor::Value as D; +use craft_psd::PsdFile; +use craft_psd::descriptor::Value as D; use photocraft_text::engine_data::Value as E; fn corpus(rel: &str) -> PsdFile { @@ -69,6 +69,6 @@ fn tysh_trailing_bounds_match_photoshop() { fn version_info_matches_photoshop() { for file in ["text/antialias-smooth.psd", "text/warp-arc-50.psd"] { let theirs = corpus(file); - assert_eq!(photocraft_psd::resources::version_info_resource(true).data, theirs.resource(1057).unwrap().data, "{file}"); + assert_eq!(craft_psd::resources::version_info_resource(true).data, theirs.resource(1057).unwrap().data, "{file}"); } } diff --git a/crates/io/tests/it/psd_unknown_adjustment.rs b/crates/io/tests/it/psd_unknown_adjustment.rs index 58bc909c57..161c4a5e8c 100644 --- a/crates/io/tests/it/psd_unknown_adjustment.rs +++ b/crates/io/tests/it/psd_unknown_adjustment.rs @@ -3,10 +3,10 @@ //! of opening silently as an empty pixel layer (#6575). Pixel layers that merely carry an unknown //! block stay pixel layers. +use craft_psd::testgen; +use craft_psd::{ChannelData, Compression, LayerFlags, LayerRecord, PsdFile, Rect, TaggedBlock, Version}; use photocraft_doc::{Adjustment, Document, LayerContent}; use photocraft_io::*; -use photocraft_psd::testgen; -use photocraft_psd::{ChannelData, Compression, LayerFlags, LayerRecord, PsdFile, Rect, TaggedBlock, Version}; /// A layer record as Photoshop writes an adjustment layer: no pixels, "pixel data irrelevant" /// set, the adjustment block first, then the usual metadata. diff --git a/crates/io/tests/it/resolution_axes.rs b/crates/io/tests/it/resolution_axes.rs index fb2bd209e2..5aa0cd990b 100644 --- a/crates/io/tests/it/resolution_axes.rs +++ b/crates/io/tests/it/resolution_axes.rs @@ -4,12 +4,12 @@ use crate::common; use common::*; +use craft_psd::resources::ids; +use craft_psd::testgen; +use craft_psd::{Compression, ImageResource, ResolutionInfo, Version}; use photocraft_codecs::{ChannelLayout, EncodeOptions, Format, Image, Metadata, SampleType as CSample}; use photocraft_color::{ColorMode, SampleType}; use photocraft_io::*; -use photocraft_psd::resources::ids; -use photocraft_psd::testgen; -use photocraft_psd::{Compression, ImageResource, ResolutionInfo, Version}; fn resolution_notes(r: &ImportResult) -> Vec<&String> { r.warnings.iter().filter(|w| w.contains("vertical resolution")).collect() @@ -75,7 +75,7 @@ fn resolution_info_cm_display_unit_is_pixels_per_inch() { assert!(resolution_notes(&r).is_empty(), "{:?}", r.warnings); let out = export(&r.document, "out.psd", &ExportOptions::default()).unwrap(); - let file = photocraft_psd::PsdFile::from_bytes(&out.bytes).unwrap(); + let file = craft_psd::PsdFile::from_bytes(&out.bytes).unwrap(); let written = file.resolution().unwrap(); assert!((written.h_res() - 300.0).abs() < 1e-3, "{}", written.h_res()); assert!((written.v_res() - 300.0).abs() < 1e-3, "{}", written.v_res()); diff --git a/crates/io/tests/it/roundtrip.rs b/crates/io/tests/it/roundtrip.rs index 484368790e..4aa501133f 100644 --- a/crates/io/tests/it/roundtrip.rs +++ b/crates/io/tests/it/roundtrip.rs @@ -3,9 +3,9 @@ use crate::common; use common::*; +use craft_psd::PsdFile; use photocraft_color::{ColorMode, SampleType}; use photocraft_io::*; -use photocraft_psd::PsdFile; fn roundtrip(doc: &photocraft_doc::Document) -> photocraft_doc::Document { let r = export(doc, "x.psd", &ExportOptions::default()).expect("export"); @@ -224,8 +224,8 @@ fn edited_adjustment_regenerates_block_unedited_keeps_raw() { #[test] fn text_layer_roundtrip_with_tysh() { + use craft_psd::descriptor::{Descriptor, UnicodeString, Value, VersionedDescriptor}; use photocraft_doc::*; - use photocraft_psd::descriptor::{Descriptor, UnicodeString, Value, VersionedDescriptor}; // Minimal TySh: version, transform, text version, text descriptor, warp. let mut tysh = 1u16.to_be_bytes().to_vec(); for v in [1.0f64, 0.0, 0.0, 1.0, 3.0, 4.0] { @@ -268,7 +268,7 @@ fn text_layer_roundtrip_with_tysh() { fn effects_raw_roundtrip() { let mut d = gen_doc(ColorMode::Rgb, SampleType::U8, Features::PIXELS); let mut lfx = vec![0, 0, 0, 0]; - lfx.extend(photocraft_psd::testgen::sample_descriptor().to_bytes()); + lfx.extend(craft_psd::testgen::sample_descriptor().to_bytes()); // Typed items and the raw block must agree; the raw bytes are then kept. let (master, items) = photocraft_io::effects_map::parse_lfx2(&lfx).unwrap(); d.layers[1].effects.enabled = master; @@ -326,7 +326,7 @@ fn document_to_psd_direct_api() { fn big_document_goes_psb_with_warning() { let d = photocraft_doc::Document::new("big", photocraft_geom::Size::new(30_001, 1), ColorMode::Grayscale, SampleType::U8); let (f, w) = document_to_psd_with(&d, &PsdExportOptions::default()); - assert_eq!(f.header.version, photocraft_psd::Version::Psb); + assert_eq!(f.header.version, craft_psd::Version::Psb); assert!(w.iter().any(|w| w.contains("PSB"))); } @@ -551,7 +551,7 @@ fn blend_if_round_trips_as_blending_ranges() { assert_eq!(rec.blending_ranges.data, want); // Layers without Blend If keep full ranges for gray + R, G, B. let rb = f.layers().iter().find(|r| r.name() == "b").unwrap(); - assert_eq!(rb.blending_ranges, photocraft_psd::BlendingRanges::full(3)); + assert_eq!(rb.blending_ranges, craft_psd::BlendingRanges::full(3)); let back = roundtrip(&d); assert_eq!(back.layers[0].blend_if, bi); assert!(back.layers[1].blend_if.is_default()); diff --git a/crates/io/tests/it/smart_contents_identity.rs b/crates/io/tests/it/smart_contents_identity.rs index 5a0714b3d8..c5dd8145d8 100644 --- a/crates/io/tests/it/smart_contents_identity.rs +++ b/crates/io/tests/it/smart_contents_identity.rs @@ -2,11 +2,11 @@ use std::sync::Arc; +use craft_psd::{PsdFile, TaggedBlock}; use photocraft_color::{BlendMode, ColorMode, SampleType}; use photocraft_doc::{Document, Layer, LayerContent, SmartContentsId, SmartFilter, SmartObject, SmartSource}; use photocraft_geom::{Affine, Size}; use photocraft_io::{ExportOptions, export, import, linked, smart_map}; -use photocraft_psd::{PsdFile, TaggedBlock}; use photocraft_raster::Surface; fn fixture() -> Document { @@ -133,7 +133,7 @@ fn duplicated_imported_layer_keeps_source_but_gets_its_own_placement() { assert_eq!(original.placed, colliding); assert_ne!(original.placed, duplicate.placed); let filter_cache = after.global_blocks.iter().find(|b| &b.key == b"FEid").unwrap(); - let filter_cache = photocraft_psd::filter_effects::FilterEffects::parse(&filter_cache.data).unwrap(); + let filter_cache = craft_psd::filter_effects::FilterEffects::parse(&filter_cache.data).unwrap(); assert_eq!(filter_cache.items.len(), 1); assert_eq!(filter_cache.items[0].id, duplicate.placed); assert_eq!(after.layers().iter().find(|l| l.name() == "original").unwrap().block(b"SoLd").unwrap().data, original_raw); diff --git a/crates/io/tests/it/smart_corpus.rs b/crates/io/tests/it/smart_corpus.rs index f258c01103..db79a6119b 100644 --- a/crates/io/tests/it/smart_corpus.rs +++ b/crates/io/tests/it/smart_corpus.rs @@ -184,7 +184,7 @@ fn smart_objects_round_trip_through_psd() { Ok((back, out)) => { compare(&em, &back, true).into_iter().for_each(|e| fail("re-embedded", e)); // The old files are gone: one lnk2 item per distinct source. - let f = photocraft_psd::PsdFile::from_bytes(&out).unwrap(); + let f = craft_psd::PsdFile::from_bytes(&out).unwrap(); let items: Vec = f .global_blocks .iter() diff --git a/crates/io/tests/it/tiff_layers.rs b/crates/io/tests/it/tiff_layers.rs index 3c7815a37a..5eb2020e42 100644 --- a/crates/io/tests/it/tiff_layers.rs +++ b/crates/io/tests/it/tiff_layers.rs @@ -5,10 +5,10 @@ use crate::common; use common::*; +use craft_psd::tiff::{ByteOrder, ImageSourceData}; use photocraft_color::{ColorMode, SampleType}; use photocraft_doc::{Document, LayerContent}; use photocraft_io::*; -use photocraft_psd::tiff::{ByteOrder, ImageSourceData}; fn tiff(doc: &Document, layers: bool) -> ExportResult { export(doc, "x.tif", &ExportOptions { tiff_layers: layers, ..Default::default() }).expect("export") @@ -207,7 +207,7 @@ fn transparency_is_straight_in_the_composite() { /// transparency: it opens as no alpha channel, and a PSD save doesn't write it twice. #[test] fn photoshop_mtrn_marks_the_merged_transparency_of_a_layered_tiff() { - use photocraft_psd::{PsdFile, TaggedBlock}; + use craft_psd::{PsdFile, TaggedBlock}; for depth in [SampleType::U8, SampleType::U16] { let mut d = Document::new("t", photocraft_geom::Size::new(8, 6), ColorMode::Rgb, depth); let fmt = d.pixel_format(); @@ -248,7 +248,7 @@ fn big_documents_use_the_psb_signature() { let d = gen_doc(ColorMode::Rgb, SampleType::U8, Features::PIXELS); let r = export(&d, "x.tif", &ExportOptions { force_psb: true, tiff_layers: true, ..Default::default() }).unwrap(); let (_, layers) = tags(&r.bytes); - assert!(layers.unwrap().starts_with(photocraft_psd::tiff::SIGNATURE_PSB)); + assert!(layers.unwrap().starts_with(craft_psd::tiff::SIGNATURE_PSB)); let back = import("x.tif", &r.bytes).unwrap().document; assert_docs_eq(&d, &back); } @@ -509,7 +509,7 @@ fn dtf_like_spot_is_written_in_full() { } fn assert_spot_resources(resources: &[u8], name: &str) { - use photocraft_psd::tiff::resources_from_bytes; + use craft_psd::tiff::resources_from_bytes; let (blocks, warnings) = resources_from_bytes(resources).unwrap(); assert!(warnings.is_empty(), "{name} {warnings:?}"); let block = |id: u16| blocks.iter().find(|b| b.id == id).unwrap_or_else(|| panic!("{name} missing {id}")); @@ -693,7 +693,7 @@ fn ink(d: &Document) -> (f32, f32) { /// records. With `layers`, the layer data has a positive layer count and an `MTrn` block (the /// specification's key is `Mtrn`); without, the file is flat. Built from the PSD model of `doc`. fn photoshop_transparency_and_spot_tiff(doc: &Document, layers: bool) -> Vec { - use photocraft_psd::{Compression, TaggedBlock}; + use craft_psd::{Compression, TaggedBlock}; let opts = PsdExportOptions { merged_matte: false, merged_compression: Compression::Raw, ..Default::default() }; let (file, _) = document_to_psd_with(doc, &opts); @@ -703,7 +703,7 @@ fn photoshop_transparency_and_spot_tiff(doc: &Document, layers: bool) -> Vec let rgba: Vec = (0..n).flat_map(|i| (0..4).map(move |c| (i, c))).map(|(i, c)| planes[c * n + i]).collect(); let mut img = photocraft_codecs::Image::from_raw(4, 4, photocraft_codecs::ChannelLayout::Rgba, photocraft_codecs::SampleType::U8, rgba).unwrap(); img.meta.extra_samples = vec![photocraft_codecs::ExtraSample { kind: 0, data: planes[4 * n..].to_vec() }]; - img.meta.photoshop_resources = Some(photocraft_psd::tiff::resources_to_bytes(&file.resources).unwrap()); + img.meta.photoshop_resources = Some(craft_psd::tiff::resources_to_bytes(&file.resources).unwrap()); if layers { let mut isd = ImageSourceData::from_psd(&file); if let Some(info) = &mut isd.layer_info { @@ -721,7 +721,7 @@ fn photoshop_transparency_and_spot_tiff(doc: &Document, layers: bool) -> Vec /// must also drop the `MTrn` marker, or the spot would read back as transparency. #[test] fn photoshop_mtrn_tiff_opens_without_a_transparency_channel() { - use photocraft_psd::TaggedBlock; + use craft_psd::TaggedBlock; let bytes = photoshop_transparency_and_spot_tiff(&transparent_art_with_white_spot(), true); let opened = import("t.tif", &bytes).unwrap().document; @@ -884,10 +884,10 @@ fn assert_photoshop_layered_tiff(bytes: &[u8], with_spot: bool) { /// here. Everything round-trips, PSD → TIFF → PSD included. #[test] fn spot_ink_is_stored_as_zero_and_alpha_is_not() { + use craft_psd::PsdFile; use photocraft_color::{Color, PixelFormat}; use photocraft_doc::{AlphaChannel, Layer}; use photocraft_geom::{Rect, Size}; - use photocraft_psd::PsdFile; use photocraft_raster::Surface; for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { @@ -977,7 +977,7 @@ fn identifiers(data: &[u8]) -> Vec { /// The identifiers in a TIFF's resource 1053 (none without one). fn tiff_alpha_identifiers(bytes: &[u8]) -> Vec { let resources = tags(bytes).0.expect("photoshop resources"); - let (blocks, _) = photocraft_psd::tiff::resources_from_bytes(&resources).unwrap(); + let (blocks, _) = craft_psd::tiff::resources_from_bytes(&resources).unwrap(); blocks.iter().find(|b| b.id == 1053).map(|b| identifiers(&b.data)).unwrap_or_default() } @@ -1054,7 +1054,7 @@ fn default_export_options_write_a_flat_tiff() { /// limit error as a PSD, instead of being converted. #[test] fn layered_tiff_nested_past_the_cap_is_rejected() { - use photocraft_psd::{GroupSpec, LayerSpec, PixelData, PsdBuilder, PsdFile}; + use craft_psd::{GroupSpec, LayerSpec, PixelData, PsdBuilder, PsdFile}; let tiff_nested = |levels: usize| { let mut b = PsdBuilder::new(4, 4); for _ in 0..levels { @@ -1116,7 +1116,7 @@ fn channels_past_sixteen_leave_no_resources_behind() { let r = tiff(&doc, true); assert!(r.warnings.iter().any(|w| w.contains("19 extra channels") && w.contains("first 16")), "{:?}", r.warnings); let (resources, _) = tags(&r.bytes); - let (resources, _) = photocraft_psd::tiff::resources_from_bytes(&resources.expect("resources")).expect("parse resources"); + let (resources, _) = craft_psd::tiff::resources_from_bytes(&resources.expect("resources")).expect("parse resources"); let data = |id: u16| resources.iter().find(|r| r.id == id).map(|r| r.data.clone()).unwrap_or_default(); let pascal = data(1006); let (mut at, mut names) = (0usize, 0usize); diff --git a/crates/io/tests/it/tiff_save_options.rs b/crates/io/tests/it/tiff_save_options.rs index dc1b564156..3bdedc71a3 100644 --- a/crates/io/tests/it/tiff_save_options.rs +++ b/crates/io/tests/it/tiff_save_options.rs @@ -2,11 +2,11 @@ //! channels, and Save Image Pyramid. Unset Save Transparency is covered in `tiff_layers`. use crate::common::*; +use craft_psd::tiff::ImageSourceData; use photocraft_color::{Color, ColorMode, PixelFormat, SampleType}; use photocraft_doc::{AlphaChannel, Document, Layer, LayerContent}; use photocraft_geom::{Rect, Size}; use photocraft_io::*; -use photocraft_psd::tiff::ImageSourceData; use photocraft_raster::Surface; /// A 4×4 document whose one layer is opaque red in its middle and transparent around it, with a @@ -41,7 +41,7 @@ fn extra_samples_tag(bytes: &[u8]) -> Vec { /// and the alpha identifiers (1053). fn channel_resources(bytes: &[u8]) -> (Vec, usize, usize) { let tag = photocraft_codecs::tiff_photoshop_tags(bytes).0.expect("resources"); - let (resources, _) = photocraft_psd::tiff::resources_from_bytes(tag).expect("parse resources"); + let (resources, _) = craft_psd::tiff::resources_from_bytes(tag).expect("parse resources"); let data = |id: u16| resources.iter().find(|r| r.id == id).map(|r| r.data.clone()).unwrap_or_default(); let pascal = data(1006); let (mut at, mut names) = (0usize, Vec::new()); diff --git a/crates/io/tests/it/tiff_type_geometry.rs b/crates/io/tests/it/tiff_type_geometry.rs index 326ec35cee..ecf83065e0 100644 --- a/crates/io/tests/it/tiff_type_geometry.rs +++ b/crates/io/tests/it/tiff_type_geometry.rs @@ -1,11 +1,11 @@ //! Photoshop's mixed-order Intel TIFF type blocks and unsafe/unresolved type fallback (#4758). +use craft_psd::descriptor::{Value, VersionedDescriptor}; use photocraft_color::{ColorMode, SampleType}; use photocraft_doc::text::{CharStyle, TextRun}; use photocraft_doc::{Document, Layer, LayerContent, Size, TextLayer}; use photocraft_geom::Affine; use photocraft_io::{ExportOptions, PsdExportOptions, document_to_psd_with, export, import, psd_to_document}; -use photocraft_psd::descriptor::{Value, VersionedDescriptor}; fn document() -> Document { document_in(ColorMode::Rgb, SampleType::U8) @@ -36,7 +36,7 @@ fn photoshop_mixed_order_tiff_import_keeps_editable_geometry_and_pixels() { let blocks = image.meta.photoshop_layers.as_mut().unwrap(); let at = blocks.windows(8).position(|b| b == b"MIB8hSyT").unwrap() + 12; // Only the text version/descriptor switch order; warp and bounds remain little-endian. - let (_, used) = photocraft_psd::descriptor::VersionedDescriptor::parse_prefix(&canonical[52..]).unwrap(); + let (_, used) = craft_psd::descriptor::VersionedDescriptor::parse_prefix(&canonical[52..]).unwrap(); blocks[at + 50..at + 52 + used].copy_from_slice(&canonical[50..52 + used]); let bytes = photocraft_codecs::encode(&image, photocraft_codecs::Format::Tiff, &Default::default()).unwrap(); let opened = import("photoshop.tif", &bytes).unwrap(); diff --git a/crates/io/tests/it/vector.rs b/crates/io/tests/it/vector.rs index 71bf66823c..685d6e0a5d 100644 --- a/crates/io/tests/it/vector.rs +++ b/crates/io/tests/it/vector.rs @@ -7,6 +7,8 @@ use std::path::{Path as FsPath, PathBuf}; #[cfg(feature = "corpus")] use std::sync::Arc; +#[cfg(feature = "corpus")] +use craft_psd::PsdFile; use photocraft_color::{Color, ColorMode, SampleType}; use photocraft_doc::{ Document, Fill, Knot, Layer, LayerContent, LineCap, LineJoin, LiveShape, NamedPath, Path, PathOp, ShapeLayer, ShapeStroke, Size, StrokeAlign, Subpath, @@ -14,8 +16,6 @@ use photocraft_doc::{ }; use photocraft_geom::{Point, Rect}; use photocraft_io::*; -#[cfg(feature = "corpus")] -use photocraft_psd::PsdFile; fn roundtrip(d: &Document) -> Document { let r = export(d, "x.psd", &ExportOptions::default()).unwrap(); @@ -222,7 +222,7 @@ fn shape_density_and_feather_roundtrip_through_mask_parameters() { sh.cache = Some(photocraft_vector::render_shape(&sh, d.pixel_format(), d.bounds())); d.layers.push(Layer::new("Shape", LayerContent::Shape(sh))); let bytes = export(&d, "x.psd", &ExportOptions::default()).unwrap().bytes; - let file = photocraft_psd::PsdFile::from_bytes(&bytes).unwrap(); + let file = craft_psd::PsdFile::from_bytes(&bytes).unwrap(); let rec = file.layers().last().unwrap(); let p = rec.layer_mask().and_then(|m| m.parameters).expect("mask parameters"); assert_eq!((p.vector_density, p.vector_feather), (Some(128), Some(6.0)), "{depth:?}"); @@ -241,7 +241,7 @@ fn shape_density_and_feather_roundtrip_through_mask_parameters() { let mut d = doc(); d.layers.push(shape_layer(ShapeLayer { path: square, fill: Some(Fill::Solid(Color::BLACK)), ..ShapeLayer::default() })); let bytes = export(&d, "x.psd", &ExportOptions::default()).unwrap().bytes; - let file = photocraft_psd::PsdFile::from_bytes(&bytes).unwrap(); + let file = craft_psd::PsdFile::from_bytes(&bytes).unwrap(); assert!(file.layers().last().unwrap().layer_mask().and_then(|m| m.parameters).is_none()); let LayerContent::Shape(back) = &import("x.psd", &bytes).unwrap().document.layers[1].content else { panic!("a shape") }; assert_eq!((back.density, back.feather), (1.0, 0.0)); diff --git a/crates/psd/Cargo.toml b/crates/psd/Cargo.toml deleted file mode 100644 index 7ecce9ee67..0000000000 --- a/crates/psd/Cargo.toml +++ /dev/null @@ -1,31 +0,0 @@ -[package] -name = "photocraft-psd" -description = "Faithful, lossless reader and writer for Adobe Photoshop PSD (v1) and PSB (v2) files" -version.workspace = true -edition.workspace = true -license.workspace = true -rust-version.workspace = true - -[lib] -name = "photocraft_psd" -path = "src/lib.rs" - -[features] -# Real-file corpus tests (opt-in; `cargo xtask test-corpus`). Fail when the corpus is missing. -corpus = [] -default = [] -serde = ["dep:serde"] -testgen = [] - -[dependencies] -thiserror = { workspace = true } -flate2 = { workspace = true } -encoding_rs = { workspace = true } -serde = { workspace = true, optional = true } - -[dev-dependencies] -proptest = { workspace = true } -photocraft-psd = { path = ".", features = ["testgen"] } - -[lints] -workspace = true diff --git a/crates/psd/README.md b/crates/psd/README.md deleted file mode 100644 index 1f6c892af5..0000000000 --- a/crates/psd/README.md +++ /dev/null @@ -1,200 +0,0 @@ -# photocraft-psd - -A standalone reader and writer for Adobe Photoshop **PSD** (version 1) and **PSB** (version 2, "large document format") files. It keeps everything it reads, so an unmodified file writes back byte for byte. It depends on no other photocraft crate. - -* Clean-room implementation from Adobe's public *Photoshop File Formats Specification*. Where the spec is silent, behavior follows MIT-licensed psd-tools / ag-psd documentation, and the code comments say so. -* `#![forbid(unsafe_code)]`. Builds for `wasm32-unknown-unknown`. The core API works on byte slices; the file helpers only exist on native targets. -* Parsing never panics on malformed input. Limits: dimensions ≤ 300 000; allocations are checked against the remaining input; a single decode produces at most `MAX_DECODED_BYTES` (8 GiB on 64-bit targets, 2 GiB on 32-bit targets); descriptor nesting depth ≤ 64. - -## Guarantees - -| Property | How | -|---|---| -| `PsdFile::from_bytes(b)?.to_bytes()? == b` for unmodified files | Channel data keeps its original encoding. Unknown blocks and resources are stored raw. Exact padding is recorded wherever it can vary: tagged blocks, layer info, section trailers. | -| `from_bytes(to_bytes(f)) == f` for any model `f` | Canonical defaults: `padding: None` means "zero-pad to even", and the parser yields `None` whenever the file used that default. | -| Lazy decoding | The parser only copies encoded bytes. Channels are decoded when asked (`ChannelData::decode`, `ImageData::decode`). | -| Lazy reading | `PsdFile::from_source` reads only the structure. Channel data and the merged image stay in the source as `Stored` ranges and are read when decoded, so opening a 2 GB file doesn't need 2 GB for the file itself. The model equals what `from_bytes` builds, and it writes back the same bytes. | -| No compositing | `PsdBuilder` takes the merged image from the caller. Without one, it writes a white placeholder and sets `has_real_merged_data = false` in resource 1057. | - -## Public API (overview) - -```rust -// Top level -PsdFile::from_bytes(&[u8]) -> Result -PsdFile::from_bytes_and_merged(&[u8], want) -> Result<(PsdFile, Option>>)> - // + decode_merged() when want(&file): ZIP inflated once -PsdFile::from_source(Source) -> Result // channel data and merged image left in the source -PsdFile::from_source_and_merged_recovering(Source, want, recover) -PsdFile::to_bytes(&self) -> Result> -PsdFile::open(path) / save(path) // native only -PsdFile { header, color_mode_data, resources, layer_info, layer_info_placement, - global_layer_mask, global_blocks, layer_mask_trailing, image_data } -PsdFile::layers() -> &[LayerRecord] // file order = bottom-most first -PsdFile::layers_mut() -> &mut Vec -PsdFile::layer(i) -> Option>; iter_layers() -PsdFile::layer_tree() -> Vec // nested groups, children bottom-to-top -PsdFile::layer_tree_max_depth(n) -> Vec // same, groups nested n deep keep no children -PsdFile::decode_merged() -> Result> // planar big-endian samples -PsdFile::composite_rgba8() -> Result -PsdFile::resource(id), global_block(key), icc_profile(), resolution(), - has_real_merged_data(), merged_has_alpha(), validate() - -// Sources (lazy reading) -trait ReadAt { len(), read_at(offset, buf) } // [u8], Vec, Arc, FileSource (pread / seek_read) -Source::new(impl ReadAt) / read_vec(offset, len) // shared handle; equal when the same source -Stored { source, offset, len }::read() / read_part(at, len) - -// Header -Header { version: Version::{Psd,Psb}, channels, width, height, depth, color_mode, reserved } -ColorMode::{Bitmap, Grayscale, Indexed, Rgb, Cmyk, Multichannel, Duotone, Lab, Unknown(u16)} - -// Layers -LayerInfo { merged_alpha, layers: Vec, padding } -LayerRecord { rect, channels: Vec, blend_mode, opacity, clipping, flags, - filler, mask: MaskData, blending_ranges, name, blocks, extra_trailing } -LayerRecord::name() // prefers `luni` -LayerRecord::section_divider(), section_type(), layer_id(), fill_opacity(), is_visible(), - block(key), block_mut(key), channel(id), channel_rect(id), decode_channel(id, depth, version) -ChannelData { id, compression: Option, data, stored: Option } // encoded bytes kept verbatim -ChannelData::new(id, compression, data); encoded() -> Cow<[u8]>; encoded_len(); load() -ChannelData::encode(id, compression, decoded, w, h, depth, version) -ChannelData::decode(w, h, depth, version); set_decoded(...) -MaskData::{None, Mask(LayerMask), Raw(Vec)} -LayerMask { rect, default_color, flags, parameters: Option, real: Option, trailing, real_first } -BlendMode (27 modes + PassThrough + Unknown([u8;4])); BlendMode::key()/from_key() -Layer<'_>::rgba8() -> Result; user_mask() -> Option>; channel_bytes(id) - -// Tagged blocks -TaggedBlock { signature /* 8BIM | 8B64 */, key, data, padding: Option> } -TaggedBlock::parsed() -> Option> -BlockData::{UnicodeName, SectionDivider, LayerId, NameSource, BlendClippedAsGroup, - BlendInteriorElements, Knockout, Protection, SheetColor, FillOpacity, MetadataSetting} -constructors: unicode_name, section_divider, layer_id, name_source, blend_clipped_as_group, - blend_interior_elements, knockout, protection, sheet_color, fill_opacity -TaggedBlock::check_structure() -> Result<()> // strict inner re-parse of PlLd, SoLd, SoLE, lnk2/3/D, lfx2 -tagged::uses_long_length(version, key) // PSB 8-byte length keys (the spec's 13 + lnk3, lnkE, pths, extd, extn, FELS, cinf, artd) -LayerInfo::unpadded_len(version), LayerInfo::pad_to(version, 4) // Photoshop pads the layer info to 4 - -// Image resources -ImageResource { signature, id, name, data }; ImageResource::parsed() -> Option> -ResourceData::{ResolutionInfo, LayerState, LayerGroupInfo, Thumbnail, GlobalAngle, IccProfile, - GlobalAltitude, VersionInfo, Exif, Xmp, TimelineInfo, Animation} - -// Legacy animation syntax (no playback/document-model dependency) -animation::AnimationResource::from_bytes / to_bytes // maniIRFR; ordered raw records -animation::AnimationRecord::descriptor() // AnDs; version-16 descriptor -animation::{frame_info, frame_sets, layer_frame_states} // borrowed sparse views, opaque ids -animation::parse_descriptor // descriptor + up to 3 zero alignment bytes -metadata::MetadataItem::animation_descriptor() // mlst / tmln; leaves other metadata alone - -// Compression -Compression::{Raw, Rle, Zip, ZipPrediction, Unknown(u16)} -compression::{encode_planes, decode_planes, PlaneLayout, packbits::{encode, decode}, predict, unpredict} -pixels::{samples_u16, samples_f32, u16_to_bytes, f32_to_bytes, unpack_bits, plane_to_u8} - -// Descriptors -descriptor::{Descriptor, VersionedDescriptor, Value, Id, Class, ReferenceItem, UnicodeString, ObjectArray} -Descriptor::from_bytes / to_bytes; VersionedDescriptor::from_bytes / to_bytes (version 16) - -// Builder -PsdBuilder::new(w, h).color_mode(..).depth(8|16).version(..).compression(..).resolution(dpi).icc_profile(..) -builder.push_layer(LayerSpec) / begin_group(GroupSpec) / end_group()? / composite(PixelData) -builder.build() -> Result; builder.to_bytes() -PixelData::{Rgba8, Rgba16, GrayA8, GrayA16, Cmyka8, Cmyka16} // CMYK given as ink, stored inverted - -// Test generator (feature `testgen`) -testgen::{all_cases, merged_only, layered, small, sample_descriptor, pattern_plane} -``` - -## Feature matrix - -| Area | Read | Write | Typed | Notes | -|---|:-:|:-:|:-:|---| -| Header, PSD v1 / PSB v2 | ✓ | ✓ | ✓ | depth 1/8/16/32; all 8 color modes plus `Unknown` | -| Color mode data | ✓ | ✓ | raw | indexed palette is used by `composite_rgba8` | -| Image resources | ✓ | ✓ | partial | 1005, 1024, 1026, 1033/1036 (raw), 1037, 1039, 1049, 1057, 1058, 1060 | -| Layer records | ✓ | ✓ | ✓ | flags, clipping, filler, blending ranges (raw + accessor) | -| Layer masks | ✓ | ✓ | ✓ | 20 / 36-byte forms, mask parameters, real mask; unparseable masks stay raw | -| Channel ids -1 / -2 / -3 | ✓ | ✓ | ✓ | per-channel rect via `channel_rect` | -| Raw / RLE / ZIP / ZIP+prediction | ✓ | ✓ | – | prediction at 8/16/32 bits (32: byte-plane shuffle + delta); RLE counts are u16 in PSD, u32 in PSB | -| Merged image data | ✓ | ✓ | – | validated at parse time (sizes, RLE counts, zlib stream incl. checksum); a zlib stream declaring more than `MAX_DECODED_BYTES` is kept verbatim without inflating it, and decoding it reports the limit | -| Tagged blocks (raw passthrough) | ✓ | ✓ | – | keeps `8BIM` / `8B64` and exact padding | -| PSB 8-byte-length keys | ✓ | ✓ | – | LMsk Lr16 Lr32 Layr Mt16 Mt32 Mtrn Alph FMsk lnk2 FEid FXid PxSD | -| Lr16 / Lr32 / Layr layer info | ✓ | ✓ | ✓ | lifted into `layer_info`; re-inserted at its original position | -| luni lsct lsdk lyid lnsr clbl infx knko lspf lclr iOpa shmd | ✓ | ✓ | ✓ | `TaggedBlock::parsed()` | -| Layer XMP (`shmd` › `cust` › `layerXMP`) | ✓ | ✓ | ✓ | `metadata::layer_xmp` / `take_layer_xmp` / `put_layer_xmp`, laid out as Photoshop 2026 writes it | -| ActionDescriptor (all OSTypes) | ✓ | ✓ | ✓ | including references, `ObAr` (best effort), `UnFl`, `Pth `, version-16 wrapper | -| Legacy frame animation syntax | ✓ | ✓ | partial | `mani` / `AnDs`, `mlst`, `tmln`, resource 1075; raw-preserving descriptor views, not playback or keyframe evaluation | -| Layer tree (groups) | ✓ | ✓ | ✓ | open/closed folders plus bounding dividers; tolerant of malformed nesting | -| RGBA8 extraction | ✓ | – | – | RGB / gray / CMYK layers; merged image also supports indexed / bitmap / duotone | -| Builder | – | ✓ | – | RGB / gray / CMYK at 8/16 bit, masks, groups, blend modes, opacity, fill, visibility, clipping | -| Patterns | ✓ | ✓ | ✓ | `Patt` / `Pat2` / `Pat3`, `.pat`, and `.abr` embedded patterns; non-empty tiles allow in-bounds channel subrectangles, decoded size is capped and PackBits expansion is input-bounded | -| Actions (`.atn`) | ✓ | ✓ | ✓ | `atn::parse` / `atn::write`: version-16 sets (Photoshop 6+): actions with function key, modifiers and colour; steps with event code or string name, panel name, enabled/dialog flags and their descriptor. Exact round trip; version-12 (Photoshop 5) files are rejected as unsupported | - -Pattern channel subrectangles are padded with zeroes to the tile rectangle. All channel -metadata is checked before any plane is decoded, against one `PatternBudget` shared by every -pattern of a parse (a block, a `.pat` file): `MAX_DECODED_BYTES` decoded and 512 MiB of zero -padding in total. `parse_pattern_block_with` lets several blocks of one document share a budget; -`.abr` patterns are charged to the file's sample budget. All channels of a pattern must have the -same depth. Fully populated channels reuse their decoded buffer without a second copy; -allocations for padded channels return an error on failure. - -### Preserved raw, not yet typed (parsers planned for M8) - -* Layer effects: `lfx2`, `lrFX`, `lmfx`. The descriptors inside can be parsed on demand with `VersionedDescriptor::from_bytes(&block.data[4..])`. -* Type layers: `TySh`, `tySh`, and the EngineData inside them. -* Smart objects: `SoLd`, `PlLd`, `SoLE`, `lnk2`, `lnkD`, `lnk3`. -* Vector masks and shapes: `vmsk`, `vsms`, `vogk`, `vscg`. -* Adjustment and fill layers: `curv`, `levl`, `hue2`, `SoCo`, `GdFl`, `PtFl`, …, plus `Patt`, `Txt2`, `FMsk`, 3D and video blocks. -* Global layer mask info (raw, with accessors), thumbnails (raw), slices, guides, paths, and every other resource id. - -## Legacy animation inspection - -```sh -cargo run -p photocraft-psd --example animation_inspect -- /path/to/input.psd -cargo test -p photocraft-psd --test it animation:: -``` - -The inspection example accepts an explicit local PSD/PSB (up to 128 MiB), reports frame counts, -stored delays and descriptor counts, and checks byte-exact raw round-trips without modifying the -input. It does not print layer names, text or descriptor values. No personal file is bundled; -committed animation tests construct synthetic payloads and PSD/PSB containers. - -`mani` is detected by its payload signature within the plug-in resource range 4000–4999, not by -assuming that resource 4003 always belongs to animation. Unknown records and unsupported descriptor -versions stay raw. `FrIn` definitions and `FSts` playback order are separate; ids are opaque stored -`long` values. Missing frame delays remain absent, and sparse `mlst` properties are not filled with -guessed defaults. Timeline tracks, loops, inheritance and property evaluation are left to higher -layers. This parser alone does **not** make PSD animations play in PhotoCraft. - -## Testing - -`cargo test -p photocraft-psd --all-features` runs the unit tests, integration tests and proptests: - -* Byte stability and model round-trips for every generated case: all depths, color modes and compressions, PSD and PSB. -* PackBits edge cases. -* Known vectors for 8/16/32-bit prediction. -* PSD vs PSB length handling. -* Truncation sweeps: every prefix of several small files must return `Err`. -* Random-mutation and random-byte proptests: never panic. -* Layer tree cases. -* Builder → `rgba8` correctness. -* Unknown blend modes and unknown blocks pass through unchanged. - -`tests/it/corpus.rs` walks `corpus/psd/**/*.{psd,psb}` at the workspace root when that directory exists. For each file it asserts that parsing succeeds and the file round-trips byte for byte, and it prints a result per file. Without the directory it skips silently. - -A `cargo-fuzz` skeleton lives in `fuzz/`. It is its own workspace and is excluded from photocraft's. Run it with `cargo +nightly fuzz run parse` (or `descriptor`) from `crates/psd`. - -## Spec ambiguities and decisions - -* **Tagged block padding.** The spec says lengths are "rounded up to an even byte count". In practice writers either include the padding in the length or append it after the data, and pad to 2 or 4. The parser detects the padding that actually follows each block: the smallest k ≤ 3 after which the next signature or the end of the region appears. It stores that padding unless it matches the default. -* **Layer info padding** (2 vs 4) is recorded the same way. -* **16/32-bit layers** are read from global `Lr16` / `Lr32` blocks when the main layer info is empty. The block data is a layer-info body with no inner length field. -* **Mask record layout.** The order is: rect, default color, flags, parameters (when flag bit 4 is set), then the real flags, background and rect whenever at least 18 bytes remain. Anything left over (for example the 2 padding bytes of the 20-byte form) is kept in `trailing`. -* **Mask channel rects.** Channel -2 uses the mask rect. Channel -3 uses the real mask rect. -* **Merged ZIP data** is a single zlib stream over all planes. Prediction is applied per row across all planes. -* **ZIP prediction at depth 1** is treated as byte-wise delta. -* **`ObAr`** is read as a u32 prefix followed by a descriptor-shaped body, following ag-psd's reading. The spec does not document it. -* **Descriptor `bool`** bytes other than 0/1 are normalized to 1 on write. -* **Pascal names** have their pad bytes written as zeros. A layer & mask section that contains only a zero layer-info length is normalized to an empty section. These are the only known non-byte-exact cases, and both are pathological. -* **Header limits.** The parser accepts up to 300 000 × 300 000 for both versions. `PsdFile::validate()` enforces the spec's 30 000 limit for PSD. diff --git a/crates/psd/examples/animation_inspect.rs b/crates/psd/examples/animation_inspect.rs deleted file mode 100644 index 2b3f7391dc..0000000000 --- a/crates/psd/examples/animation_inspect.rs +++ /dev/null @@ -1,103 +0,0 @@ -//! Inspect a local PSD/PSB's animation structure without printing layer names, text, or metadata -//! values. No asset is bundled: pass a path explicitly. Does not decode pixels or modify the file. -//! `cargo run -p photocraft-psd --example animation_inspect -- /path/to/input.psd` - -#[cfg(not(target_arch = "wasm32"))] -fn inspect() -> Result<(), Box> { - use std::collections::BTreeMap; - use std::io::Read; - - use photocraft_psd::animation::{frame_info, frame_sets, layer_frame_states}; - use photocraft_psd::metadata::parse_shmd; - use photocraft_psd::{PsdFile, ResourceData}; - - let mut args = std::env::args_os().skip(1); - let path = args.next().ok_or("usage: animation_inspect ")?; - if args.next().is_some() { - return Err("expected exactly one input path".into()); - } - // Diagnostic tool, not the production large-file importer. Bound even a file growing while read. - const MAX_BYTES: u64 = 128 * 1024 * 1024; - let file = std::fs::File::open(path)?; - if !file.metadata()?.is_file() || file.metadata()?.len() > MAX_BYTES { - return Err("expected a regular PSD/PSB no larger than 128 MiB".into()); - } - let mut bytes = Vec::new(); - file.take(MAX_BYTES + 1).read_to_end(&mut bytes)?; - if bytes.len() as u64 > MAX_BYTES { - return Err("input exceeds the diagnostic tool's 128 MiB limit".into()); - } - let psd = PsdFile::from_bytes(&bytes)?; - println!("canvas={}x{} depth={} layers={}", psd.header.width, psd.header.height, psd.header.depth, psd.layers().len()); - let mut animations = 0; - for resource in &psd.resources { - match resource.parsed() { - Some(Ok(ResourceData::Animation(animation))) => { - animations += 1; - let back = animation.to_bytes()?; - if back != resource.data { - return Err("animation resource failed its byte-exact round trip".into()); - } - println!("animation resource: {} records; byte-exact round trip", animation.records.len()); - for record in &animation.records { - if let Some(d) = record.descriptor() { - let d = d?; - let frames = frame_info(&d.descriptor)?; - let sets = frame_sets(&d.descriptor)?; - let mut delays = BTreeMap::new(); - for frame in &frames { - *delays.entry(frame.delay_centiseconds).or_insert(0usize) += 1; - } - println!(" AnDs: {} definitions, {} frame sets", frames.len(), sets.len()); - println!(" stored delays (centiseconds -> frame count): {delays:?}"); - for set in sets { - let unresolved = set.frame_ids.iter().filter(|id| !frames.iter().any(|f| f.id == **id)).count(); - println!(" frame set: {} ordered references, {unresolved} unresolved", set.frame_ids.len()); - } - } - } - } - Some(Ok(ResourceData::TimelineInfo(d))) => println!("document timeline: {} descriptor fields", d.descriptor.items.len()), - Some(Err(e)) if resource.id == 1075 || (4000..=4999).contains(&resource.id) && resource.data.starts_with(b"mani") => return Err(e.into()), - _ => {} - } - } - let (mut states, mut tracks) = (0usize, 0usize); - for layer in psd.layers() { - for block in &layer.blocks { - if block.key != *b"shmd" { - continue; - } - let items = parse_shmd(&block.data)?; - for item in &items { - if let Some(d) = item.animation_descriptor() { - let d = d?; - if item.key == *b"mlst" { - layer_frame_states(&d.descriptor)?; - states += 1; - } else { - tracks += 1; - } - } - } - if photocraft_psd::metadata::write_shmd(&items) != block.data { - return Err("layer metadata failed its byte-exact round trip".into()); - } - } - } - if psd.to_bytes()? != bytes { - return Err("PSD/PSB failed its byte-exact round trip".into()); - } - println!("animation resources={animations}; layer frame-state descriptors={states}; layer timeline descriptors={tracks}"); - println!("whole PSD/PSB: byte-exact round trip; input unchanged"); - Ok(()) -} - -fn main() -> std::process::ExitCode { - #[cfg(not(target_arch = "wasm32"))] - if let Err(e) = inspect() { - eprintln!("animation inspection failed: {e}"); - return std::process::ExitCode::FAILURE; - } - std::process::ExitCode::SUCCESS -} diff --git a/crates/psd/examples/atn_dump.rs b/crates/psd/examples/atn_dump.rs deleted file mode 100644 index 0714ab160b..0000000000 --- a/crates/psd/examples/atn_dump.rs +++ /dev/null @@ -1,91 +0,0 @@ -//! Print the actions and steps of `.atn` files, one event per line, with the top-level keys of -//! each step's descriptor: `cargo run -p photocraft-psd --example atn_dump -- set.atn…` -//! -//! Handy when adding a Photoshop event to the engine's `.atn` import table: it shows the event -//! id, the panel name and the parameter keys Photoshop recorded. - -use photocraft_psd::atn; -use photocraft_psd::descriptor::{Descriptor, ReferenceItem, Value}; - -/// Escapes control characters so a file's names cannot drive the terminal. -fn clean(s: &str) -> String { - s.chars().flat_map(char::escape_debug).collect() -} - -fn summary(v: &Value) -> String { - match v { - Value::Integer(i) => i.to_string(), - Value::Double(d) => format!("{d}"), - Value::UnitFloat { unit, value } => format!("{value}{}", String::from_utf8_lossy(unit)), - Value::Boolean(b) => b.to_string(), - Value::Text(t) => format!("{:?}", t.to_string_lossy()), - Value::Enumerated { type_id, value } => format!("{}.{}", String::from_utf8_lossy(type_id.as_bytes()), String::from_utf8_lossy(value.as_bytes())), - Value::Descriptor(d) | Value::GlobalObject(d) => format!("{{{}}}", keys(d)), - Value::List(l) => format!("[{}]", l.iter().map(summary).collect::>().join(", ")), - Value::Reference(r) => format!("ref[{}]", r.iter().map(reference).collect::>().join(" ")), - other => format!("{other:?}").chars().take(40).collect(), - } -} - -fn id(i: &photocraft_psd::descriptor::Id) -> String { - String::from_utf8_lossy(i.as_bytes()).trim().to_string() -} - -fn reference(item: &ReferenceItem) -> String { - match item { - ReferenceItem::Property { class, key } => format!("prop({},{})", id(&class.class_id), id(key)), - ReferenceItem::Class(c) => format!("Clss({})", id(&c.class_id)), - ReferenceItem::Enumerated { class, type_id, value } => format!("Enmr({},{}.{})", id(&class.class_id), id(type_id), id(value)), - ReferenceItem::Offset { class, offset } => format!("rele({},{offset})", id(&class.class_id)), - ReferenceItem::Identifier { class, id: v } => format!("Idnt({},{v})", id(&class.class_id)), - ReferenceItem::Index { class, index } => format!("indx({},{index})", id(&class.class_id)), - ReferenceItem::Name { class, name } => format!("name({},{:?})", id(&class.class_id), name.to_string_lossy()), - } -} - -fn keys(d: &Descriptor) -> String { - d.items.iter().map(|(k, v)| format!("{}={}", String::from_utf8_lossy(k.as_bytes()), summary(v))).collect::>().join(" ") -} - -fn main() { - let mut failed = false; - for path in std::env::args().skip(1) { - let bytes = match std::fs::read(&path) { - Ok(b) => b, - Err(e) => { - eprintln!("{}: {e}", clean(&path)); - failed = true; - continue; - } - }; - match atn::parse(&bytes) { - Ok(f) => { - println!( - "== {}: set {:?}, {} actions{}{}", - clean(&path), - f.name.to_string_lossy(), - f.actions.len(), - f.encoding.as_deref().map(|e| format!(", names in {}", clean(e))).unwrap_or_default(), - if f.warnings.is_empty() { String::new() } else { format!(" ({})", clean(&f.warnings.join("; "))) } - ); - for a in &f.actions { - println!(" action {:?} ({} steps)", a.name.to_string_lossy(), a.items.len()); - for item in &a.items { - let flags = format!("{}{}", if item.enabled { "" } else { " off" }, if item.with_dialog { " dialog" } else { "" }); - match &item.descriptor { - Some(d) => println!(" {:<22} {:<28}{flags} {}", clean(&item.event.id()), clean(&f.item_label(item)), clean(&keys(d))), - None => println!(" {:<22} {:<28}{flags}", clean(&item.event.id()), clean(&f.item_label(item))), - } - } - } - } - Err(e) => { - println!("== {}: {}", clean(&path), clean(&e.to_string())); - failed = true; - } - } - } - if failed { - std::process::exit(1); - } -} diff --git a/crates/psd/examples/atn_roundtrip.rs b/crates/psd/examples/atn_roundtrip.rs deleted file mode 100644 index b73151b745..0000000000 --- a/crates/psd/examples/atn_roundtrip.rs +++ /dev/null @@ -1,54 +0,0 @@ -//! Check `.atn` files for byte-exact round trips: parse, write, compare, and show the first -//! difference with its context: `cargo run -p photocraft-psd --example atn_roundtrip -- set.atn…` -//! -//! Exit status 1 when any file differs or fails to parse. - -use photocraft_psd::atn; - -fn hex(b: &[u8]) -> String { - b.iter().map(|x| format!("{x:02x}")).collect::>().join(" ") -} - -fn main() { - let mut failed = false; - for path in std::env::args().skip(1) { - let bytes = match std::fs::read(&path) { - Ok(b) => b, - Err(e) => { - eprintln!("{path}: {e}"); - failed = true; - continue; - } - }; - let file = match atn::parse(&bytes) { - Ok(f) => f, - Err(e) => { - println!("{path}: parse failed: {e}"); - failed = true; - continue; - } - }; - let written = match atn::write(&file) { - Ok(w) => w, - Err(e) => { - println!("{path}: write failed: {e}"); - failed = true; - continue; - } - }; - if written == bytes { - println!("{path}: identical ({} bytes)", bytes.len()); - continue; - } - failed = true; - let at = bytes.iter().zip(&written).position(|(a, b)| a != b).unwrap_or(bytes.len().min(written.len())); - let lo = at.saturating_sub(24); - println!("{path}: differs at byte {at} (original {} bytes, rewritten {} bytes)", bytes.len(), written.len()); - println!(" original : {}", hex(bytes.get(lo..(at + 40).min(bytes.len())).unwrap_or(&[]))); - println!(" rewritten: {}", hex(written.get(lo..(at + 40).min(written.len())).unwrap_or(&[]))); - println!(" original : {}", String::from_utf8_lossy(bytes.get(lo..(at + 40).min(bytes.len())).unwrap_or(&[])).replace(|c: char| c.is_control(), ".")); - } - if failed { - std::process::exit(1); - } -} diff --git a/crates/psd/examples/bench_layer_block.rs b/crates/psd/examples/bench_layer_block.rs deleted file mode 100644 index 4e48f4b3dc..0000000000 --- a/crates/psd/examples/bench_layer_block.rs +++ /dev/null @@ -1,39 +0,0 @@ -//! Serialization of one 24 MP raw RGB layer in Lr16/Lr32, without compression or disk I/O. -//! Run with `cargo run --release -p photocraft-psd --example bench_layer_block`. -use photocraft_psd::{ChannelData, ColorMode, Compression, Header, ImageData, LayerInfo, LayerInfoPlacement, LayerRecord, Rect, Result, Version, testgen}; -use std::hint::black_box; -use std::time::Instant; - -fn main() -> Result<()> { - for depth in [16, 32] { - let mut file = testgen::small(Version::Psd, Compression::Raw); - file.header = Header::new(Version::Psd, 6000, 4000, 3, depth, ColorMode::Rgb); - let plane_bytes = 6000 * 4000 * usize::from(depth / 8); - let mut layer = LayerRecord { rect: Rect::from_xywh(0, 0, 6000, 4000), name: b"RGB".to_vec(), ..Default::default() }; - for id in 0..3 { - layer.channels.push(ChannelData::new(id, Some(Compression::Raw), vec![0; plane_bytes])); - } - file.layer_info = Some(LayerInfo { merged_alpha: false, layers: vec![layer], padding: None }); - file.layer_info_placement = - LayerInfoPlacement::GlobalBlock { index: 0, signature: *b"8BIM", key: if depth == 16 { *b"Lr16" } else { *b"Lr32" }, padding: None }; - file.image_data = ImageData::new(Compression::Raw, vec![0; plane_bytes * 3]); - black_box(file.to_bytes()?); - let mut samples = Vec::new(); - let mut size = 0; - for _ in 0..11 { - let t = Instant::now(); - let bytes = file.to_bytes()?; - samples.push(t.elapsed().as_secs_f64() * 1000.0); - size = bytes.len(); - black_box(bytes); - } - samples.sort_by(f64::total_cmp); - println!( - "{depth}-bit, {} bytes: p50={:.3} ms p95={:.3} ms", - size, - samples.get(5).copied().unwrap_or_default(), - samples.get(10).copied().unwrap_or_default() - ); - } - Ok(()) -} diff --git a/crates/psd/examples/big_psb.rs b/crates/psd/examples/big_psb.rs deleted file mode 100644 index 23bf4de776..0000000000 --- a/crates/psd/examples/big_psb.rs +++ /dev/null @@ -1,24 +0,0 @@ -//! Writes multi-gigabyte PSB files (and the 2 GB PSD) for large-file tests (#375, #216), straight -//! from the Adobe PSD spec and independent of this crate's writer, so the fixtures don't inherit -//! its limits. The generator is `photocraft_psd::fixtures` (feature `testgen`). -//! -//! `cargo run --release -p photocraft-psd --example big_psb -- [case...]` -//! -//! Every sample follows `fixtures::sample`, so a reader can check any pixel without the file. RLE -//! cases keep each row constant (tiny files, full decoded size); raw cases vary along x too. - -use photocraft_psd::fixtures::{PSB_CASES, PSD_2GB}; - -fn main() -> std::io::Result<()> { - let args: Vec = std::env::args().skip(1).collect(); - let out = args.first().map_or_else(|| std::env::temp_dir().join("photocraft-big-psb"), std::path::PathBuf::from); - std::fs::create_dir_all(&out)?; - let wanted = args.get(1..).unwrap_or(&[]); - for case in PSB_CASES.iter().chain([&PSD_2GB]).filter(|c| wanted.is_empty() || wanted.iter().any(|w| w == c.name)) { - let path = out.join(case.file_name()); - let t = std::time::Instant::now(); - let len = case.write_file(&path)?; - println!("{}: {:.2} GB in {:.1} s", path.display(), len as f64 / 1e9, t.elapsed().as_secs_f64()); - } - Ok(()) -} diff --git a/crates/psd/fuzz/.gitignore b/crates/psd/fuzz/.gitignore deleted file mode 100644 index 5c404b9583..0000000000 --- a/crates/psd/fuzz/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -target -corpus -artifacts -coverage -Cargo.lock diff --git a/crates/psd/fuzz/Cargo.toml b/crates/psd/fuzz/Cargo.toml deleted file mode 100644 index 8682bbb725..0000000000 --- a/crates/psd/fuzz/Cargo.toml +++ /dev/null @@ -1,114 +0,0 @@ -[package] -name = "photocraft-psd-fuzz" -version = "0.0.0" -publish = false -edition = "2024" -license = "MIT OR Apache-2.0" - -# Standalone: excluded from the photocraft workspace. -[workspace] - -[package.metadata] -cargo-fuzz = true - -[dependencies] -libfuzzer-sys = "0.4" -photocraft-psd = { path = ".." } - -[[bin]] -name = "parse" -path = "fuzz_targets/parse.rs" -test = false -doc = false -bench = false - -[[bin]] -name = "descriptor" -path = "fuzz_targets/descriptor.rs" -test = false -doc = false -bench = false - -[[bin]] -name = "abr" -path = "fuzz_targets/abr.rs" -test = false -doc = false -bench = false - -[[bin]] -name = "asl" -path = "fuzz_targets/asl.rs" -test = false -doc = false -bench = false - -[[bin]] -name = "image_source_data" -path = "fuzz_targets/image_source_data.rs" -test = false -doc = false -bench = false - -[[bin]] -name = "grd" -path = "fuzz_targets/grd.rs" -test = false -doc = false -bench = false - -[[bin]] -name = "ase" -path = "fuzz_targets/ase.rs" -test = false -doc = false -bench = false - -[[bin]] -name = "aco" -path = "fuzz_targets/aco.rs" -test = false -doc = false -bench = false - -[[bin]] -name = "patterns" -path = "fuzz_targets/patterns.rs" -test = false -doc = false -bench = false - -[[bin]] -name = "filter_effects" -path = "fuzz_targets/filter_effects.rs" -test = false -doc = false -bench = false - -[[bin]] -name = "metadata" -path = "fuzz_targets/metadata.rs" -test = false -doc = false -bench = false - -[[bin]] -name = "tagged" -path = "fuzz_targets/tagged.rs" -test = false -doc = false -bench = false - -[[bin]] -name = "resources" -path = "fuzz_targets/resources.rs" -test = false -doc = false -bench = false - -[[bin]] -name = "atn" -path = "fuzz_targets/atn.rs" -test = false -doc = false -bench = false diff --git a/crates/psd/fuzz/fuzz_targets/abr.rs b/crates/psd/fuzz/fuzz_targets/abr.rs deleted file mode 100644 index 1cada105ec..0000000000 --- a/crates/psd/fuzz/fuzz_targets/abr.rs +++ /dev/null @@ -1,14 +0,0 @@ -//! Fuzz the `.abr` brush parser: any input must return `Ok` or `Err`, never panic, and anything -//! that parses must keep its samples within the advertised bounds. -#![no_main] - -use libfuzzer_sys::fuzz_target; -use photocraft_psd::abr; - -fuzz_target!(|data: &[u8]| { - if let Ok(f) = abr::parse(data) { - for s in &f.samples { - assert_eq!(s.data.len(), s.width as usize * s.height as usize * usize::from(s.depth / 8)); - } - } -}); diff --git a/crates/psd/fuzz/fuzz_targets/aco.rs b/crates/psd/fuzz/fuzz_targets/aco.rs deleted file mode 100644 index c655bfab0f..0000000000 --- a/crates/psd/fuzz/fuzz_targets/aco.rs +++ /dev/null @@ -1,21 +0,0 @@ -//! Fuzz the `.aco` Photoshop Color Swatches parser: any input must return `Ok` or `Err`, never -//! panic; anything that parses must round-trip through `write` and re-parse with the same swatch -//! count, and no name may exceed the name-unit cap. -//! -//! `MAX_SWATCHES` is `u16::MAX` and the on-disk count is a `u16`, so the count is unfalsifiable by -//! construction; the assertion is kept as documentation of that invariant, not as a check that can -//! fail. The name cap, which is reachable, is the one that bites. -#![no_main] -use libfuzzer_sys::fuzz_target; -use photocraft_psd::aco; -fuzz_target!(|data: &[u8]| { - let Ok(f) = aco::parse(data) else { return }; - assert!(f.swatches.len() <= aco::MAX_SWATCHES, "{} swatches exceeds MAX_SWATCHES", f.swatches.len()); - for s in &f.swatches { - assert!(s.name.encode_utf16().count() <= aco::MAX_NAME_UNITS, "name longer than MAX_NAME_UNITS"); - } - let out = aco::write(&f.swatches).expect("a parsed .aco must be writable"); - let back = aco::parse(&out).expect("a written .aco must re-parse"); - assert_eq!(back.swatches.len(), f.swatches.len(), "swatch count changed across a round trip"); - assert_eq!(aco::write(&back.swatches).expect("rewritable"), out, "a second round trip is not byte-stable"); -}); diff --git a/crates/psd/fuzz/fuzz_targets/ase.rs b/crates/psd/fuzz/fuzz_targets/ase.rs deleted file mode 100644 index 23a1a9d6b9..0000000000 --- a/crates/psd/fuzz/fuzz_targets/ase.rs +++ /dev/null @@ -1,39 +0,0 @@ -//! Fuzz the `.ase` Adobe Swatch Exchange parser: any input must return `Ok` or `Err`, never panic; -//! anything that parses must hold only finite colour components, stay within the swatch cap and -//! round-trip through `write` and re-parse with the same number of entries. -//! -//! A colour outside any group is a legitimate `AseEntry::Swatch`, and a group left open at the -//! end is closed by the parser, so entry shape is checked only through the round trip below. -//! The cap assertion is an invariant, not a fast check: no 4 KB input can carry 100 001 blocks. -#![no_main] -use libfuzzer_sys::fuzz_target; -use photocraft_psd::ase::{self, AseColor, AseEntry}; -fuzz_target!(|data: &[u8]| { - let Ok(f) = ase::parse(data) else { return }; - assert!(f.swatch_count() <= ase::MAX_SWATCHES, "{} swatches exceeds MAX_SWATCHES", f.swatch_count()); - for e in &f.entries { - let swatches = match e { - AseEntry::Swatch(s) => std::slice::from_ref(s), - AseEntry::Group { swatches, .. } => swatches.as_slice(), - }; - for s in swatches { - assert!(s.name.encode_utf16().count() <= ase::MAX_NAME_UNITS, "name longer than MAX_NAME_UNITS"); - // Rgb/Lab carry three components, Cmyk four, Gray one. - let check = |c: &[f32]| { - for v in c { - assert!(v.is_finite(), "colour component {v} is not finite"); - } - }; - match &s.color { - AseColor::Rgb(v) | AseColor::Lab(v) => check(v), - AseColor::Cmyk(v) => check(v), - AseColor::Gray(v) => check(std::slice::from_ref(v)), - AseColor::Other { .. } => {} - } - } - } - let out = ase::write(&f).expect("a parsed .ase must be writable"); - let back = ase::parse(&out).expect("a written .ase must re-parse"); - assert_eq!(back.swatch_count(), f.swatch_count(), "swatch count changed across a round trip"); - assert_eq!(ase::write(&back).expect("rewritable"), out, "a second round trip is not byte-stable"); -}); diff --git a/crates/psd/fuzz/fuzz_targets/asl.rs b/crates/psd/fuzz/fuzz_targets/asl.rs deleted file mode 100644 index cbf2c9169c..0000000000 --- a/crates/psd/fuzz/fuzz_targets/asl.rs +++ /dev/null @@ -1,15 +0,0 @@ -//! Fuzz the `.asl` style library parser: any input must return `Ok` or `Err`, never panic, and -//! anything that parses must write back and parse to the same number of styles. -#![no_main] - -use libfuzzer_sys::fuzz_target; -use photocraft_psd::asl; - -fuzz_target!(|data: &[u8]| { - if let Ok(f) = asl::parse(data) - && let Ok(bytes) = asl::write(&f) - { - let back = asl::parse(&bytes).expect("a written library parses"); - assert_eq!(back.styles.len(), f.styles.len()); - } -}); diff --git a/crates/psd/fuzz/fuzz_targets/atn.rs b/crates/psd/fuzz/fuzz_targets/atn.rs deleted file mode 100644 index e02e25b073..0000000000 --- a/crates/psd/fuzz/fuzz_targets/atn.rs +++ /dev/null @@ -1,12 +0,0 @@ -//! Fuzz the `.atn` actions parser: any input must return `Ok` or `Err`, never panic, and -//! anything that parses must survive writing, reparsing and stable canonical writing. -#![no_main] - -use libfuzzer_sys::fuzz_target; - -#[path = "../../tests/it/common/atn_roundtrip.rs"] -mod atn_roundtrip; - -fuzz_target!(|data: &[u8]| { - atn_roundtrip::check(data); -}); diff --git a/crates/psd/fuzz/fuzz_targets/descriptor.rs b/crates/psd/fuzz/fuzz_targets/descriptor.rs deleted file mode 100644 index e487081563..0000000000 --- a/crates/psd/fuzz/fuzz_targets/descriptor.rs +++ /dev/null @@ -1,13 +0,0 @@ -//! Fuzz the ActionDescriptor parser; anything that parses must be byte -//! stable after one normalization pass (e.g. `bool` bytes other than 0/1). -#![no_main] - -use libfuzzer_sys::fuzz_target; -use photocraft_psd::descriptor::Descriptor; - -fuzz_target!(|data: &[u8]| { - if let Ok(d) = Descriptor::from_bytes(data) { - let b = d.to_bytes(); - assert_eq!(Descriptor::from_bytes(&b).expect("re-parse").to_bytes(), b); - } -}); diff --git a/crates/psd/fuzz/fuzz_targets/filter_effects.rs b/crates/psd/fuzz/fuzz_targets/filter_effects.rs deleted file mode 100644 index 611e639b8f..0000000000 --- a/crates/psd/fuzz/fuzz_targets/filter_effects.rs +++ /dev/null @@ -1,22 +0,0 @@ -//! Fuzz the `FEid` / `FXid` smart-filter cache parser: any input must return `Ok` or `Err`, never -//! panic. Decoding every cached plane and the filter mask must also stay panic-free (a size the -//! data cannot hold is refused before allocation, for RLE and zlib alike), and a parsed block must -//! re-parse to the same value and re-serialise byte for byte. -#![no_main] -use libfuzzer_sys::fuzz_target; -use photocraft_psd::filter_effects::FilterEffects; -fuzz_target!(|data: &[u8]| { - let Ok(fx) = FilterEffects::parse(data) else { return }; - for it in &fx.items { - let _ = it.decoded_mask(); - if let (Ok(depth), Ok((w, h))) = (u16::try_from(it.depth), it.rect.size()) { - for p in it.slots.iter().flatten() { - let _ = p.decode(w, h, depth); - } - } - } - let out = fx.to_bytes(); - let back = FilterEffects::parse(&out).expect("a written FEid block must re-parse"); - assert_eq!(back, fx, "a written FEid block does not re-parse to the same value"); - assert_eq!(back.to_bytes(), out, "a second round trip is not byte-stable"); -}); diff --git a/crates/psd/fuzz/fuzz_targets/grd.rs b/crates/psd/fuzz/fuzz_targets/grd.rs deleted file mode 100644 index 16ab26a137..0000000000 --- a/crates/psd/fuzz/fuzz_targets/grd.rs +++ /dev/null @@ -1,52 +0,0 @@ -//! Fuzz the `.grd` gradient parser: any input must return `Ok` or `Err`, never panic, and every -//! number it hands back must be finite and in range. -//! -//! The cap assertions below are invariants, not fast checks: at libFuzzer default `-max_len` -//! of 4096 no input can carry 10 001 gradients or 1001 stops, so they only fire on long runs with -//! a raised limit. The inputs that do reach those caps are in `crates/psd/tests/it/swatch_malformed.rs`. -#![no_main] -use libfuzzer_sys::fuzz_target; -use photocraft_psd::grd::{self, GrdColor}; -fuzz_target!(|data: &[u8]| { - let Ok(g) = grd::parse(data) else { return }; - assert!(g.len() <= grd::MAX_GRADIENTS, "{} gradients exceeds MAX_GRADIENTS", g.len()); - for grad in &g { - assert!(grad.stops.len() <= grd::MAX_STOPS, "{} stops exceeds MAX_STOPS", grad.stops.len()); - assert!(grad.opacity.len() <= grd::MAX_STOPS, "{} transparency stops exceeds MAX_STOPS", grad.opacity.len()); - for &(l, o, m) in &grad.opacity { - assert!(l.is_finite() && (0.0..=1.0).contains(&l), "transparency location {l} out of range"); - assert!((0.0..=1.0).contains(&o), "opacity {o} outside 0..=1"); - assert!(m.is_finite() && (0.0..=1.0).contains(&m), "transparency midpoint {m} out of range"); - } - for s in &grad.stops { - assert!(s.location.is_finite() && (0.0..=1.0).contains(&s.location), "stop location {} out of range", s.location); - assert!(s.midpoint.is_finite() && (0.0..=1.0).contains(&s.midpoint), "stop midpoint {} out of range", s.midpoint); - // Each model has its own documented range, not just "finite": the parser clamps RGB, - // CMYK and Gray to 0..=1, Hue to 0..=360, L* to 0..=100 and a*/b* to the CIELAB gamut. - let unit = |c: &[f32]| { - for v in c { - assert!(v.is_finite() && (0.0..=1.0).contains(v), "colour component {v} outside 0..=1"); - } - }; - match s.color { - GrdColor::Rgb(v) => unit(&v), - GrdColor::Cmyk(v) => unit(&v), - GrdColor::Hsb([h, sat, br]) => { - assert!(h.is_finite() && (0.0..=360.0).contains(&h), "hue {h} outside 0..=360"); - unit(&[sat, br]); - } - GrdColor::Lab([l, a, b]) => { - assert!(l.is_finite() && (0.0..=100.0).contains(&l), "L* {l} outside 0..=100"); - assert!(a.is_finite() && (-128.0..=127.0).contains(&a), "a* {a} outside the CIELAB gamut"); - assert!(b.is_finite() && (-128.0..=127.0).contains(&b), "b* {b} outside the CIELAB gamut"); - } - GrdColor::Gray(v) => unit(&[v]), - GrdColor::Foreground | GrdColor::Background => {} - } - } - } - let out = grd::write(&g); - let back = grd::parse(&out).expect("a written .grd must re-parse"); - assert_eq!(back.len(), g.len(), "gradient count changed across a round trip"); - assert_eq!(grd::write(&back), out, "a second round trip is not byte-stable"); -}); diff --git a/crates/psd/fuzz/fuzz_targets/image_source_data.rs b/crates/psd/fuzz/fuzz_targets/image_source_data.rs deleted file mode 100644 index 73e7289173..0000000000 --- a/crates/psd/fuzz/fuzz_targets/image_source_data.rs +++ /dev/null @@ -1,18 +0,0 @@ -//! Fuzz the layered-TIFF tag parsers (37724 image source data in either byte order, and 34377 -//! image resources), and the byte-order transcoder on anything that parses. -#![no_main] - -use libfuzzer_sys::fuzz_target; -use photocraft_psd::tiff::{ByteOrder, ImageSourceData, resources_from_bytes}; - -fuzz_target!(|data: &[u8]| { - let _ = resources_from_bytes(data); - let Ok((d, _)) = ImageSourceData::from_bytes(data) else { return }; - // Whatever parsed must write in both orders, and each output must parse again. - for order in [ByteOrder::Big, ByteOrder::Little] { - if let Ok((out, _)) = d.to_bytes(order) { - let (again, _) = ImageSourceData::from_bytes(&out).expect("re-parse of written data"); - let _ = again.to_bytes(order); - } - } -}); diff --git a/crates/psd/fuzz/fuzz_targets/metadata.rs b/crates/psd/fuzz/fuzz_targets/metadata.rs deleted file mode 100644 index 2bb23d7548..0000000000 --- a/crates/psd/fuzz/fuzz_targets/metadata.rs +++ /dev/null @@ -1,17 +0,0 @@ -//! Fuzz the `shmd` (metadata setting) parser: any input must return `Ok` or `Err`, never panic. -//! -//! The `copy` field is a bool, so a non-canonical copy byte (anything but 0 or 1) normalises to 1 -//! on write; exact byte equality therefore cannot hold for arbitrary input, only for canonical -//! files. The invariant that does hold, and that catches reader/writer drift, is that a -//! parsed-then-written block re-parses to the *same items* (semantic equality, not just byte -//! stability -- byte stability alone would also pass if the writer silently dropped a field). -#![no_main] -use libfuzzer_sys::fuzz_target; -use photocraft_psd::metadata::{parse_shmd, write_shmd}; -fuzz_target!(|data: &[u8]| { - let Ok(items) = parse_shmd(data) else { return }; - let out = write_shmd(&items); - let back = parse_shmd(&out).expect("a written shmd block must re-parse"); - assert_eq!(back, items, "a written shmd block does not re-parse to the same items"); - assert_eq!(write_shmd(&back), out, "shmd is not byte-stable across a round trip"); -}); diff --git a/crates/psd/fuzz/fuzz_targets/parse.rs b/crates/psd/fuzz/fuzz_targets/parse.rs deleted file mode 100644 index 3dab9cd93a..0000000000 --- a/crates/psd/fuzz/fuzz_targets/parse.rs +++ /dev/null @@ -1,27 +0,0 @@ -//! Fuzz the full parser, and exercise writer/decoders on anything that parses. -#![no_main] - -use libfuzzer_sys::fuzz_target; -use photocraft_psd::PsdFile; - -fuzz_target!(|data: &[u8]| { - let Ok(f) = PsdFile::from_bytes(data) else { return }; - // Anything written must re-parse and be byte stable from then on. - // (Byte comparison rather than model equality, since NaN != NaN.) - if let Ok(out) = f.to_bytes() { - let g = PsdFile::from_bytes(&out).expect("re-parse of written file"); - assert_eq!(g.to_bytes().expect("re-write"), out); - } - // Keep decoding bounded: only small images. - if u64::from(f.header.width) * u64::from(f.header.height) <= 1 << 20 { - let _ = f.decode_merged(); - let _ = f.composite_rgba8(); - for l in f.iter_layers() { - if l.record.rect.width() * l.record.rect.height() <= 1 << 20 { - let _ = l.rgba8(); - let _ = l.user_mask(); - } - } - } - let _ = f.layer_tree(); -}); diff --git a/crates/psd/fuzz/fuzz_targets/patterns.rs b/crates/psd/fuzz/fuzz_targets/patterns.rs deleted file mode 100644 index d7dbc6236b..0000000000 --- a/crates/psd/fuzz/fuzz_targets/patterns.rs +++ /dev/null @@ -1,20 +0,0 @@ -//! Fuzz the pattern parsers: the `Patt` / `Pat2` / `Pat3` tagged blocks and standalone `.pat` -//! files. Any input must return `Ok` or `Err`, never panic. The channel offsets and padding a -//! hostile file can carry are wire artifacts, not fields of `PsdPattern`, so a parsed pattern must -//! survive a write/parse round trip unchanged -- a stronger invariant than byte stability, which -//! would also hold if the writer silently normalised a field away. -#![no_main] -use libfuzzer_sys::fuzz_target; -use photocraft_psd::patterns::{parse_pat_file, parse_pattern_block, write_pat_file, write_pattern_block}; -fuzz_target!(|data: &[u8]| { - if let Ok(ps) = parse_pattern_block(data) { - let out = write_pattern_block(&ps).expect("a parsed pattern block must be writable"); - let back = parse_pattern_block(&out).expect("a written pattern block must re-parse"); - assert_eq!(back, ps, "a pattern block does not survive a write/parse round trip"); - } - if let Ok(ps) = parse_pat_file(data) { - let out = write_pat_file(&ps).expect("a parsed .pat must be writable"); - let back = parse_pat_file(&out).expect("a written .pat must re-parse"); - assert_eq!(back, ps, "a .pat file does not survive a write/parse round trip"); - } -}); diff --git a/crates/psd/fuzz/fuzz_targets/resources.rs b/crates/psd/fuzz/fuzz_targets/resources.rs deleted file mode 100644 index 51d7311731..0000000000 --- a/crates/psd/fuzz/fuzz_targets/resources.rs +++ /dev/null @@ -1,24 +0,0 @@ -//! Fuzz the typed image-resource views: `ImageResource::parsed` for an arbitrary resource id, plus -//! the standalone `ResolutionInfo` and `VersionInfo` parsers. All must return `Ok` / `Err` / `None` -//! (and `xmp_str` an `Option`), never panic. A parsed `ResolutionInfo` (16 fixed bytes) must be -//! stable across a byte round trip. The first two bytes pick the id so every typed branch is -//! reachable; the rest is the resource data. -#![no_main] -use libfuzzer_sys::fuzz_target; -use photocraft_psd::resources::{ImageResource, ResolutionInfo, VersionInfo}; -fuzz_target!(|data: &[u8]| { - let (id, body) = match data.split_first_chunk::<2>() { - Some((p, rest)) => (u16::from_be_bytes(*p), rest.to_vec()), - None => (0u16, data.to_vec()), - }; - let r = ImageResource::new(id, body.clone()); - let _ = r.parsed(); - let _ = r.xmp_str(); - if let Ok(ri) = ResolutionInfo::from_bytes(&body) { - let round = ResolutionInfo::from_bytes(&ri.to_bytes()).expect("resolution info re-parses"); - assert_eq!(round, ri, "ResolutionInfo is not stable across a round trip"); - } - if let Ok(vi) = VersionInfo::from_bytes(&body) { - let _ = vi.to_bytes(); - } -}); diff --git a/crates/psd/fuzz/fuzz_targets/tagged.rs b/crates/psd/fuzz/fuzz_targets/tagged.rs deleted file mode 100644 index 8f7c6517ca..0000000000 --- a/crates/psd/fuzz/fuzz_targets/tagged.rs +++ /dev/null @@ -1,28 +0,0 @@ -//! Fuzz the per-block views of a tagged block: `parsed` (typed, well-known keys) and -//! `check_structure` (strict re-parse of blocks that carry their own inner sizes: placed layers, -//! linked files, object effects, filter caches). Both must return `Ok` / `Err` / `None`, never -//! panic, for arbitrary data. -//! -//! The first byte selects the key from a table of every key with a typed or strict branch, so the -//! fuzzer reaches each branch deterministically instead of having to guess a 4-byte key from raw -//! bytes (which it would almost never do); the rest is the block data. -#![no_main] -use libfuzzer_sys::fuzz_target; -use photocraft_psd::tagged::TaggedBlock; - -const KEYS: [&[u8; 4]; 20] = [ - // typed (parsed) - b"luni", b"lsct", b"lsdk", b"lyid", b"lnsr", b"clbl", b"infx", b"knko", b"tsly", b"lmgm", b"vmgm", b"lspf", b"lclr", b"iOpa", b"shmd", - // strict (check_structure) - b"PlLd", b"SoLd", b"lnk2", b"lfx2", b"FEid", -]; - -fuzz_target!(|data: &[u8]| { - let (key, body) = match data.split_first() { - Some((&sel, rest)) => (*KEYS[sel as usize % KEYS.len()], rest.to_vec()), - None => (*b"luni", Vec::new()), - }; - let b = TaggedBlock::new(key, body); - let _ = b.parsed(); - let _ = b.check_structure(); -}); diff --git a/crates/psd/proptest-regressions/descriptor.txt b/crates/psd/proptest-regressions/descriptor.txt deleted file mode 100644 index d28a250e6b..0000000000 --- a/crates/psd/proptest-regressions/descriptor.txt +++ /dev/null @@ -1,7 +0,0 @@ -# Seeds for failure cases proptest has generated in the past. It is -# automatically read and these particular cases re-run before any -# novel cases are generated. -# -# It is recommended to check this file in to source control so that -# everyone who runs the test benefits from these saved cases. -cc 8089b21c70b7dfddf5697e3a0ec01fa8aeb5ea2e8e844f62ae6e8c2453382404 # shrinks to d = Descriptor { name: UnicodeString([]), class_id: Code([0, 0, 0, 0]), items: [(Code([0, 0, 0, 0]), Descriptor(Descriptor { name: UnicodeString([]), class_id: Code([0, 0, 0, 0]), items: [(Str([0]), Boolean(false))] }))] } diff --git a/crates/psd/src/abr.rs b/crates/psd/src/abr.rs deleted file mode 100644 index 35f4135a53..0000000000 --- a/crates/psd/src/abr.rs +++ /dev/null @@ -1,676 +0,0 @@ -//! Photoshop brush files (`.abr`): the legacy v1/v2 layout and the sectioned v6+ layout. -//! -//! Implemented clean-room from public descriptions of the format (the layout notes published with -//! open-source readers such as GIMP's and ag-psd's, and observation of files written by Photoshop). -//! No code was copied. All numbers are big-endian. -//! -//! ```text -//! v1 / v2 := version u16 (1|2), count u16, count × brush -//! brush := type u16 (1 computed, 2 sampled), length u32, body[length] -//! computed := misc u32, spacing u16 (%), diameter u16, roundness u16 (%), angle i16, hardness u16 (%) -//! sampled := misc u32, spacing u16 (%), [v2: name (u32 length + UTF-16 incl. NUL)], -//! anti-alias u8, short bounds 4 × i16, bounds 4 × i32 (top, left, bottom, right), -//! depth u16 (8|16), compression u8 (0 raw, 1 PackBits with a u16 row-length table) -//! -//! v6+ := version u16 (6..=10), subversion u16 (1|2), sections… -//! section := "8BIM", key [4], length u32, data[length] (padded to 4 bytes) -//! "samp" := entries: length u32 (padded to 4), Pascal uuid, skip (10 if subversion 1, -//! 264 if 2), bounds 4 × i32, depth i16, compression u8, data -//! "patt" := patterns as in the `Patt` global block -//! "desc" := version u32 (16) + ActionDescriptor; key `Brsh` lists `brushPreset` objects -//! "phry" := version u32 (16) + ActionDescriptor; key `hierarchy` lists objects read as a -//! token stream: `Grup` { `Nm ` name, `zuid` uuid } opens a folder, `preset` {} is -//! the next preset of `desc` (in order), `groupEnd` {} closes the current folder -//! ``` -//! -//! The `phry` (preset hierarchy) section keeps the Brushes panel folders of Photoshop CC (2018+) -//! files: [`AbrFile::folders`] gives each preset's folder path. Folders nest (a `Grup` inside a -//! `Grup`); presets outside any folder are at the top level. A damaged hierarchy never fails the -//! parse: mismatches become warnings and the presets they affect land at the top level. -//! -//! [`parse`] never panics: every length is bounds-checked, sizes are capped ([`MAX_EDGE`], -//! [`MAX_TOTAL_BYTES`]) and a damaged brush is skipped with a warning when the rest of the file -//! is still readable. [`write_v12`] and [`write_v6`] produce files in the same layouts (used by -//! the tests and fuzz seeds; exporting brushes reuses them). - -use crate::compression::{Compression, PlaneLayout, decode_planes, encode_planes}; -use crate::descriptor::{Descriptor, Value, VersionedDescriptor}; -use crate::error::{PsdError, Result}; -use crate::header::Version; -use crate::patterns::{PatternBudget, PsdPattern, parse_pattern_block_with, write_pattern_block}; - -/// Largest sampled tip edge accepted (Photoshop's own limit is 5000 px). -pub const MAX_EDGE: u32 = 8192; -/// Most brushes read from one file. -pub const MAX_BRUSHES: usize = 20_000; -/// Cap on decoded sample bytes over the whole file. 32-bit targets (wasm) keep 512 MiB: the file, -/// its base64 copies and the decoded tips must all fit a 4 GiB address space, and running out -/// there aborts instead of returning an error. -pub const MAX_TOTAL_BYTES: usize = if cfg!(target_pointer_width = "64") { 1 << 30 } else { 512 << 20 }; -const _: () = assert!(cfg!(target_pointer_width = "64") || MAX_TOTAL_BYTES == 512 << 20); - -/// A sampled tip: one grayscale plane where the maximum value is full paint. -#[derive(Debug, Clone, PartialEq)] -pub struct AbrSample { - /// The uuid that `sampledData` in a preset descriptor refers to (v6+), or empty. - pub id: String, - /// Width in pixels. - pub width: u32, - /// Height in pixels. - pub height: u32, - /// Bits per sample: 8 or 16. - pub depth: u16, - /// Decoded samples, row-major (`width × height × depth / 8` bytes, 16-bit big-endian). - pub data: Vec, -} - -impl AbrSample { - /// Samples as 0..1 paint amounts. - pub fn to_unit(&self) -> Vec { - if self.depth == 16 { - self.data.as_chunks::<2>().0.iter().map(|c| f32::from(u16::from_be_bytes(*c)) / 65535.0).collect() - } else { - self.data.iter().map(|&v| f32::from(v) / 255.0).collect() - } - } -} - -/// A legacy (v1/v2) tip. -#[derive(Debug, Clone, PartialEq)] -pub enum LegacyTip { - /// Computed elliptical tip. - Computed { - /// Diameter in pixels. - diameter: u16, - /// 0..100 %. - hardness: u16, - /// Degrees. - angle: i16, - /// 0..100 %. - roundness: u16, - }, - /// Sampled tip. - Sampled(AbrSample), -} - -/// A legacy (v1/v2) brush. -#[derive(Debug, Clone, PartialEq)] -pub struct LegacyBrush { - /// Name (v2 sampled brushes only; empty otherwise). - pub name: String, - /// Spacing in percent of the diameter (0 = spacing off). - pub spacing: u16, - /// Anti-aliasing flag (sampled tips). - pub anti_alias: bool, - /// The tip. - pub tip: LegacyTip, -} - -/// A parsed brush file. -#[derive(Debug, Clone, PartialEq, Default)] -pub struct AbrFile { - /// File version (1, 2, 6, 7, 9 or 10). - pub version: u16, - /// v6+ subversion (1 or 2); 0 for v1/v2. - pub subversion: u16, - /// v1/v2 brushes, in file order. - pub legacy: Vec, - /// v6+ sampled tips (`samp`), in file order. - pub samples: Vec, - /// v6+ embedded patterns (`patt`), referenced by texture settings. - pub patterns: Vec, - /// v6+ brush presets: the `brushPreset` objects of the `desc` section, in file order. - pub presets: Vec, - /// v6+ folder of each preset, parallel to [`AbrFile::presets`]: the folder names from the - /// outermost in, empty for a preset at the top level. Empty when the file has no readable - /// preset hierarchy (`phry`), i.e. every preset is at the top level. - pub folders: Vec>, - /// Problems that did not stop the parse (skipped brushes, unknown sections…). - pub warnings: Vec, -} - -struct Rd<'a> { - b: &'a [u8], - p: usize, -} - -impl<'a> Rd<'a> { - fn new(b: &'a [u8]) -> Self { - Rd { b, p: 0 } - } - fn left(&self) -> usize { - self.b.len().saturating_sub(self.p) - } - fn take(&mut self, n: usize) -> Result<&'a [u8]> { - let end = self.p.checked_add(n).ok_or(PsdError::LimitExceeded("abr length overflow"))?; - let s = self.b.get(self.p..end).ok_or(PsdError::UnexpectedEof { offset: self.p, needed: end.saturating_sub(self.b.len()) })?; - self.p = end; - Ok(s) - } - fn arr(&mut self) -> Result<[u8; N]> { - let s = self.take(N)?; - let mut a = [0u8; N]; - a.copy_from_slice(s); - Ok(a) - } - fn u8(&mut self) -> Result { - Ok(self.arr::<1>()?[0]) - } - fn u16(&mut self) -> Result { - Ok(u16::from_be_bytes(self.arr()?)) - } - fn u32(&mut self) -> Result { - Ok(u32::from_be_bytes(self.arr()?)) - } - fn i32(&mut self) -> Result { - Ok(i32::from_be_bytes(self.arr()?)) - } - fn skip(&mut self, n: usize) -> Result<()> { - self.take(n).map(|_| ()) - } -} - -/// Bytes budget shared by all samples of one file. -struct Budget(usize); - -impl Budget { - fn spend(&mut self, n: usize) -> Result<()> { - self.0 = self.0.checked_sub(n).ok_or(PsdError::LimitExceeded("abr samples exceed MAX_TOTAL_BYTES"))?; - Ok(()) - } -} - -/// Reads bounds (top, left, bottom, right), depth and compression, then the pixel data. -fn read_sample_body(r: &mut Rd, id: String, budget: &mut Budget) -> Result { - let (top, left, bottom, right) = (r.i32()?, r.i32()?, r.i32()?, r.i32()?); - let w = i64::from(right) - i64::from(left); - let h = i64::from(bottom) - i64::from(top); - if w <= 0 || h <= 0 { - return Err(PsdError::invalid(format!("brush sample bounds {w}×{h}"))); - } - if w > i64::from(MAX_EDGE) || h > i64::from(MAX_EDGE) { - return Err(PsdError::LimitExceeded("brush sample larger than MAX_EDGE")); - } - let (w, h) = (w as u32, h as u32); - let depth = r.u16()?; - if depth != 8 && depth != 16 { - return Err(PsdError::invalid(format!("brush sample depth {depth}"))); - } - let comp = r.u8()?; - let compression = match comp { - 0 => Compression::Raw, - 1 => Compression::Rle, - c => return Err(PsdError::Unsupported(format!("brush sample compression {c}"))), - }; - let layout = PlaneLayout { planes: 1, width: w as usize, height: h as usize, depth, version: Version::Psd }; - budget.spend(layout.decoded_len()?)?; - let rest = r.take(r.left())?; - let data = decode_planes(compression, rest, &layout)?; - Ok(AbrSample { id, width: w, height: h, depth, data }) -} - -fn read_ucs2(r: &mut Rd) -> Result { - let n = r.u32()? as usize; - if n > 4096 { - return Err(PsdError::LimitExceeded("abr name length")); - } - let units: Vec = r.take(n * 2)?.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes(*c)).collect(); - Ok(String::from_utf16_lossy(&units).trim_end_matches('\0').to_string()) -} - -fn parse_v12(r: &mut Rd, version: u16, out: &mut AbrFile) -> Result<()> { - let count = r.u16()?; - let mut budget = Budget(MAX_TOTAL_BYTES); - for i in 0..count { - if r.left() == 0 { - out.warnings.push(format!("file ends after {i} of {count} brushes")); - break; - } - let ty = r.u16()?; - let len = r.u32()? as usize; - let body = match r.take(len) { - Ok(b) => b, - Err(_) => { - out.warnings.push(format!("brush {}: truncated", i + 1)); - break; - } - }; - let mut b = Rd::new(body); - let parsed = (|| -> Result> { - match ty { - 1 => { - let _misc = b.u32()?; - let spacing = b.u16()?; - let diameter = b.u16()?; - let roundness = b.u16()?; - let angle = b.u16()? as i16; - let hardness = b.u16()?; - Ok(Some(LegacyBrush { name: String::new(), spacing, anti_alias: true, tip: LegacyTip::Computed { diameter, hardness, angle, roundness } })) - } - 2 => { - let _misc = b.u32()?; - let spacing = b.u16()?; - let name = if version == 2 { read_ucs2(&mut b)? } else { String::new() }; - let anti_alias = b.u8()? != 0; - b.skip(8)?; // short bounds (superseded by the 32-bit bounds) - let s = read_sample_body(&mut b, String::new(), &mut budget)?; - Ok(Some(LegacyBrush { name, spacing, anti_alias, tip: LegacyTip::Sampled(s) })) - } - other => { - out.warnings.push(format!("brush {}: unknown brush type {other} skipped", i + 1)); - Ok(None) - } - } - })(); - match parsed { - Ok(Some(lb)) => out.legacy.push(lb), - Ok(None) => {} - Err(PsdError::LimitExceeded(m)) if m.contains("MAX_TOTAL_BYTES") => return Err(PsdError::LimitExceeded(m)), - Err(e) => out.warnings.push(format!("brush {}: {e}; skipped", i + 1)), - } - } - Ok(()) -} - -fn read_samp(data: &[u8], subversion: u16, out: &mut AbrFile, budget: &mut Budget) -> Result<()> { - let mut r = Rd::new(data); - while r.left() >= 4 { - if out.samples.len() >= MAX_BRUSHES { - out.warnings.push("too many sampled tips; the rest were skipped".into()); - break; - } - let len = r.u32()? as usize; - if len == 0 { - break; - } - let padded = len.checked_add(3).ok_or(PsdError::LimitExceeded("abr sample length"))? & !3; - let body = match r.take(padded.min(r.left())) { - Ok(b) if b.len() >= len => b, - _ => { - out.warnings.push(format!("sampled tip {}: truncated", out.samples.len() + 1)); - break; - } - }; - let mut b = Rd::new(body); - let parsed = (|| -> Result { - let n = b.u8()? as usize; - let id = String::from_utf8_lossy(b.take(n)?).to_string(); - b.skip(if subversion == 1 { 10 } else { 264 })?; - read_sample_body(&mut b, id, budget) - })(); - match parsed { - Ok(s) => out.samples.push(s), - Err(PsdError::LimitExceeded(m)) if m.contains("MAX_TOTAL_BYTES") => return Err(PsdError::LimitExceeded(m)), - Err(e) => out.warnings.push(format!("sampled tip {}: {e}; skipped", out.samples.len() + 1)), - } - } - Ok(()) -} - -fn read_desc(data: &[u8], out: &mut AbrFile) { - match VersionedDescriptor::parse_prefix(data) { - // Moves the presets out of the parsed descriptor instead of cloning each one. - Ok((vd, _)) => match vd.descriptor.items.into_iter().find(|(k, _)| k.is("Brsh")).map(|(_, v)| v) { - Some(Value::List(items)) => { - for v in items.into_iter().take(MAX_BRUSHES) { - if let Value::Descriptor(d) = v { - out.presets.push(d); - } - } - } - _ => out.warnings.push("brush settings (desc) hold no brush list".into()), - }, - Err(e) => out.warnings.push(format!("brush settings (desc) unreadable: {e}")), - } -} - -/// Deepest folder nesting read from or written to a preset hierarchy; deeper folders are merged -/// into their ancestor at this depth. -pub const MAX_FOLDER_DEPTH: usize = 32; -/// Longest folder name kept (characters). -const MAX_FOLDER_NAME: usize = 255; - -/// Most bytes of folder names held across all presets' folder paths (each name also counts its -/// `String`). Presets past it are imported at the top level. 20,000 presets three folders deep -/// with 20-character names take about 3 MB. -const MAX_FOLDER_BYTES: usize = 16 << 20; - -/// Reads the `phry` token stream into one folder path per preset (`presets` of them, the count -/// read from `desc`), in order. Paths are built only for those presets, within -/// [`MAX_FOLDER_BYTES`]: a small section can't make the reader allocate more than that. `None` -/// (with a warning) when the section is unreadable. -fn read_phry(data: &[u8], presets: usize, warnings: &mut Vec) -> Option>> { - let vd = match VersionedDescriptor::parse_prefix(data) { - Ok((vd, _)) => vd, - Err(e) => { - warnings.push(format!("brush folders (phry) unreadable: {e}; brushes imported without folders")); - return None; - } - }; - let Some(Value::List(items)) = vd.descriptor.get("hierarchy") else { - warnings.push("brush folders (phry) hold no hierarchy; brushes imported without folders".into()); - return None; - }; - let mut stack: Vec = Vec::new(); - // Folders opened past MAX_FOLDER_DEPTH: their `groupEnd`s close nothing on `stack`. - let mut overflow = 0usize; - let mut out = Vec::with_capacity(presets.min(MAX_BRUSHES)); - // Preset tokens seen (only counted past `presets`), and the bytes held by `out`'s paths. - let (mut listed, mut bytes) = (0usize, 0usize); - let (mut unbalanced, mut too_deep, mut too_big) = (false, false, false); - for v in items { - let Value::Descriptor(d) = v else { continue }; - if d.class_id.is("Grup") { - if stack.len() >= MAX_FOLDER_DEPTH { - overflow = overflow.saturating_add(1); - too_deep = true; - continue; - } - let name = match d.get("Nm ") { - Some(Value::Text(t)) => t.to_string_lossy(), - _ => String::new(), - }; - let name: String = name.trim().chars().take(MAX_FOLDER_NAME).collect(); - stack.push(if name.is_empty() { "Group".to_string() } else { name }); - } else if d.class_id.is("groupEnd") { - if overflow > 0 { - overflow -= 1; - } else if stack.pop().is_none() { - unbalanced = true; - } - } else if d.class_id.is("preset") { - listed = listed.saturating_add(1); - if out.len() >= presets { - continue; - } - let size = stack.iter().map(|n| n.len().saturating_add(size_of::())).fold(0usize, usize::saturating_add); - match bytes.checked_add(size).filter(|&b| b <= MAX_FOLDER_BYTES) { - Some(b) if !too_big => { - bytes = b; - out.push(stack.clone()); - } - _ => { - too_big = true; - out.push(Vec::new()); - } - } - } - } - if unbalanced { - warnings.push("brush folders (phry): a folder end without a folder was ignored".into()); - } - if too_deep { - warnings.push(format!("brush folders nested deeper than {MAX_FOLDER_DEPTH} levels were merged into their parent")); - } - if too_big { - warnings.push("brush folders (phry) too large; the remaining brushes were imported at the top level".into()); - } - if listed != presets { - warnings.push(format!( - "brush folders (phry) list {listed} brushes but the file holds {presets}; {}", - if listed > presets { "the extra entries were ignored" } else { "the rest were imported at the top level" } - )); - out.resize(presets, Vec::new()); - } - Some(out) -} - -/// Pairs the `phry` section's preset tokens with the presets read from `desc`, in order. -fn apply_hierarchy(out: &mut AbrFile, phry: Option<&[u8]>) { - let Some(data) = phry else { return }; - let Some(folders) = read_phry(data, out.presets.len(), &mut out.warnings) else { return }; - if folders.iter().any(|f| !f.is_empty()) { - out.folders = folders; - } -} - -fn parse_v6(r: &mut Rd, out: &mut AbrFile) -> Result<()> { - parse_v6_within(r, out, &mut Budget(MAX_TOTAL_BYTES)) -} - -/// [`parse_v6`] with the samples and patterns sharing `budget`. -fn parse_v6_within(r: &mut Rd, out: &mut AbrFile, budget: &mut Budget) -> Result<()> { - out.subversion = r.u16()?; - if !matches!(out.subversion, 1 | 2) { - return Err(PsdError::Unsupported(format!("brush file subversion {}", out.subversion))); - } - let mut hierarchy = None; - let mut patterns = PatternBudget::default(); - while r.left() >= 12 { - let sig = r.arr::<4>()?; - if &sig != b"8BIM" { - out.warnings.push(format!("unexpected data at offset {} ignored", r.p - 4)); - break; - } - let key = r.arr::<4>()?; - let len = r.u32()? as usize; - let data = match r.take(len) { - Ok(d) => d, - Err(_) => { - out.warnings.push(format!("section {} truncated", String::from_utf8_lossy(&key))); - r.take(r.left())? - } - }; - // Sections are padded to 4 bytes; tolerate writers that don't pad. - let pad = (4 - len % 4) % 4; - if pad > 0 && r.b.get(r.p..r.p + 4) != Some(b"8BIM") && r.b.get(r.p + pad..r.p + pad + 4) == Some(b"8BIM") { - r.p += pad; - } - match &key { - b"samp" => read_samp(data, out.subversion, out, budget)?, - b"patt" => { - // Patterns share the samples' budget and one padding budget across every `patt` - // section, so neither can be claimed twice and failed blocks still deduct their - // decoded charges. - let limit = u64::try_from(budget.0).unwrap_or(u64::MAX); - patterns.cap_decoded_to(limit); - let left = patterns.decoded_left(); - let res = parse_pattern_block_with(data, &mut patterns); - let spent = usize::try_from(left.saturating_sub(patterns.decoded_left())).unwrap_or(usize::MAX); - budget.spend(spent)?; - match res { - Ok(p) => out.patterns.extend(p), - Err(e) => out.warnings.push(format!("embedded patterns unreadable: {e}")), - } - } - b"desc" => read_desc(data, out), - // Read once `desc` gives the preset count; `phry` may come first. - b"phry" => hierarchy = Some(data), - // Tool presets: not needed for the brushes. - b"lPdc" => {} - k => out.warnings.push(format!("section {} ignored", String::from_utf8_lossy(k))), - } - } - apply_hierarchy(out, hierarchy); - Ok(()) -} - -/// Parses an `.abr` file. -pub fn parse(data: &[u8]) -> Result { - let mut r = Rd::new(data); - let version = r.u16()?; - let mut out = AbrFile { version, ..Default::default() }; - match version { - 1 | 2 => parse_v12(&mut r, version, &mut out)?, - 6..=10 => parse_v6(&mut r, &mut out)?, - v => return Err(PsdError::Unsupported(format!("brush file version {v}"))), - } - if out.legacy.is_empty() && out.samples.is_empty() && out.presets.is_empty() { - let why = out.warnings.first().map(|w| format!(" ({w})")).unwrap_or_default(); - return Err(PsdError::invalid(format!("the brush file holds no readable brushes{why}"))); - } - Ok(out) -} - -// ------------------------------------------------------------------ writing - -fn put_u16(o: &mut Vec, v: u16) { - o.extend_from_slice(&v.to_be_bytes()); -} -fn put_u32(o: &mut Vec, v: u32) { - o.extend_from_slice(&v.to_be_bytes()); -} - -fn write_sample_body(o: &mut Vec, s: &AbrSample, rle: bool) -> Result<()> { - if s.width == 0 || s.height == 0 || s.width > MAX_EDGE || s.height > MAX_EDGE || !matches!(s.depth, 8 | 16) { - return Err(PsdError::LimitExceeded("brush sample size or depth")); - } - for v in [0, 0, s.height as i32, s.width as i32] { - o.extend_from_slice(&v.to_be_bytes()); - } - put_u16(o, s.depth); - o.push(u8::from(rle)); - let layout = PlaneLayout { planes: 1, width: s.width as usize, height: s.height as usize, depth: s.depth, version: Version::Psd }; - o.extend_from_slice(&encode_planes(if rle { Compression::Rle } else { Compression::Raw }, &s.data, &layout)?); - Ok(()) -} - -/// Writes a v1 or v2 file. Sampled tips are PackBits-compressed when `rle`. -pub fn write_v12(version: u16, brushes: &[LegacyBrush], rle: bool) -> Result> { - if !matches!(version, 1 | 2) || brushes.len() > usize::from(u16::MAX) { - return Err(PsdError::Unsupported(format!("writing brush file version {version}"))); - } - let mut o = Vec::new(); - put_u16(&mut o, version); - put_u16(&mut o, brushes.len() as u16); - for b in brushes { - let mut body = Vec::new(); - put_u32(&mut body, 0); - put_u16(&mut body, b.spacing); - let ty = match &b.tip { - LegacyTip::Computed { diameter, hardness, angle, roundness } => { - for v in [*diameter, *roundness, *angle as u16, *hardness] { - put_u16(&mut body, v); - } - 1 - } - LegacyTip::Sampled(s) => { - if version == 2 { - let units: Vec = b.name.encode_utf16().chain(std::iter::once(0)).collect(); - put_u32(&mut body, units.len() as u32); - for u in units { - put_u16(&mut body, u); - } - } - body.push(u8::from(b.anti_alias)); - for v in [0u16, 0, s.height.min(0xffff) as u16, s.width.min(0xffff) as u16] { - put_u16(&mut body, v); - } - write_sample_body(&mut body, s, rle)?; - 2 - } - }; - put_u16(&mut o, ty); - put_u32(&mut o, body.len() as u32); - o.extend_from_slice(&body); - } - Ok(o) -} - -fn section(o: &mut Vec, key: &[u8; 4], data: &[u8]) { - o.extend_from_slice(b"8BIM"); - o.extend_from_slice(key); - let pad = (4 - data.len() % 4) % 4; - put_u32(o, (data.len() + pad) as u32); - o.extend_from_slice(data); - o.extend(std::iter::repeat_n(0u8, pad)); -} - -/// Writes a v6 (`subversion` 1 or 2) file: sampled tips, embedded patterns and the preset -/// descriptors (each a `brushPreset` object), listed under `Brsh`. Every preset is at the top -/// level ([`write_v6_folders`] keeps folders). -pub fn write_v6(subversion: u16, samples: &[AbrSample], patterns: &[PsdPattern], presets: &[Descriptor], rle: bool) -> Result> { - write_v6_folders(subversion, samples, patterns, presets, &[], rle) -} - -/// A stable uuid-shaped folder id (`zuid`) derived from the folder's path and position. -fn folder_uuid(path: &[&str], seq: usize) -> String { - // FNV-1a over the path, with two seeds, for 128 bits. - let hash = |seed: u64| { - let mut h = 0xcbf2_9ce4_8422_2325u64 ^ seed; - for b in path.iter().flat_map(|s| s.bytes().chain(std::iter::once(0))).chain(seq.to_le_bytes()) { - h ^= u64::from(b); - h = h.wrapping_mul(0x0000_0100_0000_01b3); - } - h - }; - let (a, b) = (hash(1), hash(2)); - format!("{:08x}-{:04x}-{:04x}-{:04x}-{:012x}", a >> 32, (a >> 16) & 0xffff, a & 0xffff, b >> 48, b & 0xffff_ffff_ffff) -} - -/// The `phry` descriptor for presets in `folders` (one path per preset, in preset order). -/// Presets of one folder that are not adjacent reopen it (a reader merges them by path). -fn hierarchy_descriptor(folders: &[Vec]) -> Descriptor { - let text = |s: &str| Value::Text(crate::descriptor::UnicodeString::new_nul(s)); - let mut items = Vec::new(); - let mut open: Vec<&str> = Vec::new(); - let mut seq = 0usize; - for path in folders { - let path: Vec<&str> = path.iter().take(MAX_FOLDER_DEPTH).map(String::as_str).collect(); - let common = open.iter().zip(&path).take_while(|(a, b)| a == b).count(); - while open.len() > common { - open.pop(); - items.push(Value::Descriptor(Descriptor::new("groupEnd"))); - } - for name in path.iter().skip(common) { - open.push(name); - seq += 1; - items.push(Value::Descriptor(Descriptor::new("Grup").with("Nm ", text(name)).with("zuid", text(&folder_uuid(&open, seq))))); - } - items.push(Value::Descriptor(Descriptor::new("preset"))); - } - for _ in open { - items.push(Value::Descriptor(Descriptor::new("groupEnd"))); - } - Descriptor::new("null").with("hierarchy", Value::List(items)) -} - -/// [`write_v6`] that also writes the preset hierarchy (`phry`): `folders` holds each preset's -/// folder path (outermost first; empty = top level), parallel to `presets`. With no folders (an -/// empty slice, or only empty paths) no hierarchy is written. -pub fn write_v6_folders( - subversion: u16, - samples: &[AbrSample], - patterns: &[PsdPattern], - presets: &[Descriptor], - folders: &[Vec], - rle: bool, -) -> Result> { - if !folders.is_empty() && folders.len() != presets.len() { - return Err(PsdError::invalid(format!("{} folder paths for {} brush presets", folders.len(), presets.len()))); - } - if !matches!(subversion, 1 | 2) { - return Err(PsdError::Unsupported(format!("brush file subversion {subversion}"))); - } - let mut o = Vec::new(); - put_u16(&mut o, 6); - put_u16(&mut o, subversion); - let mut samp = Vec::new(); - for s in samples { - let mut e = Vec::new(); - let id = s.id.as_bytes(); - let n = id.len().min(255); - e.push(n as u8); - e.extend_from_slice(&id[..n]); - e.extend(std::iter::repeat_n(0u8, if subversion == 1 { 10 } else { 264 })); - write_sample_body(&mut e, s, rle)?; - put_u32(&mut samp, e.len() as u32); - let pad = (4 - e.len() % 4) % 4; - samp.extend_from_slice(&e); - samp.extend(std::iter::repeat_n(0u8, pad)); - } - section(&mut o, b"samp", &samp); - if !patterns.is_empty() { - section(&mut o, b"patt", &write_pattern_block(patterns)?); - } - let list = Value::List(presets.iter().cloned().map(Value::Descriptor).collect()); - let desc = VersionedDescriptor::new(Descriptor::new("null").with("Brsh", list)); - section(&mut o, b"desc", &desc.to_bytes()); - if folders.iter().any(|f| !f.is_empty()) { - section(&mut o, b"phry", &VersionedDescriptor::new(hierarchy_descriptor(folders)).to_bytes()); - } - Ok(o) -} - -#[cfg(test)] -mod tests; diff --git a/crates/psd/src/abr/tests.rs b/crates/psd/src/abr/tests.rs deleted file mode 100644 index 29ed91ec6e..0000000000 --- a/crates/psd/src/abr/tests.rs +++ /dev/null @@ -1,298 +0,0 @@ -use super::*; -use crate::descriptor::UnicodeString; - -fn sample(id: &str, w: u32, h: u32, depth: u16) -> AbrSample { - let bpp = usize::from(depth / 8); - let data = (0..w as usize * h as usize * bpp).map(|i| if (i / bpp) % 3 == 0 { 255 } else { (i % 7) as u8 * 30 }).collect(); - AbrSample { id: id.into(), width: w, height: h, depth, data } -} - -#[test] -fn embedded_patterns_spend_the_samples_budget() { - let p = PsdPattern { - mode: 3, - width: 5, - height: 3, - name: "p".into(), - id: "id".into(), - palette: None, - depth: 8, - channels: vec![vec![1; 15], vec![2; 15], vec![3; 15]], - alpha: None, - }; - let abr = write_v6(2, &[sample("s", 10, 10, 8)], &[p.clone(), p], &[], true).unwrap(); - let parse_within = |left: usize| { - let mut out = AbrFile::default(); - let mut budget = Budget(left); - parse_v6_within(&mut Rd::new(&abr[2..]), &mut out, &mut budget).unwrap(); - (out, budget.0) - }; - // 100 sample bytes plus two 45-byte patterns. - let (out, left) = parse_within(190); - assert_eq!((out.samples.len(), out.patterns.len(), left), (1, 2, 0)); - // One byte short: the patterns no longer fit after the sample (they used not to count). - let (out, left) = parse_within(189); - assert_eq!((out.samples.len(), out.patterns.len(), left), (1, 0, 14)); - assert!(out.warnings.iter().any(|w| w.contains("embedded patterns unreadable")), "{:?}", out.warnings); -} - -#[test] -fn failed_pattern_section_does_not_refund_budget_to_later_sections() { - let p = PsdPattern { - mode: 3, - width: 5, - height: 3, - name: "p".into(), - id: "id".into(), - palette: None, - depth: 8, - channels: vec![vec![1; 15], vec![2; 15], vec![3; 15]], - alpha: None, - }; - let valid = write_pattern_block(std::slice::from_ref(&p)).unwrap(); - let mut malformed = valid.clone(); - malformed.extend_from_slice(&100u32.to_be_bytes()); - - let mut body = 2u16.to_be_bytes().to_vec(); - section(&mut body, b"patt", &malformed); - section(&mut body, b"patt", &valid); - let mut out = AbrFile::default(); - let mut budget = Budget(60); - parse_v6_within(&mut Rd::new(&body), &mut out, &mut budget).unwrap(); - - assert!(out.patterns.is_empty(), "a failed block is discarded atomically"); - assert_eq!(budget.0, 0, "the failed block's first 45 bytes and later partial 15-byte charge are retained"); - assert_eq!(out.warnings.iter().filter(|w| w.contains("embedded patterns unreadable")).count(), 2); -} - -#[test] -fn v1_v2_round_trip_raw_and_rle() { - for version in [1u16, 2] { - for rle in [false, true] { - let brushes = vec![ - LegacyBrush { - name: String::new(), - spacing: 25, - anti_alias: true, - tip: LegacyTip::Computed { diameter: 19, hardness: 80, angle: -30, roundness: 50 }, - }, - LegacyBrush { - name: if version == 2 { "Leaf ✓".into() } else { String::new() }, - spacing: 40, - anti_alias: false, - tip: LegacyTip::Sampled(sample("", 13, 9, 8)), - }, - LegacyBrush { name: String::new(), spacing: 10, anti_alias: true, tip: LegacyTip::Sampled(sample("", 4, 6, 16)) }, - ]; - let bytes = write_v12(version, &brushes, rle).unwrap(); - let f = parse(&bytes).unwrap(); - assert_eq!(f.version, version); - assert_eq!(f.legacy, brushes, "v{version} rle={rle}"); - assert!(f.warnings.is_empty(), "{:?}", f.warnings); - } - } -} - -#[test] -fn v6_round_trip_both_subversions() { - let samples = vec![sample("$abc", 20, 11, 8), sample("$def", 5, 5, 16)]; - let preset = Descriptor::new("brushPreset") - .with("Nm ", Value::Text(UnicodeString::new_nul("Grass"))) - .with("Brsh", Value::Descriptor(Descriptor::new("sampledBrush").with("sampledData", Value::Text(UnicodeString::new_nul("$abc"))))); - for sub in [1u16, 2] { - for rle in [false, true] { - let bytes = write_v6(sub, &samples, &[], std::slice::from_ref(&preset), rle).unwrap(); - let f = parse(&bytes).unwrap(); - assert_eq!((f.version, f.subversion), (6, sub)); - assert_eq!(f.samples, samples); - assert_eq!(f.presets, vec![preset.clone()]); - assert!(f.warnings.is_empty(), "{:?}", f.warnings); - } - } - assert_eq!(samples[1].to_unit().len(), 25); -} - -#[test] -fn hostile_input_errors_without_panicking() { - assert!(parse(&[]).is_err()); - assert!(parse(&[0, 3, 0, 0]).is_err()); - assert!(parse(&[0, 6, 0, 9]).is_err()); - // Absurd sample bounds. - let mut s = sample("", 2, 2, 8); - s.width = MAX_EDGE + 1; - assert!(write_v12(1, &[LegacyBrush { name: String::new(), spacing: 1, anti_alias: true, tip: LegacyTip::Sampled(s) }], false).is_err()); - let good = write_v6(2, &[sample("$a", 30, 30, 8)], &[], &[Descriptor::new("brushPreset")], true).unwrap(); - for cut in 0..good.len() { - let _ = parse(&good[..cut]); - } - let v2 = write_v12(2, &[LegacyBrush { name: "x".into(), spacing: 1, anti_alias: true, tip: LegacyTip::Sampled(sample("", 9, 9, 16)) }], true).unwrap(); - for cut in 0..v2.len() { - let _ = parse(&v2[..cut]); - } - // Flip every byte once: never panics. - for i in 0..good.len() { - let mut b = good.clone(); - b[i] ^= 0xa5; - let _ = parse(&b); - } - // A huge declared count with no data is an error, not an allocation. - assert!(parse(&[0, 2, 0xff, 0xff]).is_err()); -} - -fn named(n: &str) -> Descriptor { - Descriptor::new("brushPreset").with("Nm ", Value::Text(UnicodeString::new_nul(n))) -} - -/// Hierarchy tokens: `G(name)` opens, `P` is a preset, `E` closes. -enum Tok<'a> { - G(&'a str), - P, - E, -} - -/// A v6 file with `n` presets and a hand-made `phry` token stream. -fn with_phry(n: usize, toks: &[Tok]) -> Vec { - let presets: Vec = (0..n).map(|i| named(&format!("B{i}"))).collect(); - let mut bytes = write_v6(2, &[], &[], &presets, false).unwrap(); - section(&mut bytes, b"phry", &phry_bytes(toks)); - bytes -} - -/// A `phry` section body holding `toks`. -fn phry_bytes(toks: &[Tok]) -> Vec { - let items = toks - .iter() - .map(|t| { - Value::Descriptor(match t { - Tok::G(name) => Descriptor::new("Grup") - .with("Nm ", Value::Text(UnicodeString::new_nul(name))) - .with("zuid", Value::Text(UnicodeString::new_nul("c9ee8fd5-eb7d-924c-9e95-ddc98e40bb6c"))), - Tok::P => Descriptor::new("preset"), - Tok::E => Descriptor::new("groupEnd"), - }) - }) - .collect(); - VersionedDescriptor::new(Descriptor::new("null").with("hierarchy", Value::List(items))).to_bytes() -} - -fn path(p: &[&str]) -> Vec { - p.iter().map(|s| s.to_string()).collect() -} - -#[test] -fn phry_flat_folders_like_photoshop_25() { - // The layout of Photoshop 25.1's default brushes: four folders, every preset in one. - use Tok::*; - let f = parse(&with_phry(5, &[G("General"), P, P, E, G("Dry Media"), P, E, G("Wet Media"), P, E, G("Special Effects"), P, E])).unwrap(); - assert_eq!(f.folders, vec![path(&["General"]), path(&["General"]), path(&["Dry Media"]), path(&["Wet Media"]), path(&["Special Effects"])]); - assert!(f.warnings.is_empty(), "{:?}", f.warnings); -} - -#[test] -fn phry_nested_folders_and_top_level_presets() { - use Tok::*; - let f = parse(&with_phry(5, &[P, G("Animals"), G("Birds"), P, E, P, G("Fish"), P, E, E, P])).unwrap(); - assert_eq!(f.folders, vec![path(&[]), path(&["Animals", "Birds"]), path(&["Animals"]), path(&["Animals", "Fish"]), path(&[])]); - assert!(f.warnings.is_empty(), "{:?}", f.warnings); - // No folder at all: no hierarchy. - let f = parse(&with_phry(2, &[P, P])).unwrap(); - assert!(f.folders.is_empty()); -} - -#[test] -fn phry_mismatches_become_warnings() { - use Tok::*; - // A stray groupEnd is ignored; an unclosed folder still holds its presets. - let f = parse(&with_phry(2, &[E, G("A"), P, E, E, G("B"), P])).unwrap(); - assert_eq!(f.folders, vec![path(&["A"]), path(&["B"])]); - assert!(f.warnings.iter().any(|w| w.contains("folder end")), "{:?}", f.warnings); - // Fewer preset tokens than presets: the rest are at the top level. - let f = parse(&with_phry(3, &[G("A"), P, E])).unwrap(); - assert_eq!(f.folders, vec![path(&["A"]), path(&[]), path(&[])]); - assert!(f.warnings.iter().any(|w| w.contains("list 1 brushes but the file holds 3")), "{:?}", f.warnings); - // More preset tokens than presets: extras ignored. - let f = parse(&with_phry(1, &[G("A"), P, P, P, E])).unwrap(); - assert_eq!(f.folders, vec![path(&["A"])]); - assert_eq!(f.presets.len(), 1); - assert!(!f.warnings.is_empty()); - // A depth bomb: folders deeper than the limit merge into their ancestor at the limit. - let mut toks: Vec = (0..10_000).map(|_| G("deep")).collect(); - toks.push(P); - toks.extend((0..10_000).map(|_| E)); - toks.extend([G("next"), P, E]); - let f = parse(&with_phry(2, &toks)).unwrap(); - assert_eq!(f.folders[0].len(), MAX_FOLDER_DEPTH); - assert_eq!(f.folders[1], path(&["next"])); - assert!(f.warnings.iter().any(|w| w.contains("deeper")), "{:?}", f.warnings); - // An unreadable hierarchy: imported flat, with a warning. - let mut bytes = write_v6(2, &[], &[], &[named("x")], false).unwrap(); - section(&mut bytes, b"phry", &[0, 0, 0, 16, 0xff, 0xff]); - let f = parse(&bytes).unwrap(); - assert!(f.folders.is_empty() && f.presets.len() == 1); - assert!(f.warnings.iter().any(|w| w.contains("phry")), "{:?}", f.warnings); - // Truncations and bit flips of a file with a hierarchy never panic. - let good = with_phry(3, &[G("A"), P, G("B"), P, E, E, P]); - for cut in 0..good.len() { - let _ = parse(&good[..cut]); - } - for i in 0..good.len() { - let mut b = good.clone(); - b[i] ^= 0x5a; - let _ = parse(&b); - } -} - -#[test] -fn phry_builds_paths_only_for_the_files_presets() { - use Tok::*; - // A few-KB section listing 100,000 deep presets for a file with 2: two paths are built. - let mut toks: Vec = (0..MAX_FOLDER_DEPTH).map(|_| G("folder")).collect(); - toks.extend((0..100_000).map(|_| P)); - let mut warnings = Vec::new(); - let folders = read_phry(&phry_bytes(&toks), 2, &mut warnings).unwrap(); - assert_eq!(folders.len(), 2); - assert_eq!(folders.capacity(), 2); - assert_eq!(folders[1].len(), MAX_FOLDER_DEPTH); - assert!(warnings.iter().any(|w| w.contains("list 100000 brushes but the file holds 2")), "{warnings:?}"); - // `phry` before `desc` still pairs with the file's presets. - let mut bytes = write_v6(2, &[], &[], &[named("a"), named("b")], false).unwrap(); - let at = bytes.windows(8).position(|w| w == b"8BIMdesc").unwrap(); - let mut phry = Vec::new(); - section(&mut phry, b"phry", &phry_bytes(&[G("A"), P, E, P])); - bytes.splice(at..at, phry); - let f = parse(&bytes).unwrap(); - assert_eq!(f.folders, vec![path(&["A"]), path(&[])]); - assert!(f.warnings.is_empty(), "{:?}", f.warnings); -} - -#[test] -fn phry_folder_paths_stay_within_their_byte_budget() { - use Tok::*; - // Thousands of presets under 32 folders of 255-character names would need ~27 MB of paths. - let long = "x".repeat(MAX_FOLDER_NAME); - let mut toks: Vec = (0..MAX_FOLDER_DEPTH).map(|_| G(&long)).collect(); - toks.extend((0..3_000).map(|_| P)); - let mut warnings = Vec::new(); - let folders = read_phry(&phry_bytes(&toks), 3_000, &mut warnings).unwrap(); - assert_eq!(folders.len(), 3_000); - let held: usize = folders.iter().flatten().map(|n| n.len() + size_of::()).sum(); - assert!(held <= MAX_FOLDER_BYTES, "{held}"); - assert_eq!(folders[0].len(), MAX_FOLDER_DEPTH); - assert!(folders[2_999].is_empty(), "past the budget: top level"); - assert!(warnings.iter().any(|w| w.contains("too large")), "{warnings:?}"); -} - -#[test] -fn phry_round_trips_through_the_writer() { - let presets: Vec = (0..6).map(|i| named(&format!("B{i}"))).collect(); - let folders = vec![path(&[]), path(&["Set", "Ink"]), path(&["Set", "Ink"]), path(&["Set"]), path(&["Other"]), path(&["Set", "Ink"])]; - let bytes = write_v6_folders(2, &[], &[], &presets, &folders, true).unwrap(); - let f = parse(&bytes).unwrap(); - assert_eq!(f.presets, presets); - assert_eq!(f.folders, folders); - assert!(f.warnings.is_empty(), "{:?}", f.warnings); - // No folders: no phry section, same bytes as write_v6. - assert_eq!(write_v6_folders(2, &[], &[], &presets, &vec![Vec::new(); 6], true).unwrap(), write_v6(2, &[], &[], &presets, true).unwrap()); - // Mismatched lengths are an error. - assert!(write_v6_folders(2, &[], &[], &presets, &folders[..2], true).is_err()); -} diff --git a/crates/psd/src/aco.rs b/crates/psd/src/aco.rs deleted file mode 100644 index 24f0b1c6f1..0000000000 --- a/crates/psd/src/aco.rs +++ /dev/null @@ -1,341 +0,0 @@ -//! Photoshop Color Swatches files (`.aco`), versions 1 and 2. -//! -//! Layout from Adobe's public "Photoshop File Formats Specification" (Color Swatches): a -//! version 1 section (`u16` version = 1, `u16` count, then `count` colour records of a `u16` -//! colour space id and four `u16` values), usually followed by a version 2 section with the -//! same colours plus a name each (a `u32` UTF-16 code-unit count that includes the trailing -//! null, then the big-endian units). Files that start with the version 2 section are read too. -//! -//! Colour encodings (spec, "Color structure"): RGB and HSB are full 16-bit values; CMYK stores -//! 0 for 100 % ink; Lab stores L* × 100 (0..=10000) and signed a*, b* × 100; grayscale stores a -//! gray level 0..=10000 where 0 is black (public notes on the format convert it with -//! `level / 39.0625` to 0..255). Colours are kept at their 16-bit precision in the space they -//! were stored in; any other colour space (colour books, wide CMYK, …) is kept verbatim so a -//! read → write round trip never loses it. Converting colours is the caller's job. - -use crate::error::{PsdError, Result}; -use crate::io::{Reader, WriteExt}; - -/// Most swatches in one file (the counts are 16-bit). -pub const MAX_SWATCHES: usize = u16::MAX as usize; -/// Longest name read or written, in UTF-16 code units (Photoshop's own limit is far lower). -pub const MAX_NAME_UNITS: usize = 1024; - -/// Colour space ids of the `.aco` colour record. -pub mod space { - /// RGB. - pub const RGB: u16 = 0; - /// HSB. - pub const HSB: u16 = 1; - /// CMYK. - pub const CMYK: u16 = 2; - /// CIE L*a*b*. - pub const LAB: u16 = 7; - /// Grayscale. - pub const GRAY: u16 = 8; -} - -/// A swatch colour as stored, at full 16-bit precision. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum AcoColor { - /// Red, green, blue, 0..=65535. - Rgb([u16; 3]), - /// Hue (0..=65535 for 0..360°), saturation and brightness (0..=65535). - Hsb([u16; 3]), - /// Cyan, magenta, yellow, black **ink** amounts, 0..=65535 (65535 = 100 % ink; the file - /// stores the complement). - Cmyk([u16; 4]), - /// L* × 100 (0..=10000), a* × 100 and b* × 100 (−12800..=12700). - Lab { - /// Lightness × 100. - l: u16, - /// a* × 100. - a: i16, - /// b* × 100. - b: i16, - }, - /// Gray level 0..=10000 (0 = black, 10000 = white). - Gray(u16), - /// Any other colour space, kept verbatim (the four values as stored). - Other { - /// Colour space id. - space: u16, - /// The four stored values. - values: [u16; 4], - }, -} - -impl AcoColor { - /// Decode a colour record (space id + four stored values). - pub fn from_record(space: u16, v: [u16; 4]) -> AcoColor { - match space { - space::RGB => AcoColor::Rgb([v[0], v[1], v[2]]), - space::HSB => AcoColor::Hsb([v[0], v[1], v[2]]), - space::CMYK => AcoColor::Cmyk(v.map(|x| u16::MAX - x)), - space::LAB => AcoColor::Lab { l: v[0], a: v[1] as i16, b: v[2] as i16 }, - space::GRAY => AcoColor::Gray(v[0]), - _ => AcoColor::Other { space, values: v }, - } - } - - /// The colour record (space id + four stored values) for this colour. - pub fn to_record(self) -> (u16, [u16; 4]) { - match self { - AcoColor::Rgb([r, g, b]) => (space::RGB, [r, g, b, 0]), - AcoColor::Hsb([h, s, b]) => (space::HSB, [h, s, b, 0]), - AcoColor::Cmyk(c) => (space::CMYK, c.map(|x| u16::MAX - x)), - AcoColor::Lab { l, a, b } => (space::LAB, [l, a as u16, b as u16, 0]), - AcoColor::Gray(g) => (space::GRAY, [g, 0, 0, 0]), - AcoColor::Other { space, values } => (space, values), - } - } -} - -/// One swatch: a colour and its name (empty in version 1 only files). -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct AcoSwatch { - /// Name (from the version 2 section). - pub name: String, - /// Colour. - pub color: AcoColor, -} - -/// A parsed `.aco` file. -#[derive(Debug, Clone, PartialEq, Eq, Default)] -pub struct AcoFile { - /// Swatches in file order. - pub swatches: Vec, - /// Whether the file had a version 2 section (names). - pub has_names: bool, -} - -fn read_record(r: &mut Reader<'_>) -> Result { - let space = r.u16()?; - let v = [r.u16()?, r.u16()?, r.u16()?, r.u16()?]; - Ok(AcoColor::from_record(space, v)) -} - -fn read_name(r: &mut Reader<'_>) -> Result { - let n = u64::from(r.u32()?); - r.check_count(n, 2)?; - let n = usize::try_from(n).map_err(|_| PsdError::LimitExceeded("swatch name length"))?; - let mut units = Vec::with_capacity(n.min(MAX_NAME_UNITS)); - for _ in 0..n { - let u = r.u16()?; - if units.len() < MAX_NAME_UNITS { - units.push(u); - } - } - while units.last() == Some(&0) { - units.pop(); - } - Ok(String::from_utf16_lossy(&units)) -} - -fn read_section(r: &mut Reader<'_>, named: bool) -> Result> { - let count = r.u16()? as usize; - // Each record is at least 10 bytes (plus a 4-byte name length in version 2). - r.check_count(count as u64, if named { 14 } else { 10 })?; - let mut out = Vec::with_capacity(count); - for _ in 0..count { - let color = read_record(r)?; - let name = if named { read_name(r)? } else { String::new() }; - out.push(AcoSwatch { name, color }); - } - Ok(out) -} - -/// Parse an `.aco` file. Truncated sections, unknown versions and impossible counts are errors; -/// never panics. -pub fn parse(data: &[u8]) -> Result { - let mut r = Reader::new(data); - match r.u16()? { - 1 => { - let v1 = read_section(&mut r, false)?; - // A version 2 section follows in files written by Photoshop 6 and later; anything - // else after version 1 (padding some writers add) is ignored. - if r.remaining() >= 4 && r.peek_rest().get(..2) == Some(&[0, 2]) { - r.u16()?; - let v2 = read_section(&mut r, true)?; - return Ok(AcoFile { swatches: v2, has_names: true }); - } - Ok(AcoFile { swatches: v1, has_names: false }) - } - 2 => Ok(AcoFile { swatches: read_section(&mut r, true)?, has_names: true }), - v => Err(PsdError::UnsupportedVersion(v)), - } -} - -/// How many bytes the `.aco` sections at the start of `data` span (what [`parse`] reads), or -/// `None` when they are truncated or the version is unknown. Tells a real `.aco` apart from -/// bytes that only start like one, such as an `.act` whose first colour is `0, 1, x`. -pub fn sections_len(data: &[u8]) -> Option { - fn skip_section(r: &mut Reader<'_>, named: bool) -> Result<()> { - let count = r.u16()?; - for _ in 0..count { - r.skip(10)?; - if named { - let n = u64::from(r.u32()?); - r.bytes_u64(n * 2)?; - } - } - Ok(()) - } - let mut r = Reader::new(data); - match r.u16().ok()? { - 1 => { - skip_section(&mut r, false).ok()?; - if r.remaining() >= 4 && r.peek_rest().get(..2) == Some(&[0, 2]) { - r.u16().ok()?; - skip_section(&mut r, true).ok()?; - } - } - 2 => skip_section(&mut r, true).ok()?, - _ => return None, - } - Some(r.pos()) -} - -/// Write an `.aco` file: a version 1 section followed by a version 2 section with the names, -/// as Photoshop writes them. Fails when there are more than [`MAX_SWATCHES`] swatches. -pub fn write(swatches: &[AcoSwatch]) -> Result> { - let count = u16::try_from(swatches.len()).map_err(|_| PsdError::LimitExceeded("an .aco file holds at most 65535 swatches"))?; - let mut out = Vec::with_capacity(8 + swatches.len() * 40); - for version in [1u16, 2] { - out.put_u16(version); - out.put_u16(count); - for s in swatches { - let (space, v) = s.color.to_record(); - out.put_u16(space); - for x in v { - out.put_u16(x); - } - if version == 2 { - let units = crate::io::utf16_units(&s.name, MAX_NAME_UNITS); - // The length counts the terminating null. - out.put_u32(units.len() as u32 + 1); - for u in units { - out.put_u16(u); - } - out.put_u16(0); - } - } - } - Ok(out) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn sample() -> Vec { - vec![ - AcoSwatch { name: "Pure Red".into(), color: AcoColor::Rgb([65535, 0, 0]) }, - AcoSwatch { name: "Hue".into(), color: AcoColor::Hsb([12000, 65535, 40000]) }, - AcoSwatch { name: "Cyan ink".into(), color: AcoColor::Cmyk([65535, 0, 0, 1234]) }, - AcoSwatch { name: "Lab −".into(), color: AcoColor::Lab { l: 5321, a: -12800, b: 12700 } }, - AcoSwatch { name: "Gray".into(), color: AcoColor::Gray(5000) }, - AcoSwatch { name: "Book 日本".into(), color: AcoColor::Other { space: 3, values: [1, 2, 3, 4] } }, - AcoSwatch { name: String::new(), color: AcoColor::Rgb([1, 2, 3]) }, - ] - } - - #[test] - fn sections_len_is_what_parse_reads() { - let file = write(&sample()).unwrap(); - assert_eq!(sections_len(&file), Some(file.len())); - let mut padded = file.clone(); - padded.extend_from_slice(&[0; 6]); - assert_eq!(sections_len(&padded), Some(file.len()), "trailing bytes are not part of it"); - assert_eq!(sections_len(&file[..file.len() - 1]), None, "truncated"); - assert_eq!(sections_len(&[0, 3, 0, 0]), None, "unknown version"); - assert_eq!(sections_len(&[0, 1, 0, 0]), Some(4)); - } - - #[test] - fn round_trips_every_space_at_16_bits() { - let s = sample(); - let bytes = write(&s).unwrap(); - let f = parse(&bytes).unwrap(); - assert!(f.has_names); - assert_eq!(f.swatches, s); - assert_eq!(write(&f.swatches).unwrap(), bytes, "byte-stable"); - } - - #[test] - fn decodes_the_spec_encodings() { - // Pure cyan as the spec gives it: 0, 65535, 65535, 65535 (0 = 100 % ink). - assert_eq!(AcoColor::from_record(2, [0, 65535, 65535, 65535]), AcoColor::Cmyk([65535, 0, 0, 0])); - // White Lab = 10000, 0, 0; negative chrominance is two's complement. - assert_eq!(AcoColor::from_record(7, [10000, 0xFF38, 200, 0]), AcoColor::Lab { l: 10000, a: -200, b: 200 }); - assert_eq!(AcoColor::from_record(8, [10000, 0, 0, 0]), AcoColor::Gray(10000)); - assert_eq!(AcoColor::from_record(9, [7, 8, 9, 10]), AcoColor::Other { space: 9, values: [7, 8, 9, 10] }); - } - - #[test] - fn reads_version_1_only_and_version_2_only() { - // Version 1: two RGB colours, no names. - let v1 = [0u8, 1, 0, 2, 0, 0, 255, 255, 0, 0, 0, 0, 0, 0, 0, 8, 0x13, 0x88, 0, 0, 0, 0, 0, 0]; - let f = parse(&v1).unwrap(); - assert!(!f.has_names); - assert_eq!(f.swatches.iter().map(|s| s.color).collect::>(), vec![AcoColor::Rgb([65535, 0, 0]), AcoColor::Gray(5000)]); - // Trailing padding after a version 1 section is ignored. - let mut padded = v1.to_vec(); - padded.extend_from_slice(&[0, 0, 0, 0]); - assert_eq!(parse(&padded).unwrap().swatches.len(), 2); - // Version 2 only (the v1 section is skipped by some writers); name without its null. - let mut v2 = vec![0u8, 2, 0, 1, 0, 0, 0, 1, 0, 2, 0, 3, 0, 0]; - v2.extend_from_slice(&[0, 0, 0, 2, 0, b'O', 0, b'K']); - let f = parse(&v2).unwrap(); - assert_eq!(f.swatches, vec![AcoSwatch { name: "OK".into(), color: AcoColor::Rgb([1, 2, 3]) }]); - } - - #[test] - fn hostile_input_is_an_error_not_a_panic() { - let good = write(&sample()).unwrap(); - for n in 0..good.len() { - // Every truncation inside a section fails (a cut exactly after v1 reads v1). - let r = parse(&good[..n]); - let v1_end = 4 + sample().len() * 10; - if n == v1_end || (n > v1_end && n < v1_end + 4) { - assert!(r.is_ok(), "{n}"); - } else { - assert!(r.is_err(), "{n}"); - } - } - assert!(parse(&[]).is_err()); - assert!(parse(&[0, 3, 0, 0]).is_err(), "unknown version"); - assert!(parse(&[0, 1, 0xFF, 0xFF]).is_err(), "huge count"); - // A name length far beyond the data. - let mut bad = vec![0u8, 2, 0, 1, 0, 0, 0, 1, 0, 2, 0, 3, 0, 0]; - bad.extend_from_slice(&[0xFF, 0xFF, 0xFF, 0xFF, 0, 65]); - assert!(parse(&bad).is_err()); - // Garbage of every length never panics. - let mut seed = 0x1234_5678u32; - for len in 0..300 { - let junk: Vec = (0..len) - .map(|_| { - seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); - (seed >> 24) as u8 - }) - .collect(); - let _ = parse(&junk); - let mut v = vec![0, 1]; - v.extend_from_slice(&junk); - let _ = parse(&v); - } - } - - #[test] - fn too_many_swatches_is_an_error() { - let many = vec![AcoSwatch { name: String::new(), color: AcoColor::Gray(0) }; MAX_SWATCHES + 1]; - assert!(write(&many).is_err()); - } - - #[test] - fn long_names_are_capped() { - let long = AcoSwatch { name: "x".repeat(5000), color: AcoColor::Gray(0) }; - let f = parse(&write(&[long]).unwrap()).unwrap(); - assert_eq!(f.swatches[0].name.len(), MAX_NAME_UNITS); - } -} diff --git a/crates/psd/src/act.rs b/crates/psd/src/act.rs deleted file mode 100644 index 58e872bf97..0000000000 --- a/crates/psd/src/act.rs +++ /dev/null @@ -1,143 +0,0 @@ -//! Photoshop Color Table files (`.act`): raw 8-bit RGB palettes. -//! -//! Layout from Adobe's public "Photoshop File Formats Specification" (Color Table): 256 RGB -//! triplets (768 bytes), optionally followed by a 4-byte trailer: a big-endian `u16` color -//! count ("the number of colors to use") and a big-endian `u16` transparent color index. -//! There are no names, no groups and no other color spaces. The trailer is kept verbatim so a -//! read → write round trip never loses it; interpreting it is the caller's job. - -use crate::error::{PsdError, Result}; -use crate::io::{Reader, WriteExt}; - -/// Colors in a `.act` palette. -pub const COLOR_COUNT: usize = 256; -/// Palette bytes: 256 RGB triplets. -pub const PALETTE_LEN: usize = COLOR_COUNT * 3; -/// Trailer bytes (color count + transparent index). -pub const TRAILER_LEN: usize = 4; - -/// A parsed `.act` file: the full 256-color palette plus the optional trailer, kept verbatim. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ActFile { - /// All 256 palette colors as RGB triplets. - pub colors: [[u8; 3]; COLOR_COUNT], - /// Trailer color count ("the number of colors to use"), if the file has one. - pub count: Option, - /// Trailer transparent color index, if the file has one. - pub transparent: Option, -} - -impl ActFile { - /// Colors to use: the trailer count clamped to the palette, else the whole palette. - pub fn used(&self) -> &[[u8; 3]] { - let n = self.count.map_or(COLOR_COUNT, |c| (c as usize).min(COLOR_COUNT)); - &self.colors[..n] - } - - /// The transparent color index, if the trailer names one inside the used colors. - pub fn transparent_index(&self) -> Option { - let t = usize::from(self.transparent?); - (t < self.used().len()).then_some(t) - } -} - -/// Parse an `.act` file. Anything but 768 bytes (palette only) or 772 bytes (plus the -/// trailer) is an error; never panics. -pub fn parse(data: &[u8]) -> Result { - if data.len() != PALETTE_LEN && data.len() != PALETTE_LEN + TRAILER_LEN { - return Err(PsdError::invalid("an .act file is 768 bytes, or 772 with the count trailer")); - } - let mut r = Reader::new(data); - let raw = r.bytes(PALETTE_LEN)?; - let mut colors = [[0u8; 3]; COLOR_COUNT]; - for (i, c) in colors.iter_mut().enumerate() { - let o = i * 3; - c.copy_from_slice(&raw[o..o + 3]); - } - let (count, transparent) = if r.remaining() >= TRAILER_LEN { (Some(r.u16()?), Some(r.u16()?)) } else { (None, None) }; - Ok(ActFile { colors, count, transparent }) -} - -/// Write an `.act` file: the 256-color palette plus the trailer when `count` or `transparent` is given. -/// Short palettes are padded with black; fails on a palette longer than 256 colors. -pub fn write(colors: &[[u8; 3]], count: Option, transparent: Option) -> Result> { - if colors.len() > COLOR_COUNT { - return Err(PsdError::LimitExceeded("an .act file holds at most 256 colors")); - } - let mut out = Vec::with_capacity(PALETTE_LEN + TRAILER_LEN); - for c in colors { - out.extend_from_slice(c); - } - out.resize(PALETTE_LEN, 0); - if count.is_some() || transparent.is_some() { - out.put_u16(count.unwrap_or(COLOR_COUNT as u16)); - out.put_u16(transparent.unwrap_or(0xFFFF)); - } - Ok(out) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn sample() -> [[u8; 3]; COLOR_COUNT] { - let mut colors = [[0u8; 3]; COLOR_COUNT]; - for (i, c) in colors.iter_mut().enumerate() { - let v = i as u8; - *c = [v, v.wrapping_mul(7), v.wrapping_add(13)]; - } - colors[0] = [255, 255, 255]; - colors - } - - #[test] - fn round_trips_with_and_without_the_trailer() { - let colors = sample(); - let bare = write(&colors, None, None).unwrap(); - assert_eq!(bare.len(), PALETTE_LEN); - let f = parse(&bare).unwrap(); - assert_eq!((f.count, f.transparent), (None, None)); - assert_eq!(f.used(), &colors); - assert_eq!(f.transparent_index(), None); - assert_eq!(write(&f.colors, f.count, f.transparent).unwrap(), bare, "byte-stable"); - - let trailer = write(&colors, Some(40), Some(7)).unwrap(); - assert_eq!(trailer.len(), PALETTE_LEN + TRAILER_LEN); - let f = parse(&trailer).unwrap(); - assert_eq!((f.count, f.transparent), (Some(40), Some(7))); - assert_eq!(f.used().len(), 40); - assert_eq!(f.transparent_index(), Some(7)); - assert_eq!(write(&f.colors, f.count, f.transparent).unwrap(), trailer, "byte-stable"); - } - - #[test] - fn short_palettes_pad_and_long_ones_fail() { - let few = vec![[1u8, 2, 3]; 3]; - let bytes = write(&few, None, None).unwrap(); - assert_eq!(bytes.len(), PALETTE_LEN); - assert_eq!(&bytes[..9], &[1u8, 2, 3, 1, 2, 3, 1, 2, 3]); - assert_eq!(&bytes[9..12], &[0u8, 0, 0]); - let many = vec![[0u8; 3]; COLOR_COUNT + 1]; - assert!(write(&many, None, None).is_err()); - } - - #[test] - fn hostile_lengths_are_errors_not_panics() { - let good = write(&sample(), Some(256), Some(255)).unwrap(); - assert_eq!(good.len(), PALETTE_LEN + TRAILER_LEN); - for n in 0..good.len() { - // Only the two exact lengths parse (768 reads as a trailer-less palette). - if n == PALETTE_LEN { - assert!(parse(&good[..n]).is_ok(), "{n}"); - } else { - assert!(parse(&good[..n]).is_err(), "{n}"); - } - } - assert!(parse(&[]).is_err()); - assert!(parse(&vec![0u8; PALETTE_LEN + TRAILER_LEN + 1]).is_err(), "trailing byte"); - // A count past the palette clamps; a transparent index past it is none. - let f = parse(&write(&sample(), Some(1000), Some(999)).unwrap()).unwrap(); - assert_eq!(f.used().len(), COLOR_COUNT); - assert_eq!(f.transparent_index(), None); - } -} diff --git a/crates/psd/src/animation.rs b/crates/psd/src/animation.rs deleted file mode 100644 index de29693f6c..0000000000 --- a/crates/psd/src/animation.rs +++ /dev/null @@ -1,228 +0,0 @@ -//! Legacy Photoshop frame-animation syntax, independent of playback or document semantics. -//! -//! Observed in a CS5-authored PSD: a plug-in image resource (id in 4000..=4999) starts with -//! `maniIRFR`, a big-endian u32 byte length, then `8BIM` / key / u32 length / data records. -//! `AnDs` holds a version-16 ActionDescriptor; other records (including `Roll`) stay opaque. -//! Layer `shmd` items `mlst` and `tmln`, and image resource 1075, also hold versioned descriptors. -//! These undocumented layouts were established by inspecting file structure, not proprietary code. -//! -//! Raw records remain the source of truth: typed accessors do not rewrite them, discard unknown -//! fields, interpret sparse layer-state inheritance, or assume a frame id is its sequence index. -//! This module does **not** import an animation into PhotoCraft or evaluate timeline tracks. - -use crate::descriptor::{Descriptor, Value, VersionedDescriptor}; -use crate::error::{PsdError, Result}; -use crate::io::{Reader, WriteExt}; - -/// A record in a `mani` resource. Data includes any padding counted in its stored length. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct AnimationRecord<'a> { - /// Record key (`AnDs`, `Roll`, or an unknown key). - pub key: [u8; 4], - /// Borrowed, unmodified payload. - pub data: &'a [u8], -} - -impl AnimationRecord<'_> { - /// Typed view of `AnDs`; `None` for other records, including `Roll`. - pub fn descriptor(&self) -> Option> { - (self.key == *b"AnDs").then(|| parse_descriptor(self.data)) - } -} - -/// The records of a legacy frame-animation plug-in resource, in stored order. -/// -/// Unknown keys and duplicate records are preserved, not silently collapsed. The observed -/// envelope has no alignment bytes outside record lengths. Unsupported envelopes return an error; -/// callers should keep their original image resource unchanged in that case. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct AnimationResource<'a> { - /// Ordered records, borrowing their payloads from the input. - pub records: Vec>, -} - -impl<'a> AnimationResource<'a> { - /// Parses a complete `maniIRFR` payload, checking the outer and every inner length. - /// Descriptor decoding is lazy, so unknown/future `AnDs` versions can still round-trip raw. - pub fn from_bytes(data: &'a [u8]) -> Result { - let mut r = Reader::new(data); - signature(&mut r, *b"mani", "mani")?; - signature(&mut r, *b"IRFR", "IRFR animation envelope")?; - let len = r.u32()?; - let mut body = r.sub(u64::from(len))?; - if !r.is_empty() { - return Err(PsdError::invalid("trailing bytes after animation envelope")); - } - let mut records = Vec::new(); - while !body.is_empty() { - signature(&mut body, *b"8BIM", "8BIM animation record")?; - let key = body.array()?; - let len = body.u32()?; - let data = body.bytes_u64(u64::from(len))?; - records.push(AnimationRecord { key, data }); - } - Ok(Self { records }) - } - - /// Writes the raw records, preserving their payloads exactly, including unknown fields, - /// unknown descriptor versions and noncanonical descriptor encodings. - pub fn to_bytes(&self) -> Result> { - // Check all lengths before constructing the output; no unchecked 32-bit length casts. - let mut len = 0u32; - for record in &self.records { - let n = u32::try_from(record.data.len()).map_err(|_| PsdError::LimitExceeded("animation record exceeds u32 length"))?; - len = len.checked_add(12).and_then(|v| v.checked_add(n)).ok_or(PsdError::LimitExceeded("animation envelope exceeds u32 length"))?; - } - let mut out = Vec::new(); - out.put(b"maniIRFR"); - out.put_u32(len); - for record in &self.records { - out.put(b"8BIM"); - out.put(&record.key); - out.put_len(record.data.len() as u64, false)?; - out.put(record.data); - } - Ok(out) - } -} - -fn signature(r: &mut Reader<'_>, expected: [u8; 4], name: &'static str) -> Result<()> { - let found = r.array()?; - if found != expected { - return Err(PsdError::InvalidSignature { expected: name, found }); - } - Ok(()) -} - -/// Parses a version-16 animation descriptor, allowing up to three zero alignment bytes. -/// -/// Raw payloads should be retained separately for byte-exact writing. This accessor is strict -/// about non-padding trailing bytes and does not guess how to read a future descriptor version. -/// It is shared by `AnDs`, layer `mlst` / `tmln`, and document timeline resource 1075. -pub fn parse_descriptor(data: &[u8]) -> Result { - let (value, consumed) = VersionedDescriptor::parse_prefix(data)?; - let tail = data.get(consumed..).ok_or_else(|| PsdError::invalid("animation descriptor length"))?; - if tail.len() > 3 || tail.iter().any(|&b| b != 0) { - return Err(PsdError::invalid("trailing bytes after animation descriptor")); - } - Ok(value) -} - -/// A frame-definition view. Its id is an opaque signed `long`, not a frame index. -#[derive(Debug, Clone, PartialEq)] -pub struct FrameInfo<'a> { - /// `FrID`, preserved without reinterpreting its bit pattern. - pub id: i32, - /// `FrDl` in centiseconds, or absent. No playback default or browser clamping is applied. - pub delay_centiseconds: Option, - /// All fields, including unknown ones, in their original order. - pub descriptor: &'a Descriptor, -} - -/// Reads the `FrIn` list from an `AnDs` descriptor. A missing list is not an empty animation. -/// Wrong types, missing frame ids and negative delays return errors. Duplicate ids are retained -/// for the document importer to diagnose rather than being silently overwritten here. -pub fn frame_info(descriptor: &Descriptor) -> Result>> { - let Some(Value::List(list)) = descriptor.get("FrIn") else { - return Err(PsdError::invalid("animation FrIn must be a list")); - }; - list.iter() - .map(|v| { - let Value::Descriptor(d) = v else { return Err(PsdError::invalid("animation frame must be a descriptor")) }; - let Some(Value::Integer(id)) = d.get("FrID") else { return Err(PsdError::invalid("animation frame FrID must be a long")) }; - let delay_centiseconds = match d.get("FrDl") { - None => None, - Some(Value::Integer(n)) => Some(u32::try_from(*n).map_err(|_| PsdError::invalid("negative animation frame delay"))?), - _ => return Err(PsdError::invalid("animation frame FrDl must be a long")), - }; - Ok(FrameInfo { id: *id, delay_centiseconds, descriptor: d }) - }) - .collect() -} - -/// A stored frame set: explicit playback order is separate from the `FrIn` definitions. -#[derive(Debug, Clone, PartialEq)] -pub struct FrameSet<'a> { - /// `FsID`, an opaque identifier. - pub id: i32, - /// `AFrm`, when present. Kept uninterpreted for the importer to validate. - pub active_frame: Option, - /// `FsFr` frame ids in stored order (including any duplicate or unresolved ids). - pub frame_ids: Vec, - /// Complete original descriptor, including fields not yet understood. - pub descriptor: &'a Descriptor, -} - -/// One sparse layer-state entry from `mlst` / `LaSt`. -#[derive(Debug, Clone, PartialEq)] -pub struct LayerFrameState<'a> { - /// `FrLs`: frame ids to which this entry refers. They are not frame indices. - pub frame_ids: Vec, - /// Complete sparse state, including visibility, blend options, offsets, masks, effects, - /// text settings and unknown fields. Omitted properties are deliberately not defaulted. - pub descriptor: &'a Descriptor, -} - -/// A layer's sparse animation states, without attempting to resolve their inheritance. -#[derive(Debug, Clone, PartialEq)] -pub struct LayerFrameStates<'a> { - /// `LaID`: PSD layer identifier, not the layer's record index. - pub layer_id: i32, - /// `LaSt` entries in stored order. - pub states: Vec>, -} - -/// Reads the identifiers and sparse entries of an `mlst` descriptor. This is a syntax view: -/// missing properties stay missing, and duplicate/unresolved references remain available for -/// the importer to validate. The original descriptor remains available to the caller. -pub fn layer_frame_states(descriptor: &Descriptor) -> Result> { - let Some(Value::Integer(layer_id)) = descriptor.get("LaID") else { - return Err(PsdError::invalid("animation layer LaID must be a long")); - }; - let Some(Value::List(list)) = descriptor.get("LaSt") else { - return Err(PsdError::invalid("animation layer LaSt must be a list")); - }; - let states = list - .iter() - .map(|v| { - let Value::Descriptor(d) = v else { return Err(PsdError::invalid("animation layer state must be a descriptor")) }; - let Some(Value::List(ids)) = d.get("FrLs") else { return Err(PsdError::invalid("animation layer FrLs must be a list")) }; - let frame_ids = ids - .iter() - .map(|v| match v { - Value::Integer(n) => Ok(*n), - _ => Err(PsdError::invalid("animation layer frame reference must be a long")), - }) - .collect::>>()?; - Ok(LayerFrameState { frame_ids, descriptor: d }) - }) - .collect::>>()?; - Ok(LayerFrameStates { layer_id: *layer_id, states }) -} - -/// Reads `FSts` without inferring order from frame ids or interpreting loop-related fields. -pub fn frame_sets(descriptor: &Descriptor) -> Result>> { - let Some(Value::List(list)) = descriptor.get("FSts") else { - return Err(PsdError::invalid("animation FSts must be a list")); - }; - list.iter() - .map(|v| { - let Value::Descriptor(d) = v else { return Err(PsdError::invalid("animation frame set must be a descriptor")) }; - let Some(Value::Integer(id)) = d.get("FsID") else { return Err(PsdError::invalid("animation frame set FsID must be a long")) }; - let active_frame = match d.get("AFrm") { - None => None, - Some(Value::Integer(n)) => Some(*n), - _ => return Err(PsdError::invalid("animation frame set AFrm must be a long")), - }; - let Some(Value::List(ids)) = d.get("FsFr") else { return Err(PsdError::invalid("animation frame set FsFr must be a list")) }; - let frame_ids = ids - .iter() - .map(|v| match v { - Value::Integer(n) => Ok(*n), - _ => Err(PsdError::invalid("animation frame reference must be a long")), - }) - .collect::>>()?; - Ok(FrameSet { id: *id, active_frame, frame_ids, descriptor: d }) - }) - .collect() -} diff --git a/crates/psd/src/ase.rs b/crates/psd/src/ase.rs deleted file mode 100644 index f63db5481a..0000000000 --- a/crates/psd/src/ase.rs +++ /dev/null @@ -1,439 +0,0 @@ -//! Adobe Swatch Exchange files (`.ase`), shared by Photoshop, Illustrator and InDesign. -//! -//! Layout from public descriptions of the format (big-endian throughout): the signature -//! `ASEF`, version `u16` major (1) and minor (0), a `u32` block count, then blocks of a `u16` -//! type, a `u32` body length and the body: -//! -//! - `0xC001` group start: a name; -//! - `0xC002` group end: empty; -//! - `0x0001` colour entry: a name, a four-character colour model (`RGB `, `CMYK`, `LAB `, -//! `Gray`), its components as `f32` (3, 4, 3 and 1 of them) and a `u16` colour type -//! (0 global, 1 spot, 2 normal/process). -//! -//! A name is a `u16` UTF-16 code-unit count that includes the trailing null, then the units. -//! Components are kept exactly as stored: RGB, CMYK and gray in 0..1 (gray 1 = white), Lab as -//! L*/100 followed by a* and b*. Unknown colour models and block types are kept verbatim -//! (colour models) or skipped (block types). Converting colours is the caller's job. - -use crate::error::{PsdError, Result}; -use crate::io::{Reader, WriteExt}; - -/// File signature. -pub const SIGNATURE: &[u8; 4] = b"ASEF"; -/// Most colour entries read from one file. -pub const MAX_SWATCHES: usize = 100_000; -/// Most groups read from one file. -pub const MAX_GROUPS: usize = 10_000; -/// Longest name read or written, in UTF-16 code units. -pub const MAX_NAME_UNITS: usize = 1024; -// `put_name` writes the name length as `units.len() as u16 + 1`, so the cap must leave room in a -// u16; this guards that invariant if the constant is ever raised. -const _: () = assert!(MAX_NAME_UNITS < u16::MAX as usize); - -const GROUP_START: u16 = 0xC001; -const GROUP_END: u16 = 0xC002; -const COLOR: u16 = 0x0001; - -/// A colour as stored. -#[derive(Debug, Clone, PartialEq)] -pub enum AseColor { - /// Red, green, blue, 0..1. - Rgb([f32; 3]), - /// Cyan, magenta, yellow, black ink, 0..1. - Cmyk([f32; 4]), - /// L*/100 (0..1), a*, b*. - Lab([f32; 3]), - /// Gray level 0..1 (1 = white). - Gray(f32), - /// Any other colour model, kept verbatim: the model tag and the bytes after it, up to the - /// colour type. - Other { - /// Four-character model tag. - model: [u8; 4], - /// Component bytes as stored. - data: Vec, - }, -} - -/// The colour type of an entry. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum AseKind { - /// A global colour (edits update every use). - Global, - /// A spot colour (its own ink). - Spot, - /// A normal (process) colour. - #[default] - Process, -} - -impl AseKind { - fn from_u16(v: u16) -> AseKind { - match v { - 0 => AseKind::Global, - 1 => AseKind::Spot, - _ => AseKind::Process, - } - } - fn to_u16(self) -> u16 { - match self { - AseKind::Global => 0, - AseKind::Spot => 1, - AseKind::Process => 2, - } - } -} - -/// One colour entry. -#[derive(Debug, Clone, PartialEq)] -pub struct AseSwatch { - /// Name. - pub name: String, - /// Colour. - pub color: AseColor, - /// Colour type. - pub kind: AseKind, -} - -/// A top-level entry: a colour, or a group of colours (the format has one level of groups). -#[derive(Debug, Clone, PartialEq)] -pub enum AseEntry { - /// A colour outside any group. - Swatch(AseSwatch), - /// A named group. - Group { - /// Group name. - name: String, - /// Its colours. - swatches: Vec, - }, -} - -/// A parsed `.ase` file. -#[derive(Debug, Clone, PartialEq, Default)] -pub struct AseFile { - /// Entries in file order. - pub entries: Vec, -} - -impl AseFile { - /// Number of colour entries (in and outside groups). - pub fn swatch_count(&self) -> usize { - self.entries - .iter() - .map(|e| match e { - AseEntry::Swatch(_) => 1, - AseEntry::Group { swatches, .. } => swatches.len(), - }) - .sum() - } -} - -fn f32_be(r: &mut Reader<'_>) -> Result { - let v = f32::from_bits(r.u32()?); - // Non-finite components would poison every conversion downstream. - Ok(if v.is_finite() { v } else { 0.0 }) -} - -fn read_name(r: &mut Reader<'_>) -> Result { - if r.is_empty() { - return Ok(String::new()); - } - let n = r.u16()? as usize; - r.check_count(n as u64, 2)?; - let mut units = Vec::with_capacity(n.min(MAX_NAME_UNITS)); - for _ in 0..n { - let u = r.u16()?; - if units.len() < MAX_NAME_UNITS { - units.push(u); - } - } - while units.last() == Some(&0) { - units.pop(); - } - Ok(String::from_utf16_lossy(&units)) -} - -fn read_color(body: &mut Reader<'_>) -> Result { - let name = read_name(body)?; - let model: [u8; 4] = body.array()?; - let color = match &model { - b"RGB " => AseColor::Rgb([f32_be(body)?, f32_be(body)?, f32_be(body)?]), - b"CMYK" => AseColor::Cmyk([f32_be(body)?, f32_be(body)?, f32_be(body)?, f32_be(body)?]), - b"LAB " => AseColor::Lab([f32_be(body)?, f32_be(body)?, f32_be(body)?]), - b"Gray" => AseColor::Gray(f32_be(body)?), - _ => { - // Unknown model: everything but the trailing colour type is its data. - let n = body.remaining().saturating_sub(2); - AseColor::Other { model, data: body.bytes(n)?.to_vec() } - } - }; - // Some writers omit the colour type; it defaults to process. - let kind = if body.remaining() >= 2 { AseKind::from_u16(body.u16()?) } else { AseKind::Process }; - Ok(AseSwatch { name, color, kind }) -} - -/// Parse an `.ase` file. A wrong signature or version, a truncated block, a block count beyond -/// the data, more than [`MAX_SWATCHES`] colors or more than [`MAX_GROUPS`] groups is an error; -/// never panics. Unknown block types are skipped, a group end without a start is ignored, and a -/// group left open at the end (or a group started inside another) is closed. -pub fn parse(data: &[u8]) -> Result { - let mut r = Reader::new(data); - let sig: [u8; 4] = r.array()?; - if &sig != SIGNATURE { - return Err(PsdError::InvalidSignature { expected: "ASEF", found: sig }); - } - let major = r.u16()?; - let _minor = r.u16()?; - if major != 1 { - return Err(PsdError::UnsupportedVersion(major)); - } - let count = u64::from(r.u32()?); - // Every block has at least a type and a length. - r.check_count(count, 6)?; - let mut entries = Vec::new(); - let mut open: Option<(String, Vec)> = None; - let mut total = 0usize; - let mut groups = 0usize; - for _ in 0..count { - let kind = r.u16()?; - let len = u64::from(r.u32()?); - let mut body = r.sub(len)?; - match kind { - GROUP_START => { - groups += 1; - if groups > MAX_GROUPS { - return Err(PsdError::LimitExceeded("too many groups in an .ase file")); - } - if let Some((name, swatches)) = open.take() { - entries.push(AseEntry::Group { name, swatches }); - } - open = Some((read_name(&mut body)?, Vec::new())); - } - GROUP_END => { - if let Some((name, swatches)) = open.take() { - entries.push(AseEntry::Group { name, swatches }); - } - } - COLOR => { - total += 1; - if total > MAX_SWATCHES { - return Err(PsdError::LimitExceeded("too many swatches in an .ase file")); - } - let s = read_color(&mut body)?; - match open.as_mut() { - Some((_, v)) => v.push(s), - None => entries.push(AseEntry::Swatch(s)), - } - } - _ => {} - } - } - if let Some((name, swatches)) = open.take() { - entries.push(AseEntry::Group { name, swatches }); - } - Ok(AseFile { entries }) -} - -fn put_name(out: &mut Vec, name: &str) { - let units = crate::io::utf16_units(name, MAX_NAME_UNITS); - out.put_u16(units.len() as u16 + 1); - for u in units { - out.put_u16(u); - } - out.put_u16(0); -} - -fn put_block(out: &mut Vec, kind: u16, body: &[u8]) -> Result<()> { - out.put_u16(kind); - out.put_u32(u32::try_from(body.len()).map_err(|_| PsdError::LimitExceeded("swatch block too large"))?); - out.put(body); - Ok(()) -} - -fn put_color(out: &mut Vec, s: &AseSwatch) -> Result<()> { - let mut b = Vec::with_capacity(64); - put_name(&mut b, &s.name); - let floats = |b: &mut Vec, v: &[f32]| { - for x in v { - b.put_u32(x.to_bits()); - } - }; - match &s.color { - AseColor::Rgb(c) => { - b.put(b"RGB "); - floats(&mut b, c); - } - AseColor::Cmyk(c) => { - b.put(b"CMYK"); - floats(&mut b, c); - } - AseColor::Lab(c) => { - b.put(b"LAB "); - floats(&mut b, c); - } - AseColor::Gray(g) => { - b.put(b"Gray"); - floats(&mut b, &[*g]); - } - AseColor::Other { model, data } => { - b.put(model); - b.put(data); - } - } - b.put_u16(s.kind.to_u16()); - put_block(out, COLOR, &b) -} - -/// Write an `.ase` file (version 1.0). Fails only when a block or the block count overflows the -/// format's 32-bit fields or there are more than [`MAX_SWATCHES`] colors or more than -/// [`MAX_GROUPS`] groups. -pub fn write(file: &AseFile) -> Result> { - if file.swatch_count() > MAX_SWATCHES { - return Err(PsdError::LimitExceeded("too many swatches for an .ase file")); - } - if file.entries.iter().filter(|e| matches!(e, AseEntry::Group { .. })).count() > MAX_GROUPS { - return Err(PsdError::LimitExceeded("too many groups for an .ase file")); - } - let mut body = Vec::with_capacity(64 * file.swatch_count().max(1)); - let mut blocks = 0u64; - for e in &file.entries { - match e { - AseEntry::Swatch(s) => { - put_color(&mut body, s)?; - blocks += 1; - } - AseEntry::Group { name, swatches } => { - let mut b = Vec::with_capacity(name.len() * 2 + 4); - put_name(&mut b, name); - put_block(&mut body, GROUP_START, &b)?; - for s in swatches { - put_color(&mut body, s)?; - } - put_block(&mut body, GROUP_END, &[])?; - blocks += 2 + swatches.len() as u64; - } - } - } - let mut out = Vec::with_capacity(12 + body.len()); - out.put(SIGNATURE); - out.put_u16(1); - out.put_u16(0); - out.put_u32(u32::try_from(blocks).map_err(|_| PsdError::LimitExceeded("too many blocks"))?); - out.put(&body); - Ok(out) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn sw(name: &str, color: AseColor, kind: AseKind) -> AseSwatch { - AseSwatch { name: name.into(), color, kind } - } - - fn sample() -> AseFile { - AseFile { - entries: vec![ - AseEntry::Swatch(sw("Loose red", AseColor::Rgb([1.0, 0.0, 0.0]), AseKind::Process)), - AseEntry::Group { - name: "Brand ✓".into(), - swatches: vec![ - sw("Ink", AseColor::Cmyk([0.1, 0.2, 0.3, 0.4]), AseKind::Spot), - sw("Lab", AseColor::Lab([0.5321, -12.25, 40.5]), AseKind::Global), - sw("Mid gray", AseColor::Gray(0.5), AseKind::Process), - sw("Odd", AseColor::Other { model: *b"HSV ", data: vec![1, 2, 3, 4, 5, 6] }, AseKind::Process), - ], - }, - AseEntry::Group { name: String::new(), swatches: vec![] }, - ], - } - } - - #[test] - fn round_trips_groups_models_and_types() { - let f = sample(); - let bytes = write(&f).unwrap(); - assert_eq!(&bytes[..4], b"ASEF"); - // 1 loose + (start + 4 + end) + (start + end). - assert_eq!(u32::from_be_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]), 9); - let back = parse(&bytes).unwrap(); - assert_eq!(back, f); - assert_eq!(write(&back).unwrap(), bytes, "byte-stable"); - assert_eq!(back.swatch_count(), 5); - } - - #[test] - fn lenient_structure() { - // A colour without a type, an unknown block, an end without start, an unclosed group. - let mut body = Vec::new(); - let mut c = Vec::new(); - put_name(&mut c, "A"); - c.put(b"Gray"); - c.put_u32(0.25f32.to_bits()); - put_block(&mut body, GROUP_END, &[]).unwrap(); - put_block(&mut body, 0x7777, &[9, 9, 9]).unwrap(); - let mut g = Vec::new(); - put_name(&mut g, "G"); - put_block(&mut body, GROUP_START, &g).unwrap(); - put_block(&mut body, COLOR, &c).unwrap(); - let mut f = b"ASEF\0\x01\0\0\0\0\0\x04".to_vec(); - f.extend_from_slice(&body); - let p = parse(&f).unwrap(); - assert_eq!(p.entries, vec![AseEntry::Group { name: "G".into(), swatches: vec![sw("A", AseColor::Gray(0.25), AseKind::Process)] }]); - } - - #[test] - fn non_finite_components_become_zero() { - let f = AseFile { entries: vec![AseEntry::Swatch(sw("n", AseColor::Rgb([f32::NAN, f32::INFINITY, 0.5]), AseKind::Process))] }; - let p = parse(&write(&f).unwrap()).unwrap(); - assert_eq!(p.entries, vec![AseEntry::Swatch(sw("n", AseColor::Rgb([0.0, 0.0, 0.5]), AseKind::Process))]); - } - - #[test] - fn hostile_input_is_an_error_not_a_panic() { - let good = write(&sample()).unwrap(); - for n in 0..good.len() { - assert!(parse(&good[..n]).is_err(), "truncated at {n}"); - } - assert!(parse(b"ASEX\0\x01\0\0\0\0\0\0").is_err(), "signature"); - assert!(parse(b"ASEF\0\x02\0\0\0\0\0\0").is_err(), "version"); - assert!(parse(b"ASEF\0\x01\0\0\xff\xff\xff\xff").is_err(), "huge block count"); - // A block length beyond the data. - assert!(parse(b"ASEF\0\x01\0\0\0\0\0\x01\0\x01\xff\xff\xff\xff").is_err()); - // A name length beyond the block. - assert!(parse(b"ASEF\0\x01\0\0\0\0\0\x01\0\x01\0\0\0\x04\xff\xff\0A").is_err()); - // A colour block too short for its components. - assert!(parse(b"ASEF\0\x01\0\0\0\0\0\x01\0\x01\0\0\0\x0a\0\x01\0\0RGB \0\0").is_err()); - let mut seed = 0xDEAD_BEEFu32; - for len in 0..400 { - let junk: Vec = (0..len) - .map(|_| { - seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); - (seed >> 24) as u8 - }) - .collect(); - let mut v = b"ASEF\0\x01\0\0\0\0\0\x10".to_vec(); - v.extend_from_slice(&junk); - let _ = parse(&v); - let _ = parse(&junk); - } - } - - #[test] - fn too_many_swatches() { - let s = sw("", AseColor::Gray(0.0), AseKind::Process); - let f = AseFile { entries: vec![AseEntry::Group { name: "g".into(), swatches: vec![s; MAX_SWATCHES + 1] }] }; - assert!(write(&f).is_err()); - } - - #[test] - fn too_many_groups() { - let g = AseEntry::Group { name: "g".into(), swatches: vec![] }; - let mut f = AseFile { entries: vec![g; MAX_GROUPS + 1] }; - assert!(write(&f).is_err()); - // At the limit the file is written, and every group reads back. - f.entries.pop(); - assert_eq!(parse(&write(&f).unwrap()).unwrap().entries.len(), MAX_GROUPS); - } -} diff --git a/crates/psd/src/asl.rs b/crates/psd/src/asl.rs deleted file mode 100644 index 26cd27b9bc..0000000000 --- a/crates/psd/src/asl.rs +++ /dev/null @@ -1,276 +0,0 @@ -//! Photoshop layer style libraries (`.asl`, the Styles panel's Import Styles… / Export Styles…). -//! -//! Layout of a version 3 style library: -//! -//! ```text -//! file := u16 2, "8BSL", u16 3 (sub-version), -//! u32 pattern section length, patterns (the `Patt` block record format), -//! u32 style count, style × count -//! style := u32 length, then that many bytes: identity, style descriptor, zero padding -//! identity:= versioned descriptor (class `null`): `Nm ` name, `Idnt` id (Unicode strings) -//! style descriptor := versioned descriptor (class `Styl`): `documentMode`, `Lefx` (the same -//! tree as a layer's `lfx2` block), optional `blendOptions` (`Md `, `Opct`, -//! `fillOpacity`) -//! ``` -//! -//! The reader skips up to three padding bytes after each style record whatever its length says, -//! so it reads files whose record length counts the padding and files whose length does not. -//! The writer counts the padding in the record length, which reads correctly either way. -//! -//! Malformed input is an error, never a panic. A pattern section that doesn't decode is reported -//! in [`AslFile::pattern_error`] and skipped (its length is known), so the styles still load. - -use crate::descriptor::{Descriptor, UnicodeString, Value, VersionedDescriptor}; -use crate::error::{PsdError, Result}; -use crate::patterns::{PsdPattern, parse_pattern_block, write_pattern_block}; - -/// Most styles read from one file. -pub const MAX_STYLES: usize = 10_000; - -/// One style of a library. -#[derive(Debug, Clone, PartialEq)] -pub struct AslStyle { - /// The name shown in the Styles panel (may be empty or repeated). - pub name: String, - /// The style's unique id (usually a UUID). - pub id: String, - /// The style descriptor (class `Styl`): `Lefx`, `blendOptions`, `documentMode`. - pub style: Descriptor, -} - -/// A parsed style library. -#[derive(Debug, Clone, PartialEq, Default)] -pub struct AslFile { - /// Patterns embedded for the styles' Pattern Overlays, pattern strokes and bevel textures. - pub patterns: Vec, - /// Why the pattern section was skipped, if it didn't decode (read only). - pub pattern_error: Option, - /// Styles in file order. - pub styles: Vec, -} - -struct Rd<'a> { - b: &'a [u8], - p: usize, -} - -impl<'a> Rd<'a> { - fn take(&mut self, n: usize) -> Result<&'a [u8]> { - let end = self.p.checked_add(n).filter(|&e| e <= self.b.len()).ok_or(PsdError::UnexpectedEof { offset: self.p, needed: n })?; - let s = self.b.get(self.p..end).ok_or(PsdError::UnexpectedEof { offset: self.p, needed: n })?; - self.p = end; - Ok(s) - } - fn u16(&mut self) -> Result { - let b = self.take(2)?; - Ok(u16::from_be_bytes([b[0], b[1]])) - } - fn u32(&mut self) -> Result { - let b = self.take(4)?; - Ok(u32::from_be_bytes([b[0], b[1], b[2], b[3]])) - } - fn remaining(&self) -> usize { - self.b.len().saturating_sub(self.p) - } -} - -fn text(d: &Descriptor, key: &str) -> String { - match d.get(key) { - Some(Value::Text(t)) => t.to_string_lossy(), - _ => String::new(), - } -} - -fn read_style(body: &[u8]) -> Result { - let (identity, used) = VersionedDescriptor::parse_prefix(body)?; - let rest = body.get(used..).ok_or(PsdError::UnexpectedEof { offset: used, needed: 4 })?; - let (style, _) = VersionedDescriptor::parse_prefix(rest)?; - let id = &identity.descriptor; - Ok(AslStyle { name: text(id, "Nm "), id: text(id, "Idnt"), style: style.descriptor }) -} - -/// Parses an `.asl` style library. -pub fn parse(data: &[u8]) -> Result { - let mut r = Rd { b: data, p: 0 }; - let version = r.u16()?; - let sig = r.take(4)?; - if sig != b"8BSL" { - return Err(PsdError::InvalidSignature { expected: "8BSL", found: [sig[0], sig[1], sig[2], sig[3]] }); - } - let sub = r.u16()?; - if version != 2 || sub != 3 { - return Err(PsdError::Unsupported(format!("style library version {version}.{sub} (only 2.3, Photoshop 6 and later)"))); - } - let plen = r.u32()? as usize; - let section = r.take(plen)?; - let (patterns, pattern_error) = match parse_pattern_block(section) { - Ok(p) => (p, None), - Err(e) => (Vec::new(), Some(e.to_string())), - }; - let count = r.u32()? as usize; - // Every style needs at least its own length word. - if count > r.remaining() / 4 { - return Err(PsdError::invalid(format!("{count} styles cannot fit in the remaining {} bytes", r.remaining()))); - } - if count > MAX_STYLES { - return Err(PsdError::LimitExceeded("too many styles in one library")); - } - let mut styles = Vec::with_capacity(count); - for _ in 0..count { - let len = r.u32()? as usize; - let body = r.take(len)?; - styles.push(read_style(body)?); - let pad = (4 - len % 4) % 4; - r.p = r.p.saturating_add(pad).min(data.len()); - } - Ok(AslFile { patterns, pattern_error: pattern_error.filter(|_| !section.is_empty()), styles }) -} - -/// Writes a version 3 `.asl` style library. -pub fn write(file: &AslFile) -> Result> { - let mut out = Vec::new(); - out.extend_from_slice(&2u16.to_be_bytes()); - out.extend_from_slice(b"8BSL"); - out.extend_from_slice(&3u16.to_be_bytes()); - let patterns = write_pattern_block(&file.patterns)?; - let plen = u32::try_from(patterns.len()).map_err(|_| PsdError::LimitExceeded("pattern section larger than 4 GB"))?; - out.extend_from_slice(&plen.to_be_bytes()); - out.extend_from_slice(&patterns); - let count = u32::try_from(file.styles.len()).map_err(|_| PsdError::LimitExceeded("too many styles"))?; - out.extend_from_slice(&count.to_be_bytes()); - for s in &file.styles { - let identity = - Descriptor::new("null").with("Nm ", Value::Text(UnicodeString::new_nul(&s.name))).with("Idnt", Value::Text(UnicodeString::new_nul(&s.id))); - let mut body = VersionedDescriptor::new(identity).to_bytes(); - body.extend(VersionedDescriptor::new(s.style.clone()).to_bytes()); - body.resize(body.len().div_ceil(4) * 4, 0); - let len = u32::try_from(body.len()).map_err(|_| PsdError::LimitExceeded("style larger than 4 GB"))?; - out.extend_from_slice(&len.to_be_bytes()); - out.extend_from_slice(&body); - } - Ok(out) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::descriptor::Id; - - fn pattern() -> PsdPattern { - PsdPattern { - mode: 3, - width: 3, - height: 2, - name: "Dots".into(), - id: "a1b2".into(), - palette: None, - depth: 8, - channels: vec![vec![1, 2, 3, 4, 5, 6], vec![9; 6], vec![200; 6]], - alpha: None, - } - } - - fn style(name: &str) -> AslStyle { - let lefx = Descriptor::new("null").with("masterFXSwitch", Value::Boolean(true)).with( - "SoFi", - Value::Descriptor(Descriptor::new("SoFi").with("enab", Value::Boolean(true)).with("Opct", Value::UnitFloat { unit: *b"#Prc", value: 50.0 })), - ); - let blend = Descriptor::new("blendOptions") - .with("Md ", Value::Enumerated { type_id: Id::new("BlnM"), value: Id::new("Mltp") }) - .with("fillOpacity", Value::UnitFloat { unit: *b"#Prc", value: 25.0 }); - AslStyle { - name: name.into(), - id: format!("id-{name}"), - style: Descriptor::new("Styl") - .with("documentMode", Value::Descriptor(Descriptor::new("documentMode"))) - .with("Lefx", Value::Descriptor(lefx)) - .with("blendOptions", Value::Descriptor(blend)), - } - } - - fn sample() -> AslFile { - AslFile { patterns: vec![pattern()], pattern_error: None, styles: vec![style("Gold"), style(""), style("Ünïcode ✓")] } - } - - #[test] - fn round_trip() { - let f = sample(); - let b = write(&f).unwrap(); - assert_eq!(&b[..8], b"\0\x028BSL\0\x03"); - assert_eq!(b.len() % 4, 0); - assert_eq!(parse(&b).unwrap(), f); - let empty = AslFile::default(); - assert_eq!(parse(&write(&empty).unwrap()).unwrap(), empty); - } - - #[test] - fn record_lengths_without_padding_also_read() { - // A writer that leaves the padding out of each record length. - let f = sample(); - let mut out = write(&AslFile { styles: vec![], ..f.clone() }).unwrap(); - out.truncate(out.len() - 4); - out.extend_from_slice(&(f.styles.len() as u32).to_be_bytes()); - for s in &f.styles { - let identity = - Descriptor::new("null").with("Nm ", Value::Text(UnicodeString::new_nul(&s.name))).with("Idnt", Value::Text(UnicodeString::new_nul(&s.id))); - let mut body = VersionedDescriptor::new(identity).to_bytes(); - body.extend(VersionedDescriptor::new(s.style.clone()).to_bytes()); - body.push(7); // odd length - out.extend_from_slice(&(body.len() as u32).to_be_bytes()); - let pad = (4 - body.len() % 4) % 4; - out.extend_from_slice(&body); - out.extend(std::iter::repeat_n(0, pad)); - } - assert_eq!(parse(&out).unwrap().styles, f.styles); - } - - #[test] - fn bad_pattern_section_is_skipped() { - let mut b = write(&AslFile { patterns: vec![], pattern_error: None, styles: vec![style("A")] }).unwrap(); - // Replace the empty pattern section with 8 bytes of garbage. - let tail = b.split_off(12); - b.truncate(8); - b.extend_from_slice(&8u32.to_be_bytes()); - b.extend_from_slice(&[0, 0, 0, 4, 0xff, 0xff, 0xff, 0xff]); - b.extend(tail); - let f = parse(&b).unwrap(); - assert!(f.pattern_error.is_some()); - assert_eq!(f.styles.len(), 1); - } - - #[test] - fn hostile_input_errors() { - assert!(parse(b"").is_err()); - assert!(parse(b"\0\x028BSL\0\x02").is_err()); - assert!(parse(b"\0\x028BPS\0\x03").is_err()); - // A count far beyond the data. - let mut b = b"\0\x028BSL\0\x03\0\0\0\0".to_vec(); - b.extend_from_slice(&u32::MAX.to_be_bytes()); - assert!(parse(&b).is_err()); - // A pattern length beyond the data. - assert!(parse(b"\0\x028BSL\0\x03\xff\xff\xff\xff").is_err()); - let b = write(&sample()).unwrap(); - for cut in 0..b.len() { - assert!(parse(&b[..cut]).is_err(), "cut at {cut}"); - } - for i in 0..b.len() { - for x in [0x5a, 0xff, 0x01] { - let mut c = b.clone(); - c[i] ^= x; - let _ = parse(&c); - } - } - // Seeded random noise after a valid header. - let mut seed = 0x1234_5678u32; - for _ in 0..500 { - let mut c = b"\0\x028BSL\0\x03".to_vec(); - for _ in 0..(seed % 200) { - seed ^= seed << 13; - seed ^= seed >> 17; - seed ^= seed << 5; - c.push(seed as u8); - } - let _ = parse(&c); - } - } -} diff --git a/crates/psd/src/atn.rs b/crates/psd/src/atn.rs deleted file mode 100644 index 384070f286..0000000000 --- a/crates/psd/src/atn.rs +++ /dev/null @@ -1,860 +0,0 @@ -//! Photoshop Actions files (`.atn`): one action set, its actions and their steps. -//! -//! Implemented clean-room from Adobe's "Photoshop File Formats" specification (the *Actions* -//! and *Descriptor structure* sections) and observation of files Photoshop writes. No code was -//! copied. All numbers are big-endian; strings are the Unicode strings of descriptors (u32 -//! code-unit count + UTF-16BE, usually NUL-terminated) unless noted. -//! -//! ```text -//! file := version u32 (12 or 16), set name UnicodeString, expanded u8, count u32, count × action -//! action := function key u16 (0 = none, n = Fn), shift u8, command u8, colour u16, -//! name UnicodeString, expanded u8, count u32, count × item -//! item := expanded u8, enabled u8, with dialog u8, dialog options u8, -//! event kind [4]: "TEXT" (u32 length + ASCII event name) | "long" (4-char event code), -//! dictionary name (u32 length + ASCII: the name the Actions panel shows), -//! has descriptor i32 (-1 = a descriptor follows, 0 = none), [descriptor] -//! ``` -//! -//! The descriptor is a plain ActionDescriptor body ([`Descriptor`], no version prefix): the -//! event's parameters, exactly as Photoshop's own "Make", "Set", "Gaussian Blur"… events -//! carry them. The event name and the panel name are byte strings (ASCII, or the platform's -//! legacy encoding for a localised panel name: Mac Roman "Définir"), kept verbatim; -//! [`AtnItem::label`] gives a readable name. Photoshop 5-era files (version 12) use a -//! legacy descriptor layout: bare four-byte identifiers and no top-level class or name. -//! -//! Which legacy encoding is not recorded: Windows Photoshop uses the system code page (GBK in -//! Chinese, Shift_JIS in Japanese…), Mac Photoshop the Mac one. [`parse`] works it out once per -//! file into [`AtnFile::encoding`], first from steps that also store their name as Unicode (the -//! descriptor's class name), else by guessing from the bytes; [`parse_with_encoding`] takes it -//! from the caller. [`AtnFile::item_label`] reads names with it. The bytes themselves stay as -//! they are. -//! -//! [`parse`] never panics: every count is bounds-checked against the bytes left, descriptor -//! nesting is bounded, and allocations are capped. [`write`] always produces version 16, so -//! `parse(write(f)) == f` for files [`parse`] read, and `write(parse(b)) == b` for version 16 files -//! Photoshop wrote; a version 12 file's legacy descriptors keep their meaning, not their layout. - -use crate::codepage; -use crate::descriptor::{Descriptor, UnicodeString}; -use crate::error::{PsdError, Result}; -use crate::io::{Reader, WriteExt, decode_legacy_name}; - -mod legacy; - -/// The version written by this module (Photoshop 6 and every later release). -pub const VERSION: u32 = 16; -/// Most actions read from one set (Photoshop's own sets hold a few dozen). -pub const MAX_ACTIONS: usize = 10_000; -/// Most steps read from one action. -pub const MAX_ITEMS: usize = 100_000; - -/// A parsed action set. -#[derive(Debug, Clone, PartialEq, Default)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct AtnFile { - /// The set's name (the Actions panel folder). - pub name: UnicodeString, - /// Whether the set is shown expanded in the Actions panel. - pub expanded: bool, - /// The actions, in panel order. - pub actions: Vec, - /// Problems that did not stop the parse (trailing bytes…). - #[cfg_attr(feature = "serde", serde(default))] - pub warnings: Vec, - /// The encoding of the 8-bit names that are not ASCII or UTF-8 ("GBK", "Shift_JIS", - /// "macintosh"…): the one given to [`parse_with_encoding`], else the one that turns such - /// names into the Unicode class names stored with them, else a guess from the names shown - /// from their bytes alone. `None` when there are no such names, or nothing tells and no - /// name depends on it. [`write`] ignores it: names are written as stored. - #[cfg_attr(feature = "serde", serde(default))] - pub encoding: Option, -} - -impl AtnFile { - /// [`AtnItem::label`], with 8-bit names read in this file's [`encoding`](Self::encoding). - /// A name that is valid UTF-8 is read in that encoding only if it reads entirely as common - /// text there (GBK 去色 is also valid UTF-8, "ȥɫ"). - pub fn item_label(&self, item: &AtnItem) -> String { - let Some(enc) = self.encoding.as_deref().and_then(|label| codepage::resolve_label(label).ok()) else { return item.label() }; - let read = |bytes: &[u8]| match std::str::from_utf8(bytes) { - Ok(s) if s.is_ascii() || !codepage::reads_as_common(bytes, enc) => s.to_owned(), - _ => codepage::decode(bytes, enc), - }; - if let Some(class) = class_name(item) { - return class; - } - let dict = read(&item.dictionary); - if !dict.trim().is_empty() { - return dict; - } - match &item.event { - AtnEvent::Name(n) => read(n), - AtnEvent::Code(_) => item.event.id(), - } - } -} - -/// One action of a set. -#[derive(Debug, Clone, PartialEq, Default)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct AtnAction { - /// Function key that plays the action: 0 = none, `n` = F`n`. - pub function_key: u16, - /// Shift modifier of the function key. - pub shift: bool, - /// Command (Control on Windows) modifier of the function key. - pub command: bool, - /// Button Mode colour index (0 = none, then red, orange, yellow, green, blue, violet, gray). - pub color: u16, - /// The action's name. - pub name: UnicodeString, - /// Whether the action is shown expanded in the Actions panel. - pub expanded: bool, - /// The steps, in playback order. - pub items: Vec, -} - -/// One step of an action: a Photoshop event with its parameters. -#[derive(Debug, Clone, PartialEq)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct AtnItem { - /// Whether the step's parameters are shown expanded in the Actions panel. - pub expanded: bool, - /// The panel's check mark: a switched-off step is skipped by playback. - pub enabled: bool, - /// The panel's dialog toggle: playback shows the step's dialog. - pub with_dialog: bool, - /// Dialog options (0 = none, 2 = show the dialog, 3 = don't show), kept verbatim. - pub dialog_options: u8, - /// The event. - pub event: AtnEvent, - /// The name the Actions panel shows for the step ("Gaussian Blur", "Make", …), as stored: - /// ASCII, or the authoring platform's legacy encoding when localised. See [`Self::label`]. - pub dictionary: Vec, - /// The event's parameters; `None` for events without any. - pub descriptor: Option, -} - -impl Default for AtnItem { - fn default() -> Self { - AtnItem { - expanded: false, - enabled: true, - with_dialog: false, - dialog_options: 0, - event: AtnEvent::Code(*b" "), - dictionary: Vec::new(), - descriptor: None, - } - } -} - -impl AtnItem { - /// The panel name as text: UTF-8 when it is, otherwise decoded byte by byte (Latin-1). - pub fn dictionary(&self) -> String { - decode_legacy_name(&self.dictionary) - } - - /// A readable name for the step: the descriptor's class name when Photoshop stored one - /// (Unicode, so a localised name survives), else [`Self::dictionary`], else the event id. - pub fn label(&self) -> String { - if let Some(class) = class_name(self) { - return class; - } - let dict = self.dictionary(); - if dict.trim().is_empty() { self.event.id() } else { dict } - } -} - -/// The descriptor's class name, when Photoshop stored one: the step's name as Unicode. -fn class_name(item: &AtnItem) -> Option { - let class = item.descriptor.as_ref().map(|d| d.name.to_string_lossy())?; - (!class.trim().is_empty()).then_some(class) -} - -/// A Photoshop event id: a four-character code (`GsnB`) or a string name (`trim`). -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub enum AtnEvent { - /// Stored as `long` + 4 bytes. - Code([u8; 4]), - /// Stored as `TEXT` + length-prefixed bytes (ASCII in every file seen). - Name(Vec), -} - -impl AtnEvent { - /// A string-named event. - pub fn name(s: &str) -> Self { - AtnEvent::Name(s.as_bytes().to_vec()) - } - /// The event id as text: the code's four characters, or the name. - pub fn id(&self) -> String { - match self { - AtnEvent::Code(c) => String::from_utf8_lossy(c).into_owned(), - AtnEvent::Name(n) => String::from_utf8_lossy(n).into_owned(), - } - } - /// `true` if the event is the code or name `s`. - pub fn is(&self, s: &str) -> bool { - match self { - AtnEvent::Code(c) => c == s.as_bytes(), - AtnEvent::Name(n) => n == s.as_bytes(), - } - } -} - -/// Reads a u32 length + bytes (event and dictionary names), verbatim. -fn read_ascii(r: &mut Reader<'_>) -> Result> { - let n = r.u32()?; - Ok(r.bytes_u64(u64::from(n))?.to_vec()) -} - -fn write_ascii(out: &mut Vec, s: &[u8]) -> Result<()> { - let n = u32::try_from(s.len()).map_err(|_| PsdError::LimitExceeded("action name too long"))?; - out.put_u32(n); - out.put(s); - Ok(()) -} - -/// Read one step with the descriptor dialect selected by the file version. -fn read_item(r: &mut Reader<'_>, legacy: bool) -> Result { - let expanded = r.u8()? != 0; - let enabled = r.u8()? != 0; - let with_dialog = r.u8()? != 0; - let dialog_options = r.u8()?; - let event = match &r.array::<4>()? { - b"TEXT" => AtnEvent::Name(read_ascii(r)?), - b"long" => AtnEvent::Code(r.array()?), - other => return Err(PsdError::invalid(format!("unknown event kind {:?}", String::from_utf8_lossy(other)))), - }; - let dictionary = read_ascii(r)?; - let descriptor = match r.i32()? { - 0 => None, - -1 => Some(if legacy { legacy::read(r)? } else { Descriptor::read(r)? }), - n => return Err(PsdError::invalid(format!("bad descriptor flag {n}"))), - }; - Ok(AtnItem { expanded, enabled, with_dialog, dialog_options, event, dictionary, descriptor }) -} - -/// Read an action and its steps using the file's descriptor dialect. -fn read_action(r: &mut Reader<'_>, legacy: bool) -> Result { - let function_key = r.u16()?; - let shift = r.u8()? != 0; - let command = r.u8()? != 0; - let color = r.u16()?; - let name = UnicodeString::read(r)?; - let expanded = r.u8()? != 0; - let n = r.u32()?; - // Each item needs at least its 4 flags, the event kind, two empty names and the descriptor flag. - r.check_count(u64::from(n), 20)?; - if n as usize > MAX_ITEMS { - return Err(PsdError::LimitExceeded("too many steps in one action")); - } - let mut items = Vec::with_capacity((n as usize).min(1024)); - for _ in 0..n { - items.push(read_item(r, legacy)?); - } - Ok(AtnAction { function_key, shift, command, color, name, expanded, items }) -} - -/// Parses a version 12 or 16 `.atn` file, working out the encoding of its 8-bit names (see the -/// module docs). -pub fn parse(data: &[u8]) -> Result { - parse_with_encoding(data, None) -} - -/// [`parse`], reading 8-bit names in `encoding` (a label such as `shift_jis`, `gbk`, `big5`, -/// `euc-kr`, `windows-1252`, `macintosh`) instead of working it out. An unknown label, or one -/// for an encoding that cannot hold an 8-bit name (UTF-16, `replacement`), is an error. -pub fn parse_with_encoding(data: &[u8], encoding: Option<&str>) -> Result { - let forced = encoding.map(codepage::resolve_label).transpose()?; - let mut f = parse_layout(data)?; - let mut warnings = Vec::new(); - let enc = forced.or_else(|| name_encoding(&f, &mut warnings)); - f.warnings.extend(warnings); - f.encoding = enc.map(|e| e.name().to_owned()); - Ok(f) -} - -fn parse_layout(data: &[u8]) -> Result { - let mut r = Reader::new(data); - let version = r.u32()?; - if !matches!(version, 12 | VERSION) { - return Err(PsdError::Unsupported(format!("actions file version {version} (supported versions: 12 and 16)"))); - } - let name = UnicodeString::read(&mut r)?; - let expanded = r.u8()? != 0; - let n = r.u32()?; - // Each action needs at least its key, modifiers, colour, an empty name, the flag and a count. - r.check_count(u64::from(n), 15)?; - if n as usize > MAX_ACTIONS { - return Err(PsdError::LimitExceeded("too many actions in one set")); - } - let mut actions = Vec::with_capacity((n as usize).min(256)); - for _ in 0..n { - actions.push(read_action(&mut r, version == 12)?); - } - let mut warnings = Vec::new(); - if !r.is_empty() { - warnings.push(format!("{} trailing bytes ignored", r.remaining())); - } - Ok(AtnFile { name, expanded, actions, warnings, encoding: None }) -} - -/// The encoding of `f`'s 8-bit names that are not ASCII or UTF-8, or `None` if no name shown -/// from them needs one; adds a warning when the guess is uncertain. -fn name_encoding(f: &AtnFile, warnings: &mut Vec) -> Option<&'static encoding_rs::Encoding> { - let items = || f.actions.iter().flat_map(|a| &a.items); - let legacy = |b: &[u8]| !b.is_ascii(); - // Steps that store their name as Unicode too: the encoding that turns one into the other. - let pairs: Vec<(&[u8], String)> = items() - .filter(|i| legacy(&i.dictionary)) - .filter_map(|i| class_name(i).filter(|c| std::str::from_utf8(&i.dictionary) != Ok(c.as_str())).map(|c| (i.dictionary.as_slice(), c))) - .take(codepage::KNOWN_PAIRS) - .collect(); - let pairs: Vec<(&[u8], &str)> = pairs.iter().map(|(b, c)| (*b, c.as_str())).collect(); - if let Some(enc) = codepage::match_known(&pairs) { - return Some(enc); - } - // Otherwise guess from the names shown from bytes alone. - let invalid = |b: &[u8]| std::str::from_utf8(b).is_err(); - let mut orphans: Vec<&[u8]> = Vec::new(); - for i in items() { - if class_name(i).is_none() && invalid(&i.dictionary) { - orphans.push(&i.dictionary); - } - if let AtnEvent::Name(n) = &i.event - && invalid(n) - { - orphans.push(n); - } - } - if orphans.is_empty() { - return None; - } - // The set, action and class names are UTF-16: their script points at the writer's locale. - let mut hint = f.name.to_string_lossy(); - for name in f.actions.iter().map(|a| a.name.to_string_lossy()).chain(items().filter_map(class_name)) { - if hint.len() > codepage::GUESS_SAMPLE { - break; - } - hint.push('\n'); - hint.push_str(&name); - } - let guess = codepage::guess(&orphans, &hint); - if guess.uncertain { - warnings.push(format!( - "step names aren't UTF-8 and their encoding couldn't be determined; read as {}; load with an explicit encoding to choose another", - guess.encoding.name() - )); - } - Some(guess.encoding) -} - -fn write_item(out: &mut Vec, item: &AtnItem) -> Result<()> { - out.put_u8(u8::from(item.expanded)); - out.put_u8(u8::from(item.enabled)); - out.put_u8(u8::from(item.with_dialog)); - out.put_u8(item.dialog_options); - match &item.event { - AtnEvent::Name(n) => { - out.put(b"TEXT"); - write_ascii(out, n)?; - } - AtnEvent::Code(c) => { - out.put(b"long"); - out.put(c); - } - } - write_ascii(out, &item.dictionary)?; - match &item.descriptor { - None => out.put_i32(0), - Some(d) => { - out.put_i32(-1); - d.write(out); - } - } - Ok(()) -} - -fn count(n: usize, what: &'static str) -> Result { - u32::try_from(n).map_err(|_| PsdError::LimitExceeded(what)) -} - -/// Writes an `.atn` file (version 16). -pub fn write(f: &AtnFile) -> Result> { - let mut out = Vec::new(); - out.put_u32(VERSION); - f.name.write(&mut out); - out.put_u8(u8::from(f.expanded)); - out.put_u32(count(f.actions.len(), "too many actions")?); - for a in &f.actions { - out.put_u16(a.function_key); - out.put_u8(u8::from(a.shift)); - out.put_u8(u8::from(a.command)); - out.put_u16(a.color); - a.name.write(&mut out); - out.put_u8(u8::from(a.expanded)); - out.put_u32(count(a.items.len(), "too many steps")?); - for item in &a.items { - write_item(&mut out, item)?; - } - } - Ok(out) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::descriptor::Value; - - fn px(v: f64) -> Value { - Value::UnitFloat { unit: *b"#Pxl", value: v } - } - - fn set() -> AtnFile { - let blur = AtnItem { - expanded: true, - enabled: true, - with_dialog: true, - dialog_options: 2, - event: AtnEvent::Code(*b"GsnB"), - dictionary: b"Gaussian Blur".to_vec(), - descriptor: Some(Descriptor::new("null").with("Rds ", px(2.5))), - }; - let trim = AtnItem { - event: AtnEvent::name("trim"), - dictionary: b"Trim".to_vec(), - descriptor: Some( - Descriptor { name: UnicodeString::new_nul("Trim"), class_id: crate::descriptor::Id::Str(b"trim".to_vec()), items: Vec::new() } - .with( - "trimBasedOn", - Value::Enumerated { type_id: crate::descriptor::Id::new("trimBasedOn"), value: crate::descriptor::Id::new("topLeftPixelColor") }, - ) - .with("Top ", Value::Boolean(true)), - ), - ..Default::default() - }; - let flatten = AtnItem { enabled: false, event: AtnEvent::Code(*b"Fltn"), dictionary: b"Flatten Image".to_vec(), ..Default::default() }; - AtnFile { - name: UnicodeString::new_nul("Set ✓"), - expanded: true, - actions: vec![ - AtnAction { - function_key: 2, - shift: true, - command: false, - color: 3, - name: UnicodeString::new_nul("Soften"), - expanded: true, - items: vec![blur, flatten], - }, - AtnAction { name: UnicodeString::new_nul("Tidy"), items: vec![trim], ..Default::default() }, - AtnAction { name: UnicodeString::new_nul("Empty"), ..Default::default() }, - ], - warnings: Vec::new(), - ..Default::default() - } - } - - #[test] - fn round_trip() { - let f = set(); - let bytes = write(&f).unwrap(); - let parsed = parse(&bytes).unwrap(); - assert_eq!(parsed, f); - assert_eq!(write(&parsed).unwrap(), bytes, "writing is stable"); - assert_eq!(parsed.actions[0].items[0].event.id(), "GsnB"); - assert!(parsed.actions[1].items[0].event.is("trim")); - assert_eq!(parsed.actions[1].items[0].event.id(), "trim"); - assert_eq!(parsed.actions[0].items[0].label(), "Gaussian Blur"); - assert_eq!(parsed.actions[1].items[0].label(), "Trim"); - // A localised panel name in the platform's legacy encoding survives a round trip and - // the Unicode class name is the readable label. - let localised = AtnItem { - event: AtnEvent::name("set"), - dictionary: b"D\x8efinir".to_vec(), - descriptor: Some(Descriptor { name: UnicodeString::new_nul("Définir"), class_id: crate::descriptor::Id::new("setd"), items: Vec::new() }), - ..Default::default() - }; - assert_eq!(localised.label(), "Définir"); - let f = AtnFile { actions: vec![AtnAction { items: vec![localised.clone()], ..Default::default() }], ..Default::default() }; - let bytes = write(&f).unwrap(); - assert_eq!(parse(&bytes).unwrap().actions[0].items[0], localised); - assert_eq!(AtnItem { event: AtnEvent::name("x"), ..Default::default() }.label(), "x"); - } - - /// The layout Photoshop writes for a one-step Trim action, assembled by hand from the - /// format description: field by field, so a mistake names the field. - #[test] - fn layout_matches_the_specification() { - let f = AtnFile { - name: UnicodeString::new_nul("Trim"), - expanded: false, - actions: vec![AtnAction { - name: UnicodeString::new_nul("Action 1"), - expanded: true, - items: vec![AtnItem { - expanded: true, - event: AtnEvent::name("trim"), - dictionary: b"Trim".to_vec(), - descriptor: Some(Descriptor { - name: UnicodeString::new_nul("Trim"), - class_id: crate::descriptor::Id::Str(b"trim".to_vec()), - items: Vec::new(), - }), - ..Default::default() - }], - ..Default::default() - }], - warnings: Vec::new(), - ..Default::default() - }; - let mut expect = Vec::new(); - expect.extend_from_slice(&[0, 0, 0, 16]); // version - expect.extend_from_slice(&[0, 0, 0, 5, 0, b'T', 0, b'r', 0, b'i', 0, b'm', 0, 0]); // set name - expect.push(0); // set collapsed - expect.extend_from_slice(&[0, 0, 0, 1]); // one action - expect.extend_from_slice(&[0, 0, 0, 0, 0, 0]); // no function key, no modifiers, no colour - expect.extend_from_slice(&[0, 0, 0, 9]); - for c in "Action 1\0".encode_utf16() { - expect.extend_from_slice(&c.to_be_bytes()); - } - expect.push(1); // action expanded - expect.extend_from_slice(&[0, 0, 0, 1]); // one step - expect.extend_from_slice(&[1, 1, 0, 0]); // expanded, enabled, no dialog, options 0 - expect.extend_from_slice(b"TEXT\0\0\0\x04trim"); - expect.extend_from_slice(b"\0\0\0\x04Trim"); - expect.extend_from_slice(&[0xff; 4]); // a descriptor follows - expect.extend_from_slice(&[0, 0, 0, 5, 0, b'T', 0, b'r', 0, b'i', 0, b'm', 0, 0]); // class name - expect.extend_from_slice(b"\0\0\0\x04trim"); // class id, length-4 string form - expect.extend_from_slice(&[0, 0, 0, 0]); // no items - assert_eq!(write(&f).unwrap(), expect); - assert_eq!(parse(&expect).unwrap(), f); - } - - #[test] - fn truncation_never_parses_and_never_panics() { - let bytes = write(&set()).unwrap(); - for cut in 0..bytes.len() { - assert!(parse(&bytes[..cut]).is_err(), "cut at {cut}/{}", bytes.len()); - } - let mut padded = bytes.clone(); - padded.extend_from_slice(b"junk"); - let f = parse(&padded).unwrap(); - assert_eq!(f.warnings, ["4 trailing bytes ignored"]); - assert_eq!(f.actions, set().actions); - } - - #[test] - fn rejects_other_versions_and_bad_structure() { - let mut bytes = write(&set()).unwrap(); - bytes[3] = 7; - assert!(matches!(parse(&bytes), Err(PsdError::Unsupported(_)))); - // A count far beyond the data must fail before allocating. - let mut huge = vec![0, 0, 0, 16, 0, 0, 0, 0, 0]; - huge.extend_from_slice(&u32::MAX.to_be_bytes()); - assert!(matches!(parse(&huge), Err(PsdError::UnexpectedEof { .. }))); - // Unknown event kinds and descriptor flags are reported, not guessed. - let mut bad_kind = write(&set()).unwrap(); - let at = bad_kind.windows(4).position(|w| w == b"long").unwrap(); - bad_kind[at..at + 4].copy_from_slice(b"what"); - assert!(matches!(parse(&bad_kind), Err(PsdError::Invalid(_)))); - let mut bad_flag = write(&set()).unwrap(); - let at = bad_flag.windows(4).position(|w| w == [0xff; 4]).unwrap(); - bad_flag[at..at + 4].copy_from_slice(&[0, 0, 0, 7]); - assert!(matches!(parse(&bad_flag), Err(PsdError::Invalid(_)))); - } - - const GBK_SELECTIVE_COLOR: &[u8] = b"\xbf\xc9\xd1\xa1\xd1\xd5\xc9\xab"; // 可选颜色 (from #6743) - const GBK_DESATURATE: &[u8] = b"\xc8\xa5\xc9\xab"; // 去色: valid UTF-8 by accident ("ȥɫ") - const LABELS: [Option<&str>; 5] = [None, Some("shift_jis"), Some("gbk"), Some("utf-8"), Some("macintosh")]; - - /// A step stored with no descriptor, so nothing but its 8-bit names tells what it is called. - fn bare(event: AtnEvent, dictionary: &[u8]) -> AtnItem { - AtnItem { event, dictionary: dictionary.to_vec(), ..Default::default() } - } - - /// A step whose descriptor also stores its panel name as Unicode, as Photoshop does for most. - fn known(dictionary: &[u8], class: &str) -> AtnItem { - let descriptor = Descriptor { name: UnicodeString::new_nul(class), class_id: crate::descriptor::Id::new("setd"), items: Vec::new() }; - AtnItem { event: AtnEvent::name("set"), dictionary: dictionary.to_vec(), descriptor: Some(descriptor), ..Default::default() } - } - - fn one_action(set: &str, action: &str, items: Vec) -> Vec { - let f = AtnFile { - name: UnicodeString::new_nul(set), - actions: vec![AtnAction { name: UnicodeString::new_nul(action), items, ..Default::default() }], - ..Default::default() - }; - write(&f).unwrap() - } - - /// The issue's set: Chinese Photoshop's Selective Color step, named in GBK. - fn gbk_set() -> Vec { - one_action("相馆", "调白", vec![bare(AtnEvent::name("syntheticFill"), GBK_SELECTIVE_COLOR)]) - } - - fn labels(f: &AtnFile) -> Vec { - f.actions.iter().flat_map(|a| a.items.iter().map(|i| f.item_label(i))).collect() - } - - #[test] - fn gbk_step_names_read_as_gbk() { - let f = parse(&gbk_set()).unwrap(); - assert_eq!(f.encoding.as_deref(), Some("GBK")); - assert_eq!(labels(&f), ["可选颜色"]); - assert!(f.warnings.is_empty(), "{:?}", f.warnings); - // The item itself, without the file's code page, reads as before. - assert_ne!(f.actions[0].items[0].label(), "可选颜色"); - } - - #[test] - fn legacy_names_are_kept_byte_for_byte() { - let bytes = gbk_set(); - let f = parse(&bytes).unwrap(); - assert_eq!(f.actions[0].items[0].dictionary, GBK_SELECTIVE_COLOR); - assert_eq!(write(&f).unwrap(), bytes); - assert_eq!(parse(&write(&f).unwrap()).unwrap(), f); - } - - #[test] - fn text_event_names_decode_too() { - let f = parse(&one_action("相馆", "调白", vec![bare(AtnEvent::Name(GBK_SELECTIVE_COLOR.to_vec()), b"")])).unwrap(); - assert_eq!(f.encoding.as_deref(), Some("GBK")); - assert_eq!(labels(&f), ["可选颜色"]); - } - - #[test] - fn known_names_decide_the_code_page_of_the_rest() { - // French Photoshop on a Mac: "Définir" is stored as Mac Roman and as Unicode; the bare - // step's "Répéter" is only Mac Roman, which also reads as windows-1252 ("RŽpŽter"). - let items = vec![known(b"D\x8efinir", "Définir"), bare(AtnEvent::name("x"), b"R\x8ep\x8eter")]; - let f = parse(&one_action("Set 1", "Action 1", items)).unwrap(); - assert_eq!(f.encoding.as_deref(), Some("macintosh")); - assert_eq!(labels(&f), ["Définir", "Répéter"]); - assert!(f.warnings.is_empty(), "{:?}", f.warnings); - } - - #[test] - fn class_names_hint_the_script() { - // GBK 保存 reads as common text in GBK, Big5 and EUC-KR alike; only the Chinese class - // name of another step (whose panel name is ASCII, so it settles nothing itself) says GBK. - let items = vec![known(b"Adjust", "调整"), bare(AtnEvent::name("x"), b"\xb1\xa3\xb4\xe6")]; - let f = parse(&one_action("Set 1", "Action 1", items)).unwrap(); - assert_eq!(f.encoding.as_deref(), Some("GBK")); - assert_eq!(labels(&f), ["调整", "保存"]); - assert!(f.warnings.is_empty(), "{:?}", f.warnings); - } - - #[test] - fn latin1_symbol_names_read_as_windows_1252() { - // Windows Photoshop "Größe »" and "50 °" with no Unicode copy: Mac Roman reads these - // bytes as letters and symbols ("Grˆfle ª", "50 ∞"), but they are windows-1252, as before - // #6743. - let f = parse(&one_action("Größe ändern", "Aktion", vec![bare(AtnEvent::name("x"), b"Gr\xf6\xdfe \xbb")])).unwrap(); - assert_eq!(f.encoding.as_deref(), Some("windows-1252")); - assert_eq!(labels(&f), ["Größe »"]); - assert!(f.warnings.is_empty(), "{:?}", f.warnings); - let f = parse(&one_action("Größe ändern", "Aktion", vec![bare(AtnEvent::name("x"), b"50 \xb0")])).unwrap(); - assert_eq!(labels(&f), ["50 °"]); - // Known limitation: "Logo ®" alone reads as Mac Roman "Logo Æ", as Mac "Æble" needs that - // byte (codepage's symbols_on_mac_roman_letters_stay_ambiguous). - let f = parse(&one_action("Set 1", "Action 1", vec![bare(AtnEvent::name("x"), b"Logo \xae")])).unwrap(); - assert_eq!(f.encoding.as_deref(), Some("macintosh")); - assert_eq!(labels(&f), ["Logo Æ"]); - // Mac Roman bytes that windows-1252 reads as controls or rare letters stay Mac Roman. - let f = - parse(&one_action("Définir", "Action 1", vec![bare(AtnEvent::name("x"), b"Gr\x9a\xa7e"), bare(AtnEvent::name("y"), b"Arri\x8fre-plan")])).unwrap(); - assert_eq!(f.encoding.as_deref(), Some("macintosh")); - assert_eq!(labels(&f), ["Größe", "Arrière-plan"]); - } - - #[test] - fn mac_roman_sharp_s_and_slashed_o_stay_mac_roman() { - // Mac Roman ß ø æ Æ are the bytes of windows-1252 § ¿ ¾ ®: German and Danish Mac names - // with no Unicode copy still read as Mac Roman, each alone and as a set. - let words: [&[u8]; 5] = [b"Wei\xa7", b"Gau\xa7scher Weichzeichner", b"St\xbfrrelse", b"L\xberredst\xbfrrelse", b"\xaeble"]; - let text = ["Weiß", "Gaußscher Weichzeichner", "Størrelse", "Lærredstørrelse", "Æble"]; - for (bytes, text) in words.iter().zip(text) { - let f = parse(&one_action("Größe ändern", "Aktion 1", vec![bare(AtnEvent::name("x"), bytes)])).unwrap(); - assert_eq!((f.encoding.as_deref(), labels(&f)), (Some("macintosh"), vec![text.to_string()])); - assert!(f.warnings.is_empty(), "{:?}", f.warnings); - } - let items = words.iter().map(|w| bare(AtnEvent::name("x"), w)).collect(); - let f = parse(&one_action("Größe ändern", "Aktion 1", items)).unwrap(); - assert_eq!(f.encoding.as_deref(), Some("macintosh")); - assert_eq!(labels(&f), text); - } - - #[test] - fn mac_roman_bullets_stay_mac_roman() { - // Mac Roman • (0xA5) is windows-1252 ¥, which must not count for windows-1252. - for (set, bytes, text) in [ - ("Größe ändern", &b"Ebene \xa5 Kopie"[..], "Ebene • Kopie"), - ("Définir", b"Niveaux \xa5 Courbes", "Niveaux • Courbes"), - ("Tamaño de imagen", b"Tama\x96o \xa5 50%", "Tamaño • 50%"), - ] { - let f = parse(&one_action(set, "Aktion 1", vec![bare(AtnEvent::name("x"), bytes)])).unwrap(); - assert_eq!((f.encoding.as_deref(), labels(&f)), (Some("macintosh"), vec![text.to_string()])); - } - } - - #[test] - fn russian_sets_with_latin_compounds_stay_windows_1251() { - // Ordinary windows-1251 names plus one glued to a Latin word ("CMYKканал"): the whole - // set reads as Cyrillic, with no warning. - let words: [&[u8]; 5] = [ - b"\xcd\xee\xe2\xfb\xe9 \xf1\xeb\xee\xe9", - b"\xd0\xe0\xe7\xec\xe5\xf0 \xe8\xe7\xee\xe1\xf0\xe0\xe6\xe5\xed\xe8\xff", - b"\xca\xee\xef\xe8\xff \xf1\xeb\xee\xff 2", - b"\xca\xe0\xed\xe0\xeb RGB", - b"CMYK\xea\xe0\xed\xe0\xeb", - ]; - let text = ["Новый слой", "Размер изображения", "Копия слоя 2", "Канал RGB", "CMYKканал"]; - let items = words.iter().map(|w| bare(AtnEvent::name("x"), w)).collect(); - let f = parse(&one_action("Набор 1", "Операция 1", items)).unwrap(); - assert_eq!(f.encoding.as_deref(), Some("windows-1251")); - assert_eq!(labels(&f), text); - assert!(f.warnings.is_empty(), "{:?}", f.warnings); - } - - #[test] - fn known_names_alone_never_warn() { - // Nothing decodes to the Unicode name, but nothing is shown from the bytes either. - let f = parse(&one_action("Set 1", "Action 1", vec![known(b"\x80\xff\x80", "Something")])).unwrap(); - assert_eq!(f.encoding, None); - assert!(f.warnings.is_empty(), "{:?}", f.warnings); - assert_eq!(labels(&f), ["Something"]); - } - - #[test] - fn uncertain_guess_warns() { - let f = parse(&one_action("Set 1", "Action 1", vec![bare(AtnEvent::name("x"), b"\x80\xff\x80")])).unwrap(); - assert_eq!(f.encoding.as_deref(), Some("windows-1251")); - assert_eq!(f.warnings.len(), 1, "{:?}", f.warnings); - assert!(f.warnings[0].contains("windows-1251"), "{:?}", f.warnings); - assert_eq!(labels(&f), ["ЂяЂ"]); - } - - #[test] - fn unresolved_codepage_tie_keeps_a_top_candidate_and_the_original_bytes() { - // GBK 保存 ties with other CJK pages. No Unicode name settles it, but windows-1252 - // scored lower, so its "±£´æ" must not replace the tied candidates' readable text. - let bytes = one_action("Set 1", "Action 1", vec![bare(AtnEvent::name("x"), b"\xb1\xa3\xb4\xe6")]); - let f = parse(&bytes).unwrap(); - assert_eq!(f.encoding.as_deref(), Some("GBK")); - assert_eq!(labels(&f), ["保存"]); - assert_eq!(f.warnings.len(), 1, "{:?}", f.warnings); - assert!(f.warnings[0].contains("read as GBK"), "{:?}", f.warnings); - assert_eq!(write(&f).unwrap(), bytes); - // Callers can still resolve an ambiguous guess explicitly. - let forced = parse_with_encoding(&bytes, Some("windows-1252")).unwrap(); - assert_eq!(labels(&forced), ["±£´æ"]); - assert!(forced.warnings.is_empty(), "{:?}", forced.warnings); - assert_eq!(write(&forced).unwrap(), bytes); - } - - #[test] - fn utf8_files_have_no_encoding() { - let f = parse(&write(&set()).unwrap()).unwrap(); - assert_eq!(f.encoding, None); - let items: Vec<&AtnItem> = f.actions.iter().flat_map(|a| &a.items).collect(); - assert_eq!(labels(&f), items.iter().map(|i| i.label()).collect::>()); - let f = parse(&one_action("Set", "A", vec![bare(AtnEvent::name("x"), "高斯模糊 ✓".as_bytes())])).unwrap(); - assert_eq!((f.encoding.clone(), labels(&f)), (None, vec!["高斯模糊 ✓".to_string()])); - } - - #[test] - fn utf8_lookalike_names_decode_with_the_file() { - let items = vec![ - bare(AtnEvent::name("x"), GBK_SELECTIVE_COLOR), - bare(AtnEvent::name("y"), GBK_DESATURATE), - bare(AtnEvent::name("z"), "Gaussian Blur ✓".as_bytes()), - ]; - let f = parse(&one_action("相馆", "调白", items)).unwrap(); - assert_eq!(f.encoding.as_deref(), Some("GBK")); - assert_eq!(labels(&f), ["可选颜色", "去色", "Gaussian Blur ✓"]); - } - - #[test] - fn utf8_names_stay_in_single_byte_files() { - // windows-1252 "Größe" beside a UTF-8 "Weiß"; windows-1251 "Уровни" beside a UTF-8 "Привет". - let latin = vec![bare(AtnEvent::name("x"), b"Gr\xf6\xdfe"), bare(AtnEvent::name("y"), "Weiß".as_bytes())]; - let f = parse(&one_action("Größe ändern", "Aktion", latin)).unwrap(); - assert_eq!(f.encoding.as_deref(), Some("windows-1252")); - assert_eq!(labels(&f), ["Größe", "Weiß"]); - let cyrillic = vec![bare(AtnEvent::name("x"), b"\xd3\xf0\xee\xe2\xed\xe8"), bare(AtnEvent::name("y"), "Привет".as_bytes())]; - let f = parse(&one_action("Набор", "Операция", cyrillic)).unwrap(); - assert_eq!(f.encoding.as_deref(), Some("windows-1251")); - assert_eq!(labels(&f), ["Уровни", "Привет"]); - // A forced single-byte encoding leaves UTF-8 names alone too. - let f = parse_with_encoding(&one_action("S", "A", vec![bare(AtnEvent::name("y"), "Weiß".as_bytes())]), Some("windows-1252")).unwrap(); - assert_eq!(labels(&f), ["Weiß"]); - } - - #[test] - fn forced_encoding_wins() { - let f = parse_with_encoding(&gbk_set(), Some("big5")).unwrap(); - assert_eq!(f.encoding.as_deref(), Some("Big5")); - assert_ne!(labels(&f), ["可选颜色"]); - // A wrong override still loads, and saves the bytes it read. - let f = parse_with_encoding(&gbk_set(), Some("shift_jis")).unwrap(); - assert_eq!(f.encoding.as_deref(), Some("Shift_JIS")); - assert_eq!(write(&f).unwrap(), gbk_set()); - // Forcing applies to all-UTF-8 files too. - assert_eq!(parse_with_encoding(&write(&set()).unwrap(), Some("gbk")).unwrap().encoding.as_deref(), Some("GBK")); - } - - #[test] - fn bad_labels_are_errors() { - for bad in ["nope", "replacement", "utf-16be", ""] { - assert!(matches!(parse_with_encoding(&gbk_set(), Some(bad)), Err(PsdError::Unsupported(_))), "{bad}"); - } - } - - #[test] - fn legacy_files_never_panic() { - let items = vec![known(b"D\x8efinir", "Définir"), bare(AtnEvent::Name(GBK_SELECTIVE_COLOR.to_vec()), GBK_DESATURATE)]; - let seed = one_action("相馆", "调白", items); - let check = |bytes: &[u8]| { - for label in LABELS { - if let Ok(f) = parse_with_encoding(bytes, label) { - let _ = labels(&f); - } - } - }; - for cut in 0..seed.len() { - check(&seed[..cut]); - } - let mut x: u32 = 0x85eb_ca6b; - for i in 0..500 { - let mut bytes = seed.clone(); - for _ in 0..(i % 5 + 1) { - x ^= x << 13; - x ^= x >> 17; - x ^= x << 5; - let at = (x as usize) % bytes.len(); - bytes[at] = (x >> 8) as u8; - } - check(&bytes); - } - } - - #[test] - fn garbage_never_panics() { - let seed = write(&set()).unwrap(); - let mut x: u32 = 0x9e37_79b9; - for i in 0..2000 { - let mut bytes = seed.clone(); - for _ in 0..(i % 7 + 1) { - x ^= x << 13; - x ^= x >> 17; - x ^= x << 5; - let at = (x as usize) % bytes.len(); - bytes[at] = (x >> 8) as u8; - } - let _ = parse(&bytes); - } - } -} diff --git a/crates/psd/src/atn/legacy.rs b/crates/psd/src/atn/legacy.rs deleted file mode 100644 index c0f5053cbd..0000000000 --- a/crates/psd/src/atn/legacy.rs +++ /dev/null @@ -1,111 +0,0 @@ -//! Version 12 descriptors use bare codes and omit Unicode class names. - -use crate::descriptor::{Class, Descriptor, Id, MAX_DEPTH, ObjectArray, ReferenceItem, UnicodeString, Value, vec_for_count}; -use crate::error::{PsdError, Result}; -use crate::io::Reader; - -/// Read the unnamed, classless descriptor at the root of a legacy action step. -pub(super) fn read(r: &mut Reader<'_>) -> Result { - descriptor(r, Id::Code(*b"null"), 0) -} - -fn class(r: &mut Reader<'_>) -> Result { - Ok(Class { name: UnicodeString::default(), class_id: Id::Code(r.array()?) }) -} - -/// Read items after their enclosing class, with the same depth bound as modern descriptors. -fn descriptor(r: &mut Reader<'_>, class_id: Id, depth: usize) -> Result { - if depth > MAX_DEPTH { - return Err(PsdError::LimitExceeded("descriptor nesting too deep")); - } - let n = r.u32()?; - // A bare key, type and Boolean need nine bytes, without a modern ID length. - r.check_count(u64::from(n), 9)?; - let mut items = vec_for_count(n); - for _ in 0..n { - items.push((Id::Code(r.array()?), value(r, depth + 1)?)); - } - Ok(Descriptor { name: UnicodeString::default(), class_id, items }) -} - -/// Decode a legacy value without trying the modern dialect on malformed input. -fn value(r: &mut Reader<'_>, depth: usize) -> Result { - if depth > MAX_DEPTH { - return Err(PsdError::LimitExceeded("descriptor nesting too deep")); - } - Ok(match &r.array::<4>()? { - b"obj " => { - let n = r.u32()?; - r.check_count(u64::from(n), 8)?; - let mut items = vec_for_count(n); - for _ in 0..n { - items.push(reference(r)?); - } - Value::Reference(items) - } - ty @ (b"Objc" | b"GlbO") => { - let class_id = Id::Code(r.array()?); - let d = descriptor(r, class_id, depth + 1)?; - if ty == b"Objc" { Value::Descriptor(d) } else { Value::GlobalObject(d) } - } - b"VlLs" => { - let n = r.u32()?; - r.check_count(u64::from(n), 4)?; - let mut items = vec_for_count(n); - for _ in 0..n { - items.push(value(r, depth + 1)?); - } - Value::List(items) - } - b"doub" => Value::Double(r.f64()?), - b"UntF" => Value::UnitFloat { unit: r.array()?, value: r.f64()? }, - b"UnFl" => { - let unit = r.array()?; - let n = r.u32()?; - r.check_count(u64::from(n), 8)?; - let mut values = vec_for_count(n); - for _ in 0..n { - values.push(r.f64()?); - } - Value::UnitFloats { unit, values } - } - b"TEXT" => Value::Text(UnicodeString::read(r)?), - b"enum" => Value::Enumerated { type_id: Id::Code(r.array()?), value: Id::Code(r.array()?) }, - b"long" => Value::Integer(r.i32()?), - b"comp" => Value::LargeInteger(r.i64()?), - b"bool" => Value::Boolean(r.u8()? != 0), - b"type" => Value::Class(class(r)?), - b"GlbC" => Value::GlobalClass(class(r)?), - ty @ (b"alis" | b"Pth " | b"tdta") => { - let n = r.u32()?; - let bytes = r.bytes_u64(u64::from(n))?.to_vec(); - match ty { - b"alis" => Value::Alias(bytes), - b"Pth " => Value::Path(bytes), - _ => Value::RawData(bytes), - } - } - b"ObAr" => { - let class_id = Id::Code(r.array()?); - let prefix = r.u32()?; - Value::ObjectArray(ObjectArray { prefix, body: descriptor(r, class_id, depth + 1)? }) - } - other => return Err(PsdError::Unsupported(format!("legacy descriptor OSType {:?}", String::from_utf8_lossy(other)))), - }) -} - -/// Every legacy reference form carries a bare class, including index and identifier forms. -fn reference(r: &mut Reader<'_>) -> Result { - let ty = r.array::<4>()?; - let class = class(r)?; - Ok(match &ty { - b"prop" => ReferenceItem::Property { class, key: Id::Code(r.array()?) }, - b"Clss" => ReferenceItem::Class(class), - b"Enmr" => ReferenceItem::Enumerated { class, type_id: Id::Code(r.array()?), value: Id::Code(r.array()?) }, - b"rele" => ReferenceItem::Offset { class, offset: r.i32()? }, - b"Idnt" => ReferenceItem::Identifier { class, id: r.u32()? }, - b"indx" => ReferenceItem::Index { class, index: r.u32()? }, - b"name" => ReferenceItem::Name { class, name: UnicodeString::read(r)? }, - other => return Err(PsdError::Unsupported(format!("legacy reference item type {:?}", String::from_utf8_lossy(other)))), - }) -} diff --git a/crates/psd/src/blend.rs b/crates/psd/src/blend.rs deleted file mode 100644 index a968c10c8b..0000000000 --- a/crates/psd/src/blend.rs +++ /dev/null @@ -1,143 +0,0 @@ -//! Layer blend modes (the 4-character keys stored after the `8BIM` signature). - -/// Photoshop blend mode keys. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[allow(missing_docs)] -pub enum BlendMode { - PassThrough, - #[default] - Normal, - Dissolve, - Darken, - Multiply, - ColorBurn, - LinearBurn, - DarkerColor, - Lighten, - Screen, - ColorDodge, - LinearDodge, - LighterColor, - Overlay, - SoftLight, - HardLight, - VividLight, - LinearLight, - PinLight, - HardMix, - Difference, - Exclusion, - Subtract, - Divide, - Hue, - Saturation, - Color, - Luminosity, - /// A key not known to this crate, preserved verbatim. - Unknown([u8; 4]), -} - -impl BlendMode { - /// All known modes (27 layer modes plus pass-through for groups). - pub const ALL: [BlendMode; 28] = [ - BlendMode::PassThrough, - BlendMode::Normal, - BlendMode::Dissolve, - BlendMode::Darken, - BlendMode::Multiply, - BlendMode::ColorBurn, - BlendMode::LinearBurn, - BlendMode::DarkerColor, - BlendMode::Lighten, - BlendMode::Screen, - BlendMode::ColorDodge, - BlendMode::LinearDodge, - BlendMode::LighterColor, - BlendMode::Overlay, - BlendMode::SoftLight, - BlendMode::HardLight, - BlendMode::VividLight, - BlendMode::LinearLight, - BlendMode::PinLight, - BlendMode::HardMix, - BlendMode::Difference, - BlendMode::Exclusion, - BlendMode::Subtract, - BlendMode::Divide, - BlendMode::Hue, - BlendMode::Saturation, - BlendMode::Color, - BlendMode::Luminosity, - ]; - - /// The stored 4-byte key. - pub fn key(self) -> [u8; 4] { - *match self { - BlendMode::PassThrough => b"pass", - BlendMode::Normal => b"norm", - BlendMode::Dissolve => b"diss", - BlendMode::Darken => b"dark", - BlendMode::Multiply => b"mul ", - BlendMode::ColorBurn => b"idiv", - BlendMode::LinearBurn => b"lbrn", - BlendMode::DarkerColor => b"dkCl", - BlendMode::Lighten => b"lite", - BlendMode::Screen => b"scrn", - BlendMode::ColorDodge => b"div ", - BlendMode::LinearDodge => b"lddg", - BlendMode::LighterColor => b"lgCl", - BlendMode::Overlay => b"over", - BlendMode::SoftLight => b"sLit", - BlendMode::HardLight => b"hLit", - BlendMode::VividLight => b"vLit", - BlendMode::LinearLight => b"lLit", - BlendMode::PinLight => b"pLit", - BlendMode::HardMix => b"hMix", - BlendMode::Difference => b"diff", - BlendMode::Exclusion => b"smud", - BlendMode::Subtract => b"fsub", - BlendMode::Divide => b"fdiv", - BlendMode::Hue => b"hue ", - BlendMode::Saturation => b"sat ", - BlendMode::Color => b"colr", - BlendMode::Luminosity => b"lum ", - BlendMode::Unknown(ref k) => k, - } - } - - /// Parses a stored key; unknown keys map to [`BlendMode::Unknown`]. - pub fn from_key(key: [u8; 4]) -> Self { - BlendMode::ALL.iter().copied().find(|m| m.key() == key).unwrap_or(BlendMode::Unknown(key)) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn all_keys_roundtrip_and_unique() { - let mut keys = std::collections::HashSet::new(); - for m in BlendMode::ALL { - assert_eq!(BlendMode::from_key(m.key()), m); - assert!(keys.insert(m.key())); - } - assert_eq!(keys.len(), 28); - } - - #[test] - fn unknown_preserved() { - let m = BlendMode::from_key(*b"zzzz"); - assert_eq!(m, BlendMode::Unknown(*b"zzzz")); - assert_eq!(m.key(), *b"zzzz"); - } - - #[test] - fn specific_keys() { - assert_eq!(BlendMode::Multiply.key(), *b"mul "); - assert_eq!(BlendMode::from_key(*b"smud"), BlendMode::Exclusion); - assert_eq!(BlendMode::from_key(*b"idiv"), BlendMode::ColorBurn); - assert_eq!(BlendMode::default(), BlendMode::Normal); - } -} diff --git a/crates/psd/src/builder.rs b/crates/psd/src/builder.rs deleted file mode 100644 index 6606bdb351..0000000000 --- a/crates/psd/src/builder.rs +++ /dev/null @@ -1,471 +0,0 @@ -//! [`PsdBuilder`]: construct PSD/PSB files from pixel buffers. -//! -//! The builder never composites. Supply the merged image with -//! [`PsdBuilder::composite`]; otherwise a white placeholder is written and -//! resource 1057 records `has_real_merged_data = false`. - -use crate::blend::BlendMode; -use crate::compression::Compression; -use crate::error::{PsdError, Result}; -use crate::file::{GlobalLayerMask, LayerInfoPlacement, PsdFile}; -use crate::header::{ColorMode, Header, Version}; -use crate::image_data::ImageData; -use crate::io::encode_legacy_name; -use crate::layer::{BlendingRanges, ChannelData, LayerFlags, LayerInfo, LayerMask, LayerRecord, MaskData, Rect}; -use crate::resources::{ImageResource, ResolutionInfo, ids, version_info_resource}; -use crate::tagged::{SectionType, TaggedBlock}; - -/// Interleaved pixel data with alpha. CMYK values are *ink amounts* -/// (0 = no ink); the builder stores them inverted as Photoshop does. -#[derive(Debug, Clone, PartialEq)] -pub enum PixelData { - /// RGBA, 8-bit. - Rgba8(Vec), - /// RGBA, 16-bit. - Rgba16(Vec), - /// Gray + alpha, 8-bit. - GrayA8(Vec), - /// Gray + alpha, 16-bit. - GrayA16(Vec), - /// CMYK ink + alpha, 8-bit. - Cmyka8(Vec), - /// CMYK ink + alpha, 16-bit. - Cmyka16(Vec), -} - -impl PixelData { - fn mode_depth(&self) -> (ColorMode, u16) { - match self { - PixelData::Rgba8(_) => (ColorMode::Rgb, 8), - PixelData::Rgba16(_) => (ColorMode::Rgb, 16), - PixelData::GrayA8(_) => (ColorMode::Grayscale, 8), - PixelData::GrayA16(_) => (ColorMode::Grayscale, 16), - PixelData::Cmyka8(_) => (ColorMode::Cmyk, 8), - PixelData::Cmyka16(_) => (ColorMode::Cmyk, 16), - } - } - /// Samples per pixel including alpha. - fn spp(&self) -> usize { - match self.mode_depth().0 { - ColorMode::Rgb => 4, - ColorMode::Grayscale => 2, - _ => 5, - } - } - fn len(&self) -> usize { - match self { - PixelData::Rgba8(v) | PixelData::GrayA8(v) | PixelData::Cmyka8(v) => v.len(), - PixelData::Rgba16(v) | PixelData::GrayA16(v) | PixelData::Cmyka16(v) => v.len(), - } - } - /// Planar big-endian plane for interleaved component `c`, as stored - /// (CMYK inverted). - fn plane(&self, c: usize, n: usize) -> Vec { - let spp = self.spp(); - let invert = self.mode_depth().0 == ColorMode::Cmyk && c < 4; - match self { - PixelData::Rgba8(v) | PixelData::GrayA8(v) | PixelData::Cmyka8(v) => { - (0..n).map(|i| if invert { 255 - v[i * spp + c] } else { v[i * spp + c] }).collect() - } - PixelData::Rgba16(v) | PixelData::GrayA16(v) | PixelData::Cmyka16(v) => (0..n) - .flat_map(|i| { - let s = v[i * spp + c]; - (if invert { 65535 - s } else { s }).to_be_bytes() - }) - .collect(), - } - } - fn alpha_is_opaque(&self, n: usize) -> bool { - let spp = self.spp(); - match self { - PixelData::Rgba8(v) | PixelData::GrayA8(v) | PixelData::Cmyka8(v) => (0..n).all(|i| v[i * spp + spp - 1] == 255), - PixelData::Rgba16(v) | PixelData::GrayA16(v) | PixelData::Cmyka16(v) => (0..n).all(|i| v[i * spp + spp - 1] == 65535), - } - } - - fn composite_plane(&self, c: usize, n: usize) -> Vec { - // Photoshop stores transparent RGB composites against white; layer channels stay straight, - // and so do Gray and CMYK composites here (see `PsdBuilder::composite`). - match self { - Self::Rgba8(v) if c < 3 => v - .as_chunks::<4>() - .0 - .iter() - .take(n) - .map(|p| { - let (color, alpha) = (u32::from(p[c]), u32::from(p[3])); - if alpha == 0 { p[c] } else { ((color * alpha + 127) / 255 + 255 - alpha) as u8 } - }) - .collect(), - Self::Rgba16(v) if c < 3 => v - .as_chunks::<4>() - .0 - .iter() - .take(n) - .flat_map(|p| { - let (color, alpha) = (u32::from(p[c]), u32::from(p[3])); - let value = if alpha == 0 { p[c] } else { ((color * alpha + 32767) / 65535 + 65535 - alpha) as u16 }; - value.to_be_bytes() - }) - .collect(), - _ => self.plane(c, n), - } - } -} - -/// A layer mask given as 8-bit samples. -#[derive(Debug, Clone, PartialEq)] -pub struct MaskSpec { - /// Mask rectangle in document coordinates. - pub rect: Rect, - /// `rect.width × rect.height` samples (0 = hidden, 255 = shown). - pub data: Vec, - /// Value outside the rectangle. - pub default_color: u8, - /// Mask disabled. - pub disabled: bool, -} - -/// A raster layer. -#[derive(Debug, Clone, PartialEq)] -pub struct LayerSpec { - /// Name (written both as legacy Pascal name and `luni`). - pub name: String, - /// Left edge. - pub left: i32, - /// Top edge. - pub top: i32, - /// Width. - pub width: u32, - /// Height. - pub height: u32, - /// Pixels (must match the builder's color mode and depth). - pub pixels: PixelData, - /// Blend mode. - pub blend_mode: BlendMode, - /// Opacity 0..=255. - pub opacity: u8, - /// Fill opacity (`iOpa`), if not 255. - pub fill_opacity: Option, - /// Visible. - pub visible: bool, - /// Clipped to the layer below. - pub clipping: bool, - /// Optional user mask. - pub mask: Option, - /// Additional blocks appended verbatim. - pub extra_blocks: Vec, -} - -impl LayerSpec { - /// A visible normal layer at full opacity. - pub fn new(name: impl Into, left: i32, top: i32, width: u32, height: u32, pixels: PixelData) -> Self { - LayerSpec { - name: name.into(), - left, - top, - width, - height, - pixels, - blend_mode: BlendMode::Normal, - opacity: 255, - fill_opacity: None, - visible: true, - clipping: false, - mask: None, - extra_blocks: Vec::new(), - } - } -} - -/// A group (folder). -#[derive(Debug, Clone, PartialEq)] -pub struct GroupSpec { - /// Name. - pub name: String, - /// Blend mode (default pass-through). - pub blend_mode: BlendMode, - /// Opacity. - pub opacity: u8, - /// Visible. - pub visible: bool, - /// Open (expanded) in the layers panel. - pub open: bool, -} - -impl GroupSpec { - /// A visible, open, pass-through group. - pub fn new(name: impl Into) -> Self { - GroupSpec { name: name.into(), blend_mode: BlendMode::PassThrough, opacity: 255, visible: true, open: true } - } -} - -#[derive(Debug, Clone)] -enum Entry { - Layer(LayerSpec), - GroupStart, - GroupEnd(GroupSpec), -} - -/// Builds [`PsdFile`]s. Layers are pushed bottom-to-top. -#[derive(Debug, Clone)] -pub struct PsdBuilder { - width: u32, - height: u32, - color_mode: ColorMode, - depth: u16, - version: Version, - compression: Compression, - entries: Vec, - open_groups: Vec, - composite: Option, - resources: Vec, -} - -impl PsdBuilder { - /// An 8-bit RGB PSD using RLE compression. - pub fn new(width: u32, height: u32) -> Self { - PsdBuilder { - width, - height, - color_mode: ColorMode::Rgb, - depth: 8, - version: Version::Psd, - compression: Compression::Rle, - entries: Vec::new(), - open_groups: Vec::new(), - composite: None, - resources: Vec::new(), - } - } - /// Sets the color mode (RGB, Grayscale or CMYK). - pub fn color_mode(mut self, mode: ColorMode) -> Self { - self.color_mode = mode; - self - } - /// Sets the depth (8 or 16). - pub fn depth(mut self, depth: u16) -> Self { - self.depth = depth; - self - } - /// Sets PSD or PSB. - pub fn version(mut self, v: Version) -> Self { - self.version = v; - self - } - /// Sets the compression used for all channels and the merged image. - pub fn compression(mut self, c: Compression) -> Self { - self.compression = c; - self - } - /// Adds a resolution resource. - pub fn resolution(mut self, dpi: f64) -> Self { - self.resources.push(ImageResource::new(ids::RESOLUTION_INFO, ResolutionInfo::from_dpi(dpi).to_bytes())); - self - } - /// Adds an ICC profile resource. - pub fn icc_profile(mut self, icc: Vec) -> Self { - self.resources.push(ImageResource::new(ids::ICC_PROFILE, icc)); - self - } - /// Adds an arbitrary image resource. - pub fn resource(mut self, r: ImageResource) -> Self { - self.resources.push(r); - self - } - /// Pushes a layer above the previous one. - pub fn push_layer(&mut self, spec: LayerSpec) -> &mut Self { - self.entries.push(Entry::Layer(spec)); - self - } - /// Opens a group; subsequent layers go inside until [`Self::end_group`]. - pub fn begin_group(&mut self, spec: GroupSpec) -> &mut Self { - self.entries.push(Entry::GroupStart); - self.open_groups.push(spec); - self - } - /// Closes the innermost open group. - pub fn end_group(&mut self) -> Result<&mut Self> { - let g = self.open_groups.pop().ok_or_else(|| PsdError::invalid("end_group without begin_group"))?; - self.entries.push(Entry::GroupEnd(g)); - Ok(self) - } - /// Supplies the merged composite (straight samples, same format as layers, full canvas). - /// Transparent RGB is stored against white, quantized at the document depth. Gray and CMYK - /// composites with alpha are stored straight (not matted against white as Photoshop would): - /// a test that reads them as a Photoshop merged image has to allow for that. - pub fn composite(&mut self, pixels: PixelData) -> &mut Self { - self.composite = Some(pixels); - self - } - - fn color_channels(&self) -> Result { - match self.color_mode { - ColorMode::Rgb => Ok(3), - ColorMode::Grayscale => Ok(1), - ColorMode::Cmyk => Ok(4), - m => Err(PsdError::Unsupported(format!("builder color mode {m:?}"))), - } - } - - fn check_pixels(&self, p: &PixelData, n: usize) -> Result<()> { - if p.mode_depth() != (self.color_mode, self.depth) { - return Err(PsdError::invalid(format!("pixel format {:?} does not match document {:?}/{}", p.mode_depth(), self.color_mode, self.depth))); - } - if p.len() != n * p.spp() { - return Err(PsdError::invalid(format!("pixel buffer has {} samples, expected {}", p.len(), n * p.spp()))); - } - Ok(()) - } - - fn channel(&self, id: i16, plane: &[u8], w: usize, h: usize) -> Result { - ChannelData::encode(id, self.compression, plane, w, h, self.depth, self.version) - } - - fn empty_channels(&self, cc: usize) -> Result> { - let mut v = vec![self.channel(-1, &[], 0, 0)?]; - for c in 0..cc { - v.push(self.channel(c as i16, &[], 0, 0)?); - } - Ok(v) - } - - fn layer_record(&self, spec: &LayerSpec, cc: usize, id: u32) -> Result { - let (w, h) = (spec.width as usize, spec.height as usize); - let n = w * h; - self.check_pixels(&spec.pixels, n)?; - let mut channels = vec![self.channel(-1, &spec.pixels.plane(cc, n), w, h)?]; - for c in 0..cc { - channels.push(self.channel(c as i16, &spec.pixels.plane(c, n), w, h)?); - } - let mut mask = MaskData::None; - if let Some(m) = &spec.mask { - let (mw, mh) = m.rect.size()?; - if m.data.len() != mw * mh { - return Err(PsdError::invalid("mask buffer size mismatch")); - } - let plane: Vec = if self.depth == 16 { m.data.iter().flat_map(|&v| (u16::from(v) * 257).to_be_bytes()).collect() } else { m.data.clone() }; - channels.push(self.channel(-2, &plane, mw, mh)?); - let flags = if m.disabled { LayerMask::FLAG_DISABLED } else { 0 }; - mask = MaskData::Mask(LayerMask::new(m.rect, m.default_color, flags)); - } - let mut flags = LayerFlags(0); - flags.set_hidden(!spec.visible); - let mut blocks = vec![TaggedBlock::unicode_name(&spec.name), TaggedBlock::layer_id(id)]; - if let Some(f) = spec.fill_opacity { - blocks.push(TaggedBlock::fill_opacity(f)); - } - blocks.extend(spec.extra_blocks.iter().cloned()); - Ok(LayerRecord { - rect: Rect::from_xywh(spec.left, spec.top, spec.width, spec.height), - channels, - blend_mode: spec.blend_mode, - opacity: spec.opacity, - clipping: u8::from(spec.clipping), - flags, - filler: 0, - mask, - blending_ranges: BlendingRanges::full(cc), - name: encode_legacy_name(&spec.name), - blocks, - extra_trailing: Vec::new(), - }) - } - - /// Builds the file model. - pub fn build(&self) -> Result { - if !self.open_groups.is_empty() { - return Err(PsdError::invalid("unclosed group")); - } - if !matches!(self.depth, 8 | 16) { - return Err(PsdError::Unsupported(format!("builder depth {}", self.depth))); - } - let cc = self.color_channels()?; - let mut layers = Vec::new(); - for (id, e) in (1u32..).zip(self.entries.iter()) { - match e { - Entry::Layer(spec) => layers.push(self.layer_record(spec, cc, id)?), - Entry::GroupStart => layers.push(LayerRecord { - channels: self.empty_channels(cc)?, - blending_ranges: BlendingRanges::full(cc), - name: b"".to_vec(), - blocks: vec![ - TaggedBlock::unicode_name(""), - TaggedBlock::layer_id(id), - TaggedBlock::section_divider(SectionType::BoundingDivider, None, None), - ], - ..Default::default() - }), - Entry::GroupEnd(g) => { - let kind = if g.open { SectionType::OpenFolder } else { SectionType::ClosedFolder }; - let mut flags = LayerFlags(0); - flags.set_hidden(!g.visible); - layers.push(LayerRecord { - channels: self.empty_channels(cc)?, - blend_mode: g.blend_mode, - opacity: g.opacity, - flags, - blending_ranges: BlendingRanges::full(cc), - name: encode_legacy_name(&g.name), - blocks: vec![ - TaggedBlock::unicode_name(&g.name), - TaggedBlock::layer_id(id), - TaggedBlock::section_divider(kind, Some(g.blend_mode), None), - ], - ..Default::default() - }); - } - } - } - - let (w, h) = (self.width as usize, self.height as usize); - let n = w * h; - let (real, planes, with_alpha) = match &self.composite { - Some(p) => { - self.check_pixels(p, n)?; - let with_alpha = !p.alpha_is_opaque(n); - let mut planes = Vec::new(); - for c in 0..cc + usize::from(with_alpha) { - planes.extend(if with_alpha { p.composite_plane(c, n) } else { p.plane(c, n) }); - } - (true, planes, with_alpha) - } - None => { - let bps = usize::from(self.depth / 8); - // White: 0xff in RGB/gray; no ink (stored inverted as 0xff) in CMYK. - (false, vec![0xff; n * bps * cc], false) - } - }; - let channels = (cc + usize::from(with_alpha)) as u16; - let header = Header::new(self.version, self.width, self.height, channels, self.depth, self.color_mode); - header.validate()?; - let image_data = ImageData::encode(self.compression, &planes, &header)?; - let mut resources = self.resources.clone(); - resources.push(version_info_resource(real)); - - let has_layers = !layers.is_empty(); - let layer_info = has_layers.then_some(LayerInfo { merged_alpha: with_alpha, layers, padding: None }); - let layer_info_placement = if has_layers && self.depth == 16 { - LayerInfoPlacement::GlobalBlock { index: 0, signature: *b"8BIM", key: *b"Lr16", padding: None } - } else { - LayerInfoPlacement::Section - }; - Ok(PsdFile { - header, - color_mode_data: Vec::new(), - resources, - layer_info, - layer_info_placement, - global_layer_mask: has_layers.then(GlobalLayerMask::default), - global_blocks: Vec::new(), - layer_mask_trailing: Vec::new(), - image_data, - write_as_psb: false, - }) - } - - /// Builds and serializes. - pub fn to_bytes(&self) -> Result> { - self.build()?.to_bytes() - } -} diff --git a/crates/psd/src/codepage.rs b/crates/psd/src/codepage.rs deleted file mode 100644 index af472ea574..0000000000 --- a/crates/psd/src/codepage.rs +++ /dev/null @@ -1,874 +0,0 @@ -//! Guessing the legacy code page of 8-bit names. -//! -//! Some formats store names as bytes in the writer's system ANSI code page: an `.atn` file's -//! event and dictionary names are CP936 (GBK) from Chinese Photoshop, CP932 (Shift_JIS) from -//! Japanese, Mac Roman from French Photoshop on a Mac, and so on. Nothing in the file says -//! which. When the file also stores some of those names as Unicode, [`match_known`] finds the -//! code page that turns the bytes into them. Otherwise [`guess`] infers one code page per file -//! from the names that are not valid UTF-8: -//! -//! 1. Candidates: Shift_JIS, GBK, Big5, EUC-KR, windows-1252, windows-1251, macintosh (Mac -//! Roman). A candidate that cannot decode every name without errors is dropped. -//! 2. Each survivor is scored by the share of non-ASCII bytes that fall in its common-text -//! ranges (GB2312 hanzi, JIS level-1 kanji and kana, Big5 level 1, KS X 1001 Hangul, -//! letters, each page's punctuation, the degree sign, and ¡ opening an exclamation: "¡Hola!"). The -//! highest share wins. Two single-byte readings don't count: the letters windows-1252 alone -//! has at 0x80–0x9F (Š š Ž ž Ÿ ƒ ˆ), where Mac Roman keeps its accented lowercase (Mac -//! "Opacité" is 1252 "OpacitŽ"), unless one starts a word ("Škoda", "šuma") in a file whose -//! own Unicode text doesn't write the Mac Roman letter on that byte instead (a French "état", -//! a German "über", "öffnen"); -//! and a windows-1251 Cyrillic letter glued into an ASCII word (Mac "Höhe" is 1251 "Hшhe"), -//! unless the name's letters are mostly Cyrillic ("CMYKканал", "Копiя") or the letter is in a -//! Cyrillic word of its own ("Layer Слой", "Слой1"). -//! 3. A tie is broken by the script of a hint (text the file stores as UTF-16, so it decodes -//! fine): kana → Shift_JIS; Hangul → EUC-KR; Cyrillic → windows-1251; Han → GBK, then -//! Big5, then Shift_JIS; Latin letters → windows-1252, then Mac Roman. -//! 4. Still tied: windows-1252 if it is among them, otherwise the first highest-scoring candidate, -//! flagged [`Guess::uncertain`]. -//! 5. Mac Roman must also beat windows-1252 when the Latin-1 symbols that are no letter in -//! Mac Roman (© ° « ± …) count as common text (1252 "Größe »" is Mac Roman "Grˆfle ª"). -//! Otherwise the names are windows-1252, with step 3's hint and step 4's warning. -//! -//! Only the first [`GUESS_SAMPLE`] bytes of names are examined. - -use encoding_rs::{ - BIG5, DecoderResult, EUC_KR, Encoding, GBK, ISO_2022_JP, MACINTOSH, REPLACEMENT, SHIFT_JIS, UTF_8, UTF_16BE, UTF_16LE, WINDOWS_1251, WINDOWS_1252, - X_USER_DEFINED, -}; - -use crate::error::{PsdError, Result}; - -/// At most this many bytes of legacy names are examined by [`guess`]. -pub(crate) const GUESS_SAMPLE: usize = 64 * 1024; -/// At most this many `(bytes, text)` pairs are examined by [`match_known`]. -pub(crate) const KNOWN_PAIRS: usize = 256; - -/// The code page [`guess`] settled on. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) struct Guess { - /// The encoding to decode every legacy name with. - pub(crate) encoding: &'static Encoding, - /// Nothing settled the highest-scoring candidates' tie; `encoding` is a fallback among them. - pub(crate) uncertain: bool, -} - -/// Candidates in tie order (encoding_rs exposes encodings as statics, hence a function). -fn candidates() -> [&'static Encoding; 7] { - [SHIFT_JIS, GBK, BIG5, EUC_KR, WINDOWS_1252, WINDOWS_1251, MACINTOSH] -} - -/// The candidate that decodes the most of `pairs` (a name's bytes and the same name as Unicode) -/// exactly; ties go to the earlier candidate. `None` when it decodes none of them. Only the first -/// [`KNOWN_PAIRS`] pairs are examined. -pub(crate) fn match_known(pairs: &[(&[u8], &str)]) -> Option<&'static Encoding> { - let pairs = pairs.get(..KNOWN_PAIRS).unwrap_or(pairs); - let mut best = None; - let mut best_count = 0usize; - for enc in candidates() { - let count = pairs.iter().filter(|(bytes, text)| enc.decode_without_bom_handling_and_without_replacement(bytes).is_some_and(|d| d == *text)).count(); - if count > best_count { - best = Some(enc); - best_count = count; - } - } - best -} - -/// Guesses the one code page that `legacy` (names that are not valid UTF-8, in file order) were -/// written in. `hint` is text from the same file whose script points at the writer's locale. -pub(crate) fn guess(legacy: &[&[u8]], hint: &str) -> Guess { - let sample = sample(legacy); - if sample.is_empty() { - return Guess { encoding: UTF_8, uncertain: false }; - } - // The accented letters the file's own Unicode text uses: what its writer's language writes. - let hint_letters: std::collections::BTreeSet = hint.chars().filter(|c| !c.is_ascii() && c.is_alphabetic()).flat_map(char::to_lowercase).collect(); - let mut best: Vec<&'static Encoding> = Vec::new(); - let mut best_score = (0u64, 1u64); - for enc in candidates() { - if !sample.iter().all(|(name, truncated)| decodes_cleanly(enc, name, *truncated)) { - continue; - } - let score = sample.iter().fold((0u64, 0u64), |(c, h), (name, _)| { - let (nc, nh) = score(enc, name, &hint_letters); - (c.saturating_add(nc), h.saturating_add(nh)) - }); - let score = if score.1 == 0 { (0, 1) } else { score }; - // Compare common/high shares without division: a/b vs c/d ⇔ a·d vs c·b. - let lhs = u128::from(score.0) * u128::from(best_score.1); - let rhs = u128::from(best_score.0) * u128::from(score.1); - if best.is_empty() || lhs > rhs { - best = vec![enc]; - best_score = score; - } else if lhs == rhs { - best.push(enc); - } - } - let picked = match best.as_slice() { - [only] => Guess { encoding: only, uncertain: false }, - tied => match hinted(hint).iter().find(|enc| tied.contains(enc)) { - Some(enc) => Guess { encoding: enc, uncertain: false }, - // A fallback must not discard the scores and choose a lower-scoring page. - None => { - let encoding = if tied.contains(&WINDOWS_1252) { WINDOWS_1252 } else { tied.first().copied().unwrap_or(WINDOWS_1252) }; - Guess { encoding, uncertain: true } - } - }, - }; - if picked.encoding != MACINTOSH { - return picked; - } - // windows-1252 maps every byte, so it is always a survivor here. - let latin = |enc| { - sample.iter().fold((0u64, 0u64), |(c, h), (name, _)| { - let (nc, nh) = single_byte_score(enc, name, is_latin_common); - (c.saturating_add(nc), h.saturating_add(nh)) - }) - }; - let (windows, mac) = (latin(WINDOWS_1252), latin(MACINTOSH)); - let lhs = u128::from(windows.0) * u128::from(mac.1.max(1)); - let rhs = u128::from(mac.0) * u128::from(windows.1.max(1)); - if lhs < rhs { - picked - } else if lhs > rhs || hinted(hint).contains(&WINDOWS_1252) { - Guess { encoding: WINDOWS_1252, uncertain: false } - } else { - Guess { encoding: WINDOWS_1252, uncertain: true } - } -} - -/// Decodes `bytes` with `enc`; invalid sequences become U+FFFD. -pub(crate) fn decode(bytes: &[u8], enc: &'static Encoding) -> String { - enc.decode_without_bom_handling(bytes).0.into_owned() -} - -/// Whether `name` decodes cleanly in the double-byte page `enc` and every non-ASCII byte of it is -/// common text there. A name in such a page can be valid UTF-8 by accident (GBK 去色 is -/// `C8 A5 C9 AB`, "ȥɫ"); real UTF-8 rarely passes, as its continuation bytes fall outside the -/// common ranges. Single-byte pages are never asked: their letters cover UTF-8's lead and -/// continuation bytes ("Weiß" is "Weiß" in windows-1252), and a legacy name there is valid UTF-8 -/// by accident only for unlikely pairs such as "é". -pub(crate) fn reads_as_common(name: &[u8], enc: &'static Encoding) -> bool { - if ![SHIFT_JIS, GBK, BIG5, EUC_KR].contains(&enc) { - return false; - } - let (common, high) = score(enc, name, &std::collections::BTreeSet::new()); - high > 0 && common == high && decodes_cleanly(enc, name, false) -} - -/// Resolves an encoding label (`shift_jis`, `gbk`, `big5`, `euc-kr`, `windows-1252`, `utf-8`…; -/// case and surrounding whitespace are ignored). Labels for encodings that cannot hold an 8-bit -/// name are rejected, and so is ISO-2022-JP: its names are 7-bit escape sequences, which would -/// pass through as ASCII. -pub(crate) fn resolve_label(label: &str) -> Result<&'static Encoding> { - match Encoding::for_label(label.as_bytes()) { - Some(enc) if ![REPLACEMENT, UTF_16LE, UTF_16BE, X_USER_DEFINED, ISO_2022_JP].contains(&enc) => Ok(enc), - _ => Err(PsdError::Unsupported(format!("text encoding `{label}`: use a label such as shift_jis, gbk, big5, euc-kr, windows-1252 or utf-8"))), - } -} - -/// The names [`guess`] examines, each with whether it was cut short: names in order while they -/// fit in [`GUESS_SAMPLE`] bytes, and at least the first (truncated if it alone is longer). -fn sample<'a>(legacy: &[&'a [u8]]) -> Vec<(&'a [u8], bool)> { - let mut out = Vec::new(); - let mut total = 0usize; - for name in legacy { - let next = total.saturating_add(name.len()); - if next > GUESS_SAMPLE { - if out.is_empty() { - out.push((name.get(..GUESS_SAMPLE).unwrap_or(name), true)); - } - break; - } - out.push((*name, false)); - total = next; - } - out -} - -/// Whether `enc` decodes `bytes` without a single error. A `truncated` name may end inside a -/// character, which is not held against it. -fn decodes_cleanly(enc: &'static Encoding, bytes: &[u8], truncated: bool) -> bool { - let mut decoder = enc.new_decoder_without_bom_handling(); - // Every candidate decodes one byte to at most three UTF-8 bytes, so this is linear in - // `bytes` (a sampled name, or one name the file already holds). - let mut out = String::with_capacity(bytes.len().saturating_mul(3).saturating_add(16)); - let mut rest = bytes; - loop { - let (result, read) = decoder.decode_to_string_without_replacement(rest, &mut out, !truncated); - match result { - DecoderResult::InputEmpty => return true, - DecoderResult::Malformed(..) => return false, - DecoderResult::OutputFull => { - rest = rest.get(read..).unwrap_or_default(); - out.clear(); - } - } - } -} - -/// `(common, high)`: how many of a name's non-ASCII bytes `enc` reads as common text, and how -/// many non-ASCII bytes it has. `hint_letters` are the accented letters the file's own Unicode -/// text writes (lowercase), which settle a word-initial letter of windows-1252's 0x80–0x9F. -fn score(enc: &'static Encoding, name: &[u8], hint_letters: &std::collections::BTreeSet) -> (u64, u64) { - if enc == WINDOWS_1252 || enc == WINDOWS_1251 || enc == MACINTOSH { - // Letters, typographic punctuation, the degree sign ("Rotation 90°") and ¡ opening an - // exclamation ("¡Hola!") are common, as the double-byte pages' punctuation rows are, with two readings - // held back: the letters windows-1252 alone has at 0x80–0x9F, where Mac Roman keeps its - // accented lowercase (Mac "Opacité" is 1252 "OpacitŽ"), unless one starts a word ("Škoda", - // "ŠKODA", "šuma") and the file's own text doesn't write the Mac Roman letter instead (a - // French "état", a German "öffnen"); and a windows-1251 Cyrillic letter glued into an ASCII - // word (Mac "Höhe" is 1251 "Hшhe"), unless the name's letters are mostly Cyrillic - // ("CMYKканал", Ukrainian "Копiя") or the letter is in a Cyrillic word of its own ("Layer - // Слой"); a lone one right after a digit (Mac "90°" alone is 1251 "90Ў") never counts. - let decoded = Name::new(&decode(name, enc)); - let chars = &decoded.chars; - let cyrillic_name = chars.iter().filter(|c| is_cyrillic(**c)).count() >= chars.iter().filter(|c| c.is_ascii_alphabetic()).count(); - let (mut common, mut high) = (0u64, 0u64); - for (i, &ch) in chars.iter().enumerate() { - if ch.is_ascii() { - continue; - } - high += 1; - let (after_ascii, letters_after) = position(&decoded, i); - let counts = if enc == WINDOWS_1252 && RARE_1252_LETTERS.contains(&ch) { - // Opening a word, capital or not ("Škoda", "šuma"), before an ASCII letter of - // either case: Mac Roman's lowercase ä before a capital ("äKODA") is no word - // either. - let word_initial = !after_ascii && letters_after >= 1; - // The same byte as Mac Roman reads it: a letter the file's text writes, where it - // never writes this one, says the writer meant that ("état", not "Žtat"). - let written = |c: char| c.to_lowercase().all(|l| hint_letters.contains(&l)); - let mac = name.get(i..=i).map(|b| decode(b, MACINTOSH)).and_then(|s| s.chars().next()); - word_initial && !(mac.is_some_and(|m| m.is_alphabetic() && written(m)) && !written(ch)) - } else if enc == WINDOWS_1251 && is_cyrillic(ch) { - (cyrillic_name && !lone_after_digit(&decoded, i)) || cyrillic_word(&decoded, i) - } else { - ch.is_alphabetic() || TYPOGRAPHIC.contains(&ch) || ch == '°' || (ch == '¡' && opens_exclamation(&decoded, i)) - }; - common += u64::from(counts); - } - return (common, high); - } - let (mut common, mut high) = (0u64, 0u64); - let mut i = 0usize; - while let Some(&lead) = name.get(i) { - if lead < 0x80 { - i += 1; - continue; - } - // Shift_JIS reads 0x80 and 0xA0–0xDF (half-width katakana, rare in real text) as one - // byte; elsewhere a lone non-ASCII byte is an error the decoder already rejected. - let single = if enc == SHIFT_JIS { lead == 0x80 || (0xA0..=0xDF).contains(&lead) || lead >= 0xFD } else { lead == 0x80 || lead == 0xFF }; - if single { - high += 1; - i += 1; - continue; - } - // A lead byte with nothing after it is where the sample was cut (the decoder rejects one - // at the real end of a name), so it is not scored. - let Some(&trail) = name.get(i + 1) else { break }; - // GB18030 four-byte sequences (second byte a digit) are never common text. - let len = if enc == GBK && trail.is_ascii_digit() { 4 } else { 2 }; - let len = len.min(name.len().saturating_sub(i)); - high += len as u64; - if len == 2 && is_common(enc, lead, trail) { - common += 2; - } - i += len; - } - (common, high) -} - -/// [`score`] for a single-byte page, where one byte is one character and a non-ASCII byte is a -/// non-ASCII character: `common` decides which of them count. -/// -/// windows-1251 has Cyrillic letters (І і Ј д) where windows-1252 has ² ³ £ ä, so a windows-1252 -/// name like "Fläche m²" or "Preis £5" would score higher as windows-1251 ("Flдche mІ", "Preis -/// Ј5"); [`score`] counts a Cyrillic letter only in a name whose letters are mostly Cyrillic, -/// which keeps ordinary Russian names such as "CMYKканал" or Ukrainian "Копiя" (Latin i). -fn single_byte_score(enc: &'static Encoding, name: &[u8], common: impl Fn(&Name, usize) -> bool) -> (u64, u64) { - let name = Name::new(&decode(name, enc)); - (0..name.chars.len()) - .filter(|i| name.chars.get(*i).is_some_and(|c| !c.is_ascii())) - .fold((0, 0), |(c, h), i| (c.saturating_add(u64::from(common(&name, i))), h.saturating_add(1))) -} - -/// A decoded name with what the position checks of [`score`] need, each found in one pass, so a -/// long name (a hostile file's 64 KiB of one letter) costs linear time rather than a rescan of -/// the rest of the name from every character. -struct Name { - chars: Vec, - /// `letters_from[k]`: how many letters run from `k` on (0 one past the end). - letters_from: Vec, - /// Where the last "!" is. - last_bang: Option, - /// For each Cyrillic letter, the run of Cyrillic letters around it (`start..end`); `i..i + 1` - /// for any other character. - cyrillic_runs: Vec<(usize, usize)>, -} - -impl Name { - fn new(text: &str) -> Self { - let chars: Vec = text.chars().collect(); - let n = chars.len(); - let mut letters_from = vec![0usize; n + 1]; - let mut run = 0usize; - for (slot, c) in letters_from.iter_mut().zip(&chars).rev() { - run = if c.is_alphabetic() { run.saturating_add(1) } else { 0 }; - *slot = run; - } - let last_bang = chars.iter().rposition(|c| *c == '!'); - let mut cyrillic_runs: Vec<(usize, usize)> = (0..n).map(|i| (i, i + 1)).collect(); - let mut start = 0; - while start < n { - if !chars.get(start).is_some_and(|c| is_cyrillic(*c)) { - start += 1; - continue; - } - let mut end = start + 1; - while chars.get(end).is_some_and(|c| is_cyrillic(*c)) { - end += 1; - } - for slot in cyrillic_runs.get_mut(start..end).into_iter().flatten() { - *slot = (start, end); - } - start = end; - } - Self { chars, letters_from, last_bang, cyrillic_runs } - } -} - -/// Whether the character at `i` of `chars` is common in Western names, for weighing windows-1252 -/// against Mac Roman: letters (but not those windows-1252 has only at 0x80–0x9F, where Mac Roman -/// keeps its accented lowercase: Mac "Größe" is 1252 "Grš§e"), typographic punctuation, -/// [`LATIN_SYMBOLS`], and ¡ opening an exclamation ("¡Hola!"), as in [`score`]. -fn is_latin_common(name: &Name, i: usize) -> bool { - let Some(&ch) = name.chars.get(i) else { return false }; - (ch.is_alphabetic() && !RARE_1252_LETTERS.contains(&ch) && ch != 'Œ' && ch != 'œ') - || TYPOGRAPHIC.contains(&ch) - || LATIN_SYMBOLS.contains(&ch) - || (ch == '¡' && opens_exclamation(name, i)) -} - -/// Where the character at `i` of `name` sits: right after an ASCII letter or digit (inside a word -/// or a number), and how many letters (accented ones too: "¡Ánimo!") follow it directly. -fn position(name: &Name, i: usize) -> (bool, usize) { - let after_ascii = i.checked_sub(1).and_then(|k| name.chars.get(k)).is_some_and(|c| c.is_ascii_alphanumeric()); - let letters_after = name.letters_from.get(i.saturating_add(1)).copied().unwrap_or(0); - (after_ascii, letters_after) -} - -/// Whether ¡ at `i` opens a Spanish exclamation ("¡Hola!", "Capa ¡Atención!", "¡Ánimo!"): no letter -/// or digit right before it, two letters or more after it, and the closing "!" later in the name. -/// Mac Roman's degree sign on the same byte follows a number ("Rotation 90°") or stands before a -/// unit ("20 °C", "20 °Celsius"), with no "!" to close. -fn opens_exclamation(name: &Name, i: usize) -> bool { - let (after_ascii, letters_after) = position(name, i); - !after_ascii && letters_after >= 2 && name.last_bang.is_some_and(|bang| bang > i) -} - -/// The letters windows-1252 has at 0x80–0x9F and Mac Roman doesn't: there Mac Roman keeps its -/// accented lowercase (ä é ö ü ...), so these never decide against it. Œ and œ (0x8C, 0x9C: Mac -/// å, ú) are real French letters and keep counting in [`score`]; [`is_latin_common`] leaves them -/// out as well. -const RARE_1252_LETTERS: [char; 7] = ['Š', 'š', 'Ž', 'ž', 'Ÿ', 'ƒ', 'ˆ']; - -/// A Cyrillic letter (windows-1251's 0xC0–0xFF and the letters among 0x80–0xBF). -fn is_cyrillic(ch: char) -> bool { - ('\u{0400}'..='\u{04FF}').contains(&ch) && ch.is_alphabetic() -} - -/// Whether the Cyrillic letter at `i` is in a Cyrillic word of its own: a run of two or more, -/// not glued to an ASCII letter, and not right after a digit ("Слой" beside "Layer", "Слой1"; -/// not "OpacitЋ", nor "90ЎЋ", which is Mac Roman "90°é": a digit ends a number, not a word, so -/// what follows it without a space is its unit or suffix). -fn cyrillic_word(name: &Name, i: usize) -> bool { - let (start, end) = cyrillic_run(name, i); - let glued_before = start.checked_sub(1).and_then(|k| name.chars.get(k)).is_some_and(|c| c.is_ascii_alphanumeric()); - let glued_after = name.chars.get(end).is_some_and(|c| c.is_ascii_alphabetic()); - end.saturating_sub(start) >= 2 && !glued_before && !glued_after -} - -/// The run of Cyrillic letters around the Cyrillic letter at `i` (`start..end`). -fn cyrillic_run(name: &Name, i: usize) -> (usize, usize) { - name.cyrillic_runs.get(i).copied().unwrap_or((i, i.saturating_add(1))) -} - -/// Whether the Cyrillic letter at `i` stands alone right after a digit: a unit or a symbol misread -/// (Mac Roman "90°" is windows-1251 "90Ў"), whatever the rest of the name; a run after a digit is -/// a Russian unit ("5шт") and still counts. -fn lone_after_digit(name: &Name, i: usize) -> bool { - let (start, end) = cyrillic_run(name, i); - end.saturating_sub(start) == 1 && start.checked_sub(1).and_then(|k| name.chars.get(k)).is_some_and(|c| c.is_ascii_digit()) -} - -/// Latin-1 symbols people type in names (windows-1252 0xA2–0xB7) whose byte is not a letter in -/// Mac Roman. Left out because Mac Roman has a letter there: § ® ¹ » ¼ ½ ¾ ¿ (ß Æ π ª º Ω æ ø, -/// as in Mac "Weiß", "Størrelse", "Æble"), € and ™ (0x80, 0x99: Ä, ô), and ¥ (0xA5, Mac Roman's -/// bullet in [`TYPOGRAPHIC`]: Mac "Ebene • Kopie"); and ¡ (0xA1), where Mac Roman has its degree -/// sign, the commoner of the two in names (Mac "Rotation 90°", "20 °C"), which counts only where -/// it opens an exclamation closed by "!" ("¡Hola!"; [`opens_exclamation`], in [`score`] and -/// [`is_latin_common`]). So a windows-1252 name whose only non-ASCII character is one of the -/// others ("Logo ®", "¿Tamaño?", "Preis ¥100") still reads as Mac Roman ("Logo Æ"). -const LATIN_SYMBOLS: [char; 10] = ['¢', '£', '©', '«', '°', '±', '²', '³', '¶', '·']; - -/// Quotes, dashes, ellipsis and bullet: windows-1252 and windows-1251 share them at 0x82–0x97; -/// Mac Roman has them too. -const TYPOGRAPHIC: [char; 10] = ['‘', '’', '‚', '“', '”', '„', '–', '—', '…', '•']; - -/// Whether the two-byte character `lead trail` is in `enc`'s common-text ranges. -fn is_common(enc: &'static Encoding, lead: u8, trail: u8) -> bool { - let both = u16::from_be_bytes([lead, trail]); - if enc == GBK { - // GB2312 hanzi, and the punctuation rows 0xA1 and 0xA3. - (0xA1..=0xFE).contains(&trail) && ((0xB0..=0xF7).contains(&lead) || lead == 0xA1 || lead == 0xA3) - } else if enc == BIG5 { - // Level-1 hanzi and punctuation. - (0xA4..=0xC6).contains(&lead) || lead == 0xA1 - } else if enc == SHIFT_JIS { - // Kana, level-1 kanji and punctuation. - (0x829F..=0x8396).contains(&both) || (0x889F..=0x9872).contains(&both) || (0x8140..=0x81FC).contains(&both) - } else if enc == EUC_KR { - // KS X 1001 Hangul and punctuation. - trail >= 0xA1 && ((0xB0..=0xC8).contains(&lead) || lead == 0xA1) - } else { - false - } -} - -/// The candidates the script of `hint` points at, strongest first. -fn hinted(hint: &str) -> Vec<&'static Encoding> { - let has = |lo: char, hi: char| hint.chars().any(|c| (lo..=hi).contains(&c) && c.is_alphabetic()); - if has('\u{3040}', '\u{30FF}') { - vec![SHIFT_JIS] - } else if has('\u{AC00}', '\u{D7A3}') { - vec![EUC_KR] - } else if has('\u{0400}', '\u{04FF}') { - vec![WINDOWS_1251] - } else if has('\u{4E00}', '\u{9FFF}') { - vec![GBK, BIG5, SHIFT_JIS] - } else if has('\u{00C0}', '\u{024F}') { - vec![WINDOWS_1252, MACINTOSH] - } else { - vec![] - } -} - -#[cfg(test)] -mod tests { - use super::*; - - const GBK_ISSUE: &[&[u8]] = &[b"\xbf\xc9\xd1\xa1\xd1\xd5\xc9\xab"]; // 可选颜色 (from #6743) - const SJIS_COMMENT: &[&[u8]] = &[ - b"\x91\x49\x91\xf0\x20\x3a", // 選択 : - b"\x90\xdd\x92\xe8\x20\x3a", // 設定 : - b"\x83\x52\x83\x73\x81\x5b", // コピー - b"\x95\xdb\x91\xb6", // 保存 - ]; - - fn encoded(enc: &'static Encoding, words: &[&str]) -> Vec> { - words.iter().map(|w| enc.encode(w).0.into_owned()).collect() - } - - fn guess_words(enc: &'static Encoding, words: &[&str], hint: &str) -> Guess { - let bytes = encoded(enc, words); - let refs: Vec<&[u8]> = bytes.iter().map(Vec::as_slice).collect(); - guess(&refs, hint) - } - - #[test] - fn issue_gbk_bytes_read_as_gbk() { - let g = guess(GBK_ISSUE, "调白"); - assert_eq!(g.encoding, GBK); - assert!(!g.uncertain); - assert_eq!(decode(GBK_ISSUE[0], g.encoding), "可选颜色"); - } - - #[test] - fn kanji_only_hint_still_reads_shift_jis() { - let g = guess(SJIS_COMMENT, "保存"); - assert_eq!(g.encoding, SHIFT_JIS); - assert_eq!(decode(SJIS_COMMENT[0], g.encoding), "選択 :"); - } - - #[test] - fn big5_reads_as_big5() { - assert_eq!(guess_words(BIG5, &["色彩範圍", "移動", "選取", "儲存"], "").encoding, BIG5); - } - - #[test] - fn hangul_hint_reads_euc_kr() { - let g = guess_words(EUC_KR, &["선택", "이동", "저장", "색상 범위"], "동작 1"); - assert_eq!(g.encoding, EUC_KR); - assert!(!g.uncertain); - } - - #[test] - fn cyrillic_hint_reads_1251() { - let g = guess_words(WINDOWS_1251, &["Выделить", "Переместить", "Сохранить"], "Набор 1"); - assert_eq!(g.encoding, WINDOWS_1251); - assert!(!g.uncertain); - } - - #[test] - fn latin_hint_reads_1252() { - let g = guess_words(WINDOWS_1252, &["Größe"], "Größe ändern"); - assert_eq!(g.encoding, WINDOWS_1252); - assert!(!g.uncertain); - } - - #[test] - fn tie_without_hint_is_1252_and_uncertain() { - let g = guess_words(WINDOWS_1252, &["Größe"], "Set 1"); - assert_eq!(g.encoding, WINDOWS_1252); - assert!(g.uncertain); - } - - #[test] - fn han_tie_prefers_gbk() { - // GBK 保存: GBK, Big5 and EUC-KR all read both characters as common text. - let names: &[&[u8]] = &[b"\xb1\xa3\xb4\xe6"]; - let g = guess(names, "动作"); - assert_eq!(g.encoding, GBK); - assert!(!g.uncertain); - assert_eq!(decode(names[0], g.encoding), "保存"); - assert_eq!(guess(names, "Set 1"), Guess { encoding: GBK, uncertain: true }); - } - - #[test] - fn typographic_punctuation_reads_as_1252() { - // ’ (0x92) then a letter is also a level-1 kanji pair in Shift_JIS. - let names: &[&[u8]] = &[b"Taille de l\x92image"]; - let g = guess(names, "Actions par défaut"); - assert_eq!(g.encoding, WINDOWS_1252); - assert!(!g.uncertain); - assert_eq!(decode(names[0], g.encoding), "Taille de l’image"); - assert_eq!(guess(names, "Set 1"), Guess { encoding: WINDOWS_1252, uncertain: true }); - } - - #[test] - fn utf8_lookalikes_read_as_common_text() { - // GBK 去色 and EUC-KR 칠 are also valid UTF-8 ("ȥɫ", "ĥ"). - assert!(reads_as_common(b"\xc8\xa5\xc9\xab", GBK)); - assert!(reads_as_common(b"\xc4\xa5", EUC_KR)); - // Real UTF-8 text does not: its continuation bytes fall outside the common ranges. - assert!(!reads_as_common("高斯模糊".as_bytes(), GBK)); - assert!(!reads_as_common("Gaussian Blur ✓".as_bytes(), GBK)); - assert!(!reads_as_common("Größe".as_bytes(), WINDOWS_1252)); - assert!(!reads_as_common(b"Blur", GBK), "ASCII has nothing to re-read"); - // Nor does UTF-8 in a single-byte page, though "Weiß" is à and Ÿ there and "Привет" is - // all Р, С and letters: accidental UTF-8 is a double-byte page's problem. - assert!(!reads_as_common("Weiß".as_bytes(), WINDOWS_1252)); - assert!(!reads_as_common("Привет".as_bytes(), WINDOWS_1251)); - assert!(!reads_as_common("Weiß".as_bytes(), MACINTOSH)); - } - - #[test] - fn latin1_symbols_read_as_1252_not_mac_roman() { - // Mac Roman has letters where windows-1252 has some symbols: 1252 "Größe »" is Mac Roman - // "Grˆfle ª", "50 °" is "50 ∞". Latin-1 (what a code-page-blind reader shows) wins. - for words in [&["Größe »"][..], &["50 °"], &["Contraste « auto »", "© Größe", "± 5"]] { - let g = guess_words(WINDOWS_1252, words, "Größe ändern"); - assert_eq!(g, Guess { encoding: WINDOWS_1252, uncertain: false }, "{words:?}"); - assert_eq!(guess_words(WINDOWS_1252, words, "Set 1").encoding, WINDOWS_1252, "{words:?}"); - } - } - - #[test] - fn mac_roman_accents_on_1252_letter_bytes_stay_mac_roman() { - // #10774: Mac Roman é ä ö ü Ä (0x8E 0x8A 0x9A 0x9F 0x80) are windows-1252 Ž Š š Ÿ € and - // windows-1251 Ћ Љ ш џ Ђ. The rare 1252 letters don't count, and a Cyrillic letter in a - // Latin word doesn't either, so Mac Roman wins with a Latin hint and without one. - let cases: [(&[&str], &str); 6] = [ - (&["Opacité 50%"], "Définir"), - (&["Opacité 50%"], "Set 1"), - (&["Sélection", "Créer un calque", "Luminosité/Contraste"], "Définir"), - (&["Höhe", "Grün", "Bär"], "Größe ändern"), - (&["Höhe"], "Set 1"), - (&["Ändern"], "Set 1"), - ]; - for (words, hint) in cases { - let g = guess_words(MACINTOSH, words, hint); - assert_eq!(g, Guess { encoding: MACINTOSH, uncertain: false }, "{words:?} with hint {hint:?}"); - let bytes = encoded(MACINTOSH, words); - for (b, w) in bytes.iter().zip(words) { - assert_eq!(decode(b, g.encoding), *w); - } - } - } - - #[test] - fn mac_roman_degree_sign_is_not_cyrillic() { - // #10774: Mac Roman ° (0xA1) is windows-1251 Ў and windows-1252 ¡; the degree sign is - // common text, a lone Cyrillic letter among Latin ones is not. - // "Layer 90°é" (review): ° and é are windows-1251 Ў and Ћ, two Cyrillic letters in a row, - // but glued to the number, so no word of their own. - let cases: [(&[&str], &str); 6] = [ - (&["Rotation 90°"], "Définir"), - (&["Rotation 90°"], "Set 1"), - (&["Rotation 90°"], "Größe ändern"), - (&["Rotation 90°", "Arrière-plan"], "Définir"), - (&["Layer 90°é"], "Set 1"), - (&["Layer 90°é"], "Définir"), - ]; - for (words, hint) in cases { - let g = guess_words(MACINTOSH, words, hint); - assert_eq!(g, Guess { encoding: MACINTOSH, uncertain: false }, "{words:?} with hint {hint:?}"); - } - } - - #[test] - fn the_same_names_in_windows_1252_stay_windows_1252() { - for words in [&["Opacité 50%"][..], &["Höhe", "Grün", "Bär"], &["Rotation 90°"], &["Ändern"], &["Fläche m²"], &["Preis £5"]] { - assert_eq!(guess_words(WINDOWS_1252, words, "Set 1").encoding, WINDOWS_1252, "{words:?}"); - assert_eq!(guess_words(WINDOWS_1252, words, "Größe ändern"), Guess { encoding: WINDOWS_1252, uncertain: false }, "{words:?}"); - } - } - - #[test] - fn cyrillic_names_in_latin_hinted_files_and_cyrillic_words_beside_latin_ones_stay_1251() { - // A Cyrillic step name in a set with a French name reads as it did; a Cyrillic word beside - // a Latin one counts as Cyrillic, and the hint settles the tie with windows-1252's letters. - assert_eq!(guess_words(WINDOWS_1251, &["Ёлка"], "Définir"), Guess { encoding: WINDOWS_1251, uncertain: false }); - assert_eq!(guess_words(WINDOWS_1251, &["Layer Слой"], "Набор 1"), Guess { encoding: WINDOWS_1251, uncertain: false }); - assert_eq!(guess_words(WINDOWS_1251, &["Layer Слой1"], "Набор 1"), Guess { encoding: WINDOWS_1251, uncertain: false }, "a digit after the word"); - assert_eq!(guess_words(WINDOWS_1251, &["Layer Слой", "Ёлка"], "Set 1"), Guess { encoding: WINDOWS_1251, uncertain: false }); - // Alone and without a hint, "Layer Слой" is as ambiguous as before (every letter is one in - // windows-1252 too): the flagged fallback. - assert_eq!(guess_words(WINDOWS_1251, &["Layer Слой"], "Set 1"), Guess { encoding: WINDOWS_1252, uncertain: true }); - } - - #[test] - fn a_rare_letter_starting_a_word_keeps_windows_1252() { - // windows-1252 Š Ž Ÿ š ž (0x8A 0x8E 0x9F 0x9A 0x9E) are Mac Roman ä é ü ö û: one starting - // a word ("Škoda", all caps "ŠKODA", lowercase "šuma") counts, an accent inside a word (Mac - // "Opacité" as "OpacitŽ") does not. - for words in [&["Škoda"][..], &["ŠKODA"], &["Žaba"], &["Šibenik", "Žaba"], &["ŽUPA", "Set"], &["šuma"], &["žaba"]] { - assert_eq!(guess_words(WINDOWS_1252, words, "Nastavení"), Guess { encoding: WINDOWS_1252, uncertain: false }, "{words:?}"); - assert_eq!(guess_words(WINDOWS_1252, words, "Set 1").encoding, WINDOWS_1252, "{words:?}"); - } - // In a file whose own text writes ä and never Š (a German Photoshop), the byte reads as - // the Mac Roman ä it would be there: the file's alphabet decides what the bytes can't. - assert_eq!(guess_words(WINDOWS_1252, &["Škoda"], "Größe ändern").encoding, MACINTOSH); - assert_eq!(guess_words(WINDOWS_1252, &["šuma"], "Größe ändern").encoding, MACINTOSH, "the file writes ö and never š"); - } - - #[test] - fn an_inverted_exclamation_opening_an_exclamation_reads_as_windows_1252() { - // windows-1252 ¡ (0xA1) is Mac Roman °: ¡ opening an exclamation, two letters or more - // after it (accented ones too) and the closing "!" later ("¡Hola!", "¡Ánimo!"), counts for - // windows-1252 (review); after a number ("90°"), before a unit ("20 °C", "20 °Celsius") or - // without a "!" the byte stays the degree sign. - for words in [&["¡Hola!"][..], &["Capa ¡Atención!"], &["¡Ánimo!"]] { - assert_eq!(guess_words(WINDOWS_1252, words, "Configuración"), Guess { encoding: WINDOWS_1252, uncertain: false }, "{words:?}"); - assert_eq!(guess_words(WINDOWS_1252, words, "Set 1").encoding, WINDOWS_1252, "{words:?}"); - } - // (With a Latin hint: without any, a lone 0xA1 before an ASCII letter is also a Big5 - // punctuation pair, a tie the fallback flags.) - for words in [&["Rotation 90°"][..], &["Temperatur 20 °C"], &["20 °Celsius"]] { - for hint in ["Configuración", "Größe ändern"] { - assert_eq!(guess_words(MACINTOSH, words, hint), Guess { encoding: MACINTOSH, uncertain: false }, "{words:?} with hint {hint:?}"); - } - } - assert_eq!(guess_words(MACINTOSH, &["Rotation 90°"], "Set 1"), Guess { encoding: MACINTOSH, uncertain: false }); - } - - #[test] - fn a_lone_cyrillic_letter_after_a_digit_is_no_cyrillic_name() { - // Mac Roman "90°" alone has no ASCII letter, so its one windows-1251 letter (Ў) would make - // the name "mostly Cyrillic" (review): a lone Cyrillic letter right after a digit never - // counts. A one-letter Cyrillic name and a Russian unit after a digit still do. - for hint in ["Set 1", "Définir"] { - assert_eq!(guess_words(MACINTOSH, &["90°"], hint), Guess { encoding: MACINTOSH, uncertain: false }, "hint {hint:?}"); - } - assert_eq!(guess_words(WINDOWS_1251, &["Я"], "Набор 1"), Guess { encoding: WINDOWS_1251, uncertain: false }); - assert_eq!(guess_words(WINDOWS_1251, &["5шт"], "Набор 1"), Guess { encoding: WINDOWS_1251, uncertain: false }); - } - - #[test] - fn word_initial_mac_roman_accents_read_as_mac_roman_when_the_file_writes_them() { - // Mac é (0x8E) and ü (0x9F) are windows-1252 Ž and Ÿ, capitals that could start a word: - // in a file whose own text has é or ü and no Ž or Ÿ, they are the Mac Roman letters. - assert_eq!(guess_words(MACINTOSH, &["état"], "Définir"), Guess { encoding: MACINTOSH, uncertain: false }); - assert_eq!(guess_words(MACINTOSH, &["über Ebene", "Höhe"], "Größe ändern"), Guess { encoding: MACINTOSH, uncertain: false }); - // Without such text the capital reading stands, as before: the bytes alone can't tell. - assert_eq!(guess_words(MACINTOSH, &["éclair"], "Set 1").encoding, WINDOWS_1252); - } - - #[test] - fn cyrillic_names_with_latin_words_still_read_as_1251() { - // Mostly Cyrillic letters count; a Latin brand name or a Latin i in Ukrainian doesn't - // turn the set into something else. - let g = guess_words(WINDOWS_1251, &["CMYKканал", "Копiя", "Слой 1"], "Набор 1"); - assert_eq!(g, Guess { encoding: WINDOWS_1251, uncertain: false }); - let bytes = encoded(WINDOWS_1251, &["CMYKканал"]); - assert_eq!(decode(&bytes[0], g.encoding), "CMYKканал"); - } - - #[test] - fn symbols_on_mac_roman_letters_stay_ambiguous() { - // Known limitation: 1252 "Logo ®" is Mac Roman "Logo Æ", and ® is not counted for - // windows-1252 because Mac "Æble" and "Weiß" use those bytes, so the name reads as Mac - // Roman whatever the hint, as it did before the symbols counted at all. - let logo: &[&[u8]] = &[b"Logo \xae"]; - assert_eq!(guess(logo, "Größe ändern"), Guess { encoding: MACINTOSH, uncertain: false }); - assert_eq!(guess(logo, "Set 1"), Guess { encoding: MACINTOSH, uncertain: false }); - } - - #[test] - fn mac_roman_letters_still_read_as_mac_roman() { - // Mac Roman "Größe" is 1252 "Grš§e", "Ändern" "€ndern", "Contrôle" "Contr™le": Mac Roman - // still wins once § counts as common text in windows-1252. - // Mac Roman ß ø æ Æ are 1252 § ¿ ¾ ®, which windows-1252 must not count as common. - let words = [ - "Größe", - "Ändern", - "Contrôle", - "Weiß", - "Schwarzweiß", - "Gaußscher Weichzeichner", - "Schließen", - "Størrelse", - "Lærredstørrelse", - "Fjern støy", - "Æble", - ]; - for word in words { - let g = guess_words(MACINTOSH, &[word], "Définir l’arrière-plan"); - assert_eq!(g, Guess { encoding: MACINTOSH, uncertain: false }, "{word}"); - } - } - - #[test] - fn latin_hint_reads_mac_roman_when_1252_does_not_fit() { - // Mac Roman "Arrière-plan": è is 0x8F, a control character in windows-1252. - let names: &[&[u8]] = &[b"Arri\x8fre-plan"]; - let g = guess(names, "Définir l’arrière-plan"); - assert_eq!(g.encoding, MACINTOSH); - assert!(!g.uncertain); - assert_eq!(decode(names[0], g.encoding), "Arrière-plan"); - } - - #[test] - fn known_names_pick_the_code_page() { - // Every step of the corpus/atn sets that has a non-ASCII panel name also stores it as - // Unicode (the descriptor's class name); here, Mac Roman "Définir". - let pairs: &[(&[u8], &str)] = &[(b"D\x8efinir", "Définir")]; - assert_eq!(match_known(pairs), Some(MACINTOSH)); - // GBK 保存 is a three-way tie for the statistics; the known name settles it. - assert_eq!(match_known(&[(b"\xb1\xa3\xb4\xe6", "保存")]), Some(GBK)); - } - - #[test] - fn most_matching_names_win() { - let pairs: &[(&[u8], &str)] = &[(b"\xb1\xa3\xb4\xe6", "保存"), (b"D\x8efinir", "Définir"), (b"Arri\x8fre-plan", "Arrière-plan")]; - assert_eq!(match_known(pairs), Some(MACINTOSH)); - } - - #[test] - fn known_name_ties_take_candidate_order() { - // Curly quotes are 0x93/0x94 in both windows-1252 and windows-1251. - assert_eq!(match_known(&[(b"\x93x\x94", "“x”")]), Some(WINDOWS_1252)); - } - - #[test] - fn known_names_that_match_nothing_decide_nothing() { - assert_eq!(match_known(&[(b"D\x8efinir", "Set")]), None); - assert_eq!(match_known(&[]), None); - // Past the cap, pairs are not examined. - let mut pairs = vec![(&b"D\x8efinir"[..], "x"); KNOWN_PAIRS]; - pairs.push((b"\xb1\xa3\xb4\xe6", "保存")); - assert_eq!(match_known(&pairs), None); - } - - #[test] - fn bytes_no_cjk_page_accepts_still_decode() { - // 0xFF is invalid in every double-byte candidate; windows-1251 (Ђ я Ђ) and Mac Roman - // (Ä ˇ Ä) read all three bytes as letters, windows-1252 only one (€ ÿ €). With nothing to - // break the tie, the first highest-scoring candidate still decodes them. - let names: &[&[u8]] = &[b"\x80\xff\x80"]; - let g = guess(names, ""); - assert_eq!(g, Guess { encoding: WINDOWS_1251, uncertain: true }); - assert_eq!(decode(names[0], g.encoding), "ЂяЂ"); - assert_eq!(guess(names, "Набор").encoding, WINDOWS_1251); - } - - #[test] - fn uncertain_fallback_stays_among_the_highest_scoring_candidates() { - // Mac Roman "éäé" (0x8E 0x8A 0x8E) is windows-1251 "ЋЉЋ", three Cyrillic letters in a - // row, a word of their own: both pages score every non-ASCII byte as a letter (an odd - // count of bytes, so no double-byte page reads it). Windows-1252 reads "ŽŠŽ", no - // word-initial capital among them, so it must not win their unresolved tie. (Mac Roman - // "Ändern" no longer ties: its lone Cyrillic letter glued into a Latin word, 1251 - // "Ђndern", doesn't count, #10774.) - let names: &[&[u8]] = &[b"\x8e\x8a\x8e"]; - for hint in ["Set 1", "保存"] { - let g = guess(names, hint); - assert!([MACINTOSH, WINDOWS_1251].contains(&g.encoding), "{hint}: {g:?}"); - assert!(g.uncertain, "{hint}: {g:?}"); - assert_eq!(score(g.encoding, names[0], &Default::default()), (3, 3)); - } - assert_eq!(guess(&[b"\x80ndern"], "Set 1"), Guess { encoding: MACINTOSH, uncertain: false }, "Mac Roman \"Ändern\" is certain now"); - } - - #[test] - fn empty_or_utf8_input_is_utf8() { - let g = guess(&[], ""); - assert_eq!(g.encoding, UTF_8); - assert!(!g.uncertain); - } - - #[test] - fn mixed_code_pages_pick_one_and_never_fail() { - let names: &[&[u8]] = &[GBK_ISSUE[0], SJIS_COMMENT[0]]; - let g = guess(names, ""); - for name in names { - assert!(!decode(name, g.encoding).is_empty()); - } - } - - #[test] - fn huge_input_is_sampled() { - let names = vec![GBK_ISSUE[0]; 10_000]; - assert_eq!(guess(&names, "").encoding, GBK); - } - - #[test] - fn one_name_over_the_sample_cap_is_truncated_not_rejected() { - // "a" then 可 repeated past the cap, so the cut lands inside a two-byte character. - let mut name = b"a".to_vec(); - name.extend(b"\xbf\xc9".repeat(GUESS_SAMPLE / 2 + 1)); - assert_eq!(guess(&[&name], "可").encoding, GBK); - } - - #[test] - fn labels_resolve_and_reject() { - for ok in ["shift_jis", "gbk", "big5", "euc-kr", "windows-1252", "utf-8"] { - assert!(resolve_label(ok).is_ok(), "{ok}"); - } - for bad in ["nope", "replacement", "utf-16le", "x-user-defined", "", "iso-2022-kr", "iso-2022-jp", "csISO2022JP"] { - let Err(e) = resolve_label(bad) else { panic!("{bad} should be rejected") }; - assert!(e.to_string().contains("shift_jis"), "{e}"); - } - } - - #[test] - fn labels_match_loosely() { - assert_eq!(resolve_label("Shift_JIS").ok(), Some(SHIFT_JIS)); - assert_eq!(resolve_label(" gbk ").ok(), Some(GBK)); - assert_eq!(resolve_label("gb2312").ok(), Some(GBK)); - } - #[test] - fn a_name_of_one_long_letter_run_is_scored_in_linear_time() { - // 64 KiB of one letter (é in windows-1252, й in windows-1251, Mac Roman's È), of - // alternating Cyrillic letters, and of "¡ab" with no "!": every character used to rescan - // the rest of the name, which took minutes. - for unit in [&[0xe9u8][..], &[0xe9, 0xea][..], &[0xa1, b'a', b'b'][..]] { - let name = unit.repeat(GUESS_SAMPLE / unit.len()); - let started = std::time::Instant::now(); - let _ = guess(&[&name], ""); - assert!(started.elapsed() < std::time::Duration::from_secs(5), "{:?} took {:?}", unit, started.elapsed()); - } - } -} diff --git a/crates/psd/src/compression.rs b/crates/psd/src/compression.rs deleted file mode 100644 index 513dda76ba..0000000000 --- a/crates/psd/src/compression.rs +++ /dev/null @@ -1,679 +0,0 @@ -//! Channel image data codecs: Raw, RLE (PackBits), ZIP, and ZIP with prediction. -//! -//! All decoded data is *planar* bytes in the file's native big-endian sample -//! order: `planes × height × row_bytes` where `row_bytes` depends on depth -//! (depth 1 packs 8 pixels per byte, MSB first). - -use std::io::{Read, Write}; - -use crate::error::{PsdError, Result}; -use crate::header::{Version, row_bytes}; -use crate::io::Reader; - -/// Maximum number of decoded bytes a single decode call may produce: 8 GiB on -/// 64-bit targets, 2 GiB elsewhere (the same budget as `photocraft-codecs`' -/// default `Limits::max_alloc`). -/// -/// The decoders bound their output by their input on their own (RLE expands -/// at most 64x, Raw needs every byte present), so this is a backstop against -/// decompression bombs, not the main guard. It has to fit real PSBs: a -/// 30000² RGB merged image is 2.7 GB, and a 33000² 16-bit layer channel is -/// 2.2 GB, both of which a 2 GiB cap turned into empty layers (#375). -pub const MAX_DECODED_BYTES: u64 = if cfg!(target_pointer_width = "64") { 8 << 30 } else { 2 << 30 }; - -/// Compression method stored before channel / image data. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub enum Compression { - /// 0: uncompressed. - Raw, - /// 1: PackBits RLE with a per-row byte-count table. - Rle, - /// 2: zlib without prediction. - Zip, - /// 3: zlib with horizontal delta prediction. - ZipPrediction, - /// Unknown method; data is preserved but cannot be decoded. - Unknown(u16), -} - -impl Compression { - /// All four decodable methods. - pub const ALL: [Compression; 4] = [Compression::Raw, Compression::Rle, Compression::Zip, Compression::ZipPrediction]; - - /// From the stored value. - pub fn from_u16(v: u16) -> Self { - match v { - 0 => Compression::Raw, - 1 => Compression::Rle, - 2 => Compression::Zip, - 3 => Compression::ZipPrediction, - v => Compression::Unknown(v), - } - } - /// Stored value. - pub fn as_u16(self) -> u16 { - match self { - Compression::Raw => 0, - Compression::Rle => 1, - Compression::Zip => 2, - Compression::ZipPrediction => 3, - Compression::Unknown(v) => v, - } - } -} - -/// PackBits (Apple / TIFF) run-length coding as used by Photoshop RLE. -pub mod packbits { - use crate::error::{PsdError, Result}; - - /// Encodes one row, appending to `out`. Never emits the `-128` no-op. - pub fn encode(src: &[u8], out: &mut Vec) { - let n = src.len(); - let mut i = 0; - while i < n { - // Length of the run starting at i. - let mut run = 1; - while i + run < n && run < 128 && src[i + run] == src[i] { - run += 1; - } - // A literal never stops before a 2-byte run, so any run of >= 2 - // seen here starts a packet boundary: a run packet is never larger. - if run >= 2 { - out.push((1i16 - run as i16) as i8 as u8); - out.push(src[i]); - i += run; - continue; - } - // Literal: extend until a run of >= 3 begins or 128 bytes. - let start = i; - let mut len = 0; - while i < n && len < 128 { - if i + 2 < n && src[i] == src[i + 1] && src[i] == src[i + 2] { - break; - } - i += 1; - len += 1; - } - out.push((len - 1) as u8); - out.extend_from_slice(&src[start..start + len]); - } - } - - /// Returns the encoded form of `src`. - pub fn encode_vec(src: &[u8]) -> Vec { - let mut v = Vec::with_capacity(src.len() + src.len() / 128 + 1); - encode(src, &mut v); - v - } - - /// Decodes `src` into exactly `expected` bytes appended to `out`. - /// - /// Errors if the input is exhausted early or a packet would overflow the - /// row. Trailing input after the row is complete is ignored except for - /// `-128` no-op bytes (which are always permitted). Errors, rather than - /// overflowing, when `out.len() + expected` does not fit in `usize`. - pub(crate) fn decode_into(src: &[u8], expected: usize, out: &mut Vec) -> Result<()> { - let target = out.len().checked_add(expected).ok_or(PsdError::LimitExceeded("PackBits output length overflows usize"))?; - let mut i = 0; - while out.len() < target { - let Some(&h) = src.get(i) else { - return Err(PsdError::Decompress("PackBits row ended early".into())); - }; - i += 1; - let h = h as i8; - if h >= 0 { - let len = h as usize + 1; - let Some(lit) = src.get(i..i + len) else { - return Err(PsdError::Decompress("PackBits literal truncated".into())); - }; - if len > target - out.len() { - return Err(PsdError::Decompress("PackBits literal overflows row".into())); - } - out.extend_from_slice(lit); - i += len; - } else if h != -128 { - let len = 1 - h as isize; - let len = len as usize; - let Some(&b) = src.get(i) else { - return Err(PsdError::Decompress("PackBits run truncated".into())); - }; - i += 1; - if len > target - out.len() { - return Err(PsdError::Decompress("PackBits run overflows row".into())); - } - out.extend(std::iter::repeat_n(b, len)); - } - } - Ok(()) - } - - /// Decodes into a new vector of exactly `expected` bytes. - pub fn decode(src: &[u8], expected: usize) -> Result> { - let mut v = Vec::with_capacity(expected.min(src.len().saturating_mul(64))); - decode_into(src, expected, &mut v)?; - Ok(v) - } -} - -/// Geometry of a set of planes to encode/decode. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct PlaneLayout { - /// Number of planes (1 for a layer channel, channel count for the merged image). - pub planes: usize, - /// Pixels per row. - pub width: usize, - /// Rows per plane. - pub height: usize, - /// Bits per sample (1, 8, 16, 32). - pub depth: u16, - /// PSD vs PSB (affects RLE count width). - pub version: Version, -} - -impl PlaneLayout { - /// Bytes per row. - pub fn row_bytes(&self) -> usize { - row_bytes(self.width, self.depth) - } - /// Total rows over all planes. - pub fn rows(&self) -> Result { - self.planes.checked_mul(self.height).ok_or(PsdError::LimitExceeded("row count overflow")) - } - /// Total decoded bytes without the [`MAX_DECODED_BYTES`] cap. - pub fn total_bytes(&self) -> Result { - (self.planes as u64) - .checked_mul(self.height as u64) - .and_then(|v| v.checked_mul(self.row_bytes() as u64)) - .ok_or(PsdError::LimitExceeded("decoded size overflow")) - } - - /// Total decoded bytes, checked against [`MAX_DECODED_BYTES`]. - pub fn decoded_len(&self) -> Result { - let total = self.total_bytes()?; - if total > MAX_DECODED_BYTES { - return Err(PsdError::LimitExceeded("decoded channel data exceeds MAX_DECODED_BYTES")); - } - usize::try_from(total).map_err(|_| PsdError::LimitExceeded("decoded size exceeds address space")) - } - fn count_size(&self) -> usize { - if self.version.is_psb() { 4 } else { 2 } - } -} - -/// Decodes planar data (excluding the 2-byte compression field). -pub fn decode_planes(compression: Compression, data: &[u8], layout: &PlaneLayout) -> Result> { - let total = layout.decoded_len()?; - match compression { - Compression::Raw => { - if data.len() < total { - return Err(PsdError::UnexpectedEof { offset: data.len(), needed: total - data.len() }); - } - let mut out = Vec::new(); - out.try_reserve_exact(total).map_err(|_| PsdError::OutOfMemory("not enough memory for the decoded channel data"))?; - out.extend_from_slice(&data[..total]); - Ok(out) - } - Compression::Rle => decode_rle(data, layout, total), - Compression::Zip => inflate_checked(data, total as u64, true), - Compression::ZipPrediction => { - let mut v = inflate_checked(data, total as u64, true)?; - unpredict(&mut v, layout)?; - Ok(v) - } - Compression::Unknown(c) => Err(PsdError::Unsupported(format!("compression method {c}"))), - } -} - -/// Bytes a PSD RLE stream (two-byte row counts) of `rows` rows grows by when written as PSB -/// (four-byte row counts); an error when `data` is too short to hold the count table. -pub(crate) fn rle_widening(data: &[u8], rows: usize) -> Result { - rle_widening_len(data.len() as u64, rows) -} - -/// [`rle_widening`] from the stream's length alone, so data still in its source (or staged on -/// the scratch disk) is not read to measure it. -pub(crate) fn rle_widening_len(len: u64, rows: usize) -> Result { - let table = rows.checked_mul(2).ok_or(PsdError::LimitExceeded("row count overflow"))? as u64; - if len < table { - let offset = usize::try_from(len).unwrap_or(usize::MAX); - return Err(PsdError::UnexpectedEof { offset, needed: usize::try_from(table - len).unwrap_or(usize::MAX) }); - } - Ok(table) -} - -/// Encodes planar data (excluding the 2-byte compression field). -pub fn encode_planes(compression: Compression, decoded: &[u8], layout: &PlaneLayout) -> Result> { - let total = layout.decoded_len()?; - if decoded.len() != total { - return Err(PsdError::invalid(format!("decoded buffer has {} bytes, layout requires {}", decoded.len(), total))); - } - match compression { - Compression::Raw => Ok(decoded.to_vec()), - Compression::Rle => encode_rle(decoded, layout), - Compression::Zip => Ok(zip_compress(decoded)), - Compression::ZipPrediction => { - let mut v = decoded.to_vec(); - predict(&mut v, layout)?; - Ok(zip_compress(&v)) - } - Compression::Unknown(c) => Err(PsdError::Unsupported(format!("compression method {c}"))), - } -} - -/// Validates encoded sizes and, for ZIP, the complete stream and checksum. -/// -/// A ZIP stream whose declared size is over [`MAX_DECODED_BYTES`] is not -/// decompressed at all (#2926): [`decode_planes`] refuses that layout with -/// `LimitExceeded` anyway, so inflating it here would only be work on an -/// untrusted file before the same error. The bytes are kept verbatim, like an -/// unknown compression method, so a layered file whose merged composite is -/// too large still opens. -pub(crate) fn validate_planes(compression: Compression, data: &[u8], layout: &PlaneLayout) -> Result<()> { - let total = layout.total_bytes()?; - match compression { - Compression::Raw => { - if (data.len() as u64) < total { - return Err(PsdError::UnexpectedEof { offset: data.len(), needed: usize::try_from(total - data.len() as u64).unwrap_or(usize::MAX) }); - } - Ok(()) - } - Compression::Rle => { - let rows = layout.rows()?; - let mut r = Reader::new(data); - r.check_count(rows as u64, layout.count_size() as u64)?; - let mut sum: u64 = 0; - for _ in 0..rows { - sum += read_count(&mut r, layout.version)? as u64; - } - if sum > r.remaining() as u64 { - return Err(PsdError::UnexpectedEof { offset: data.len(), needed: usize::try_from(sum - r.remaining() as u64).unwrap_or(usize::MAX) }); - } - Ok(()) - } - Compression::Zip | Compression::ZipPrediction if total > MAX_DECODED_BYTES => Ok(()), - Compression::Zip | Compression::ZipPrediction => inflate_checked(data, total, false).map(|_| ()), - Compression::Unknown(_) => Ok(()), - } -} - -#[cfg(test)] -thread_local! { - /// ZIP streams inflated on this thread (tests count passes over the merged image). - pub(crate) static INFLATES: std::cell::Cell = const { std::cell::Cell::new(0) }; -} - -// Keep only the requested pixels, but consume the trailer to verify Adler-32. -// A bounded surplus tolerates padding without unbounded decompression work. -fn inflate_checked(data: &[u8], expected: u64, keep: bool) -> Result> { - const OOM: PsdError = PsdError::OutOfMemory("not enough memory for the decoded channel data"); - #[cfg(test)] - INFLATES.with(|n| n.set(n.get() + 1)); - let mut decoder = flate2::Decompress::new(true); - let mut out = Vec::new(); - if keep { - out.try_reserve_exact(expected.min(data.len().saturating_mul(1032) as u64) as usize).map_err(|_| OOM)?; - } - let mut buf = vec![0; expected.saturating_add(1).clamp(8192, 1 << 20) as usize]; - loop { - let before = (decoder.total_in(), decoder.total_out()); - let input = data.get(before.0 as usize..).ok_or_else(|| PsdError::Decompress("zlib input overflow".into()))?; - let status = decoder.decompress(input, &mut buf, flate2::FlushDecompress::None).map_err(|e| PsdError::Decompress(e.to_string()))?; - let got = decoder.total_out(); - if got > expected.saturating_add(1 << 20) { - return Err(PsdError::LimitExceeded("zlib output exceeds the declared size and padding allowance")); - } - if keep { - let n = (got - before.1).min(expected.saturating_sub(out.len() as u64)) as usize; - out.try_reserve(n).map_err(|_| OOM)?; - out.extend_from_slice(buf.get(..n).ok_or_else(|| PsdError::Decompress("zlib output overflow".into()))?); - } - if status == flate2::Status::StreamEnd { - if got < expected { - return Err(PsdError::Decompress(format!("zlib stream produced {got} of {expected} bytes"))); - } - return Ok(out); - } - if before == (decoder.total_in(), got) { - return Err(PsdError::Decompress("incomplete zlib stream".into())); - } - } -} - -fn read_count(r: &mut Reader<'_>, v: Version) -> Result { - Ok(if v.is_psb() { r.u32()? as usize } else { r.u16()? as usize }) -} - -fn decode_rle(data: &[u8], layout: &PlaneLayout, total: usize) -> Result> { - let rows = layout.rows()?; - let rb = layout.row_bytes(); - let mut r = Reader::new(data); - r.check_count(rows as u64, layout.count_size() as u64)?; - let mut counts = Vec::with_capacity(rows); - for _ in 0..rows { - counts.push(read_count(&mut r, layout.version)?); - } - // PackBits expands at most 64x (2 input bytes -> 128 output bytes). - if (total as u64) > (r.remaining() as u64).saturating_mul(64) { - return Err(PsdError::Decompress("RLE data too short for declared size".into())); - } - // Up to MAX_DECODED_BYTES: report a machine without that much memory instead of aborting. - let mut out = Vec::new(); - out.try_reserve_exact(total).map_err(|_| PsdError::OutOfMemory("not enough memory for the decoded channel data"))?; - for c in counts { - let row = r.bytes(c)?; - packbits::decode_into(row, rb, &mut out)?; - } - Ok(out) -} - -fn encode_rle(decoded: &[u8], layout: &PlaneLayout) -> Result> { - let rows = layout.rows()?; - let rb = layout.row_bytes(); - let cs = layout.count_size(); - if rows == 0 { - return Ok(Vec::new()); - } - // Rows are independent, so they pack in parallel chunks of rows. Two passes: the first only - // measures each packed row, so the output is allocated once at its exact size and the second - // packs every row straight into its place. Collecting packed chunks and then copying them into - // the output held the encoded image twice, with growth slack, at the save's peak (#6607). - let threads = if cfg!(target_arch = "wasm32") { 1 } else { std::thread::available_parallelism().map_or(1, |v| v.get()).clamp(1, 32) }; - let chunk_rows = rows.div_ceil(threads).max(1); - let row = |r: usize| &decoded[r * rb..(r + 1) * rb]; - let mut lens = vec![0usize; rows]; - for_each_chunk(threads, lens.chunks_mut(chunk_rows).enumerate().collect(), |(c, lens): (usize, &mut [usize])| { - let mut scratch = Vec::with_capacity(rb + rb.div_ceil(128)); - for (i, len) in lens.iter_mut().enumerate() { - scratch.clear(); - packbits::encode(row(c * chunk_rows + i), &mut scratch); - *len = scratch.len(); - } - })?; - let mut out = vec![0u8; rows * cs + lens.iter().sum::()]; - let (counts, mut packed) = out.split_at_mut(rows * cs); - for (count, &n) in counts.chunks_exact_mut(cs).zip(&lens) { - if cs == 2 { - let n = u16::try_from(n).map_err(|_| PsdError::LimitExceeded("RLE row exceeds 65535 bytes (use PSB)"))?; - count.copy_from_slice(&n.to_be_bytes()); - } else { - let n = u32::try_from(n).map_err(|_| PsdError::LimitExceeded("RLE row too large"))?; - count.copy_from_slice(&n.to_be_bytes()); - } - } - let mut parts = Vec::with_capacity(threads); - for (c, lens) in lens.chunks(chunk_rows).enumerate() { - let (part, rest) = packed.split_at_mut(lens.iter().sum()); - parts.push((c, lens, part)); - packed = rest; - } - for_each_chunk(threads, parts, |(c, lens, mut part): (usize, &[usize], &mut [u8])| { - let mut scratch = Vec::with_capacity(rb + rb.div_ceil(128)); - for (i, &n) in lens.iter().enumerate() { - scratch.clear(); - packbits::encode(row(c * chunk_rows + i), &mut scratch); - let (dst, rest) = std::mem::take(&mut part).split_at_mut(n); - dst.copy_from_slice(&scratch); - part = rest; - } - })?; - Ok(out) -} - -/// Runs `f` on every item, each on its own thread unless `threads` is 1 (wasm32). -fn for_each_chunk(threads: usize, items: Vec, f: impl Fn(T) + Sync) -> Result<()> { - if threads == 1 { - items.into_iter().for_each(f); - return Ok(()); - } - std::thread::scope(|scope| { - let handles: Vec<_> = items.into_iter().map(|item| scope.spawn(|| f(item))).collect(); - // Join every worker instead of aborting the scope with a thread panic. - let mut ok = true; - for h in handles { - ok &= h.join().is_ok(); - } - if ok { Ok(()) } else { Err(PsdError::LimitExceeded("an RLE worker panicked")) } - }) -} - -/// zlib-compresses `data` at the default level. -pub fn zip_compress(data: &[u8]) -> Vec { - let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); - // Writing into a Vec cannot fail. - let _ = e.write_all(data); - e.finish().unwrap_or_default() -} - -/// Decompresses a zlib stream, requiring at least `expected` output bytes and -/// returning exactly `expected`. -/// -/// The output is reserved fallibly and read in chunks, never past `expected` -/// bytes, so a stream declaring a huge size reports an error on a machine -/// without that much memory instead of aborting. -pub fn zip_decompress(data: &[u8], expected: usize) -> Result> { - const OOM: PsdError = PsdError::OutOfMemory("not enough memory for the decoded channel data"); - const CHUNK: usize = 1 << 20; - let mut dec = flate2::read::ZlibDecoder::new(data).take(u64::try_from(expected).unwrap_or(u64::MAX)); - let mut out = Vec::new(); - // Deflate expands at most ~1032:1, so this is the most a valid stream can need. - out.try_reserve_exact(expected.min(data.len().saturating_mul(1032))).map_err(|_| OOM)?; - let mut buf = vec![0u8; CHUNK.min(expected).max(1)]; - loop { - let n = match dec.read(&mut buf) { - Ok(0) => break, - Ok(n) => n, - Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue, - Err(e) => return Err(PsdError::Decompress(e.to_string())), - }; - let chunk = buf.get(..n).ok_or_else(|| PsdError::Decompress("zlib reader overran its buffer".into()))?; - out.try_reserve(n).map_err(|_| OOM)?; - out.extend_from_slice(chunk); - } - if out.len() < expected { - return Err(PsdError::Decompress(format!("zlib stream produced {} of {} bytes", out.len(), expected))); - } - Ok(out) -} - -/// Applies horizontal delta prediction in place (encoder side). -/// -/// * 1/8-bit: byte-wise delta per row. -/// * 16-bit: big-endian u16 sample delta per row. -/// * 32-bit: each row's samples are split into four byte planes (MSB plane -/// first) and the resulting row is byte-wise delta coded, as Photoshop does. -pub fn predict(buf: &mut [u8], layout: &PlaneLayout) -> Result<()> { - let rb = layout.row_bytes(); - if rb == 0 { - return Ok(()); - } - let w = layout.width; - let mut scratch = Vec::new(); - for row in buf.chunks_exact_mut(rb) { - match layout.depth { - 1 | 8 => { - for i in (1..row.len()).rev() { - row[i] = row[i].wrapping_sub(row[i - 1]); - } - } - 16 => { - // One forward pass: the previous *original* sample stays in a - // register, so nothing is re-read after being overwritten. - let mut prev = 0u16; - for s in row.as_chunks_mut::<2>().0 { - let cur = u16::from_be_bytes(*s); - *s = cur.wrapping_sub(prev).to_be_bytes(); - prev = cur; - } - } - 32 => { - scratch.clear(); - scratch.resize(rb, 0); - // Split into four byte planes (MSB first) and delta the whole - // plane-major row. Plane k's first delta uses the last byte of - // plane k - 1, so seed each plane's "previous" with it. - let mut prev = [0u8; 4]; - if let Some(l) = row.as_chunks::<4>().0.last() { - prev = [0, l[0], l[1], l[2]]; - } - let (p0, rest) = scratch.split_at_mut(w); - let (p1, rest) = rest.split_at_mut(w); - let (p2, p3) = rest.split_at_mut(w); - let planes = p0.iter_mut().zip(p1.iter_mut()).zip(p2.iter_mut().zip(p3.iter_mut())); - for (s, ((o0, o1), (o2, o3))) in row.as_chunks::<4>().0.iter().zip(planes) { - *o0 = s[0].wrapping_sub(prev[0]); - *o1 = s[1].wrapping_sub(prev[1]); - *o2 = s[2].wrapping_sub(prev[2]); - *o3 = s[3].wrapping_sub(prev[3]); - prev = [s[0], s[1], s[2], s[3]]; - } - row.copy_from_slice(&scratch); - } - d => return Err(PsdError::Unsupported(format!("prediction for depth {d}"))), - } - } - Ok(()) -} - -/// Reverses [`predict`] in place (decoder side). -pub fn unpredict(buf: &mut [u8], layout: &PlaneLayout) -> Result<()> { - let rb = layout.row_bytes(); - if rb == 0 { - return Ok(()); - } - let w = layout.width; - let mut scratch = Vec::new(); - for row in buf.chunks_exact_mut(rb) { - match layout.depth { - 1 | 8 => { - for i in 1..row.len() { - row[i] = row[i].wrapping_add(row[i - 1]); - } - } - 16 => { - // Running sum carried in a register; one pass over the row. - let mut acc = 0u16; - for s in row.as_chunks_mut::<2>().0 { - acc = acc.wrapping_add(u16::from_be_bytes(*s)); - *s = acc.to_be_bytes(); - } - } - 32 => { - scratch.clear(); - scratch.extend_from_slice(row); - // The byte-wise running sum runs across the four planes, so - // plane k starts at the wrapping total of planes 0..k. - let (p0, rest) = scratch.split_at(w); - let (p1, rest) = rest.split_at(w); - let (p2, p3) = rest.split_at(w); - let sum = |p: &[u8]| p.iter().fold(0u8, |a, &b| a.wrapping_add(b)); - let b1 = sum(p0); - let b2 = b1.wrapping_add(sum(p1)); - let b3 = b2.wrapping_add(sum(p2)); - let mut acc = [0u8, b1, b2, b3]; - let planes = p0.iter().zip(p1).zip(p2.iter().zip(p3)); - for (o, ((&s0, &s1), (&s2, &s3))) in row.as_chunks_mut::<4>().0.iter_mut().zip(planes) { - acc[0] = acc[0].wrapping_add(s0); - acc[1] = acc[1].wrapping_add(s1); - acc[2] = acc[2].wrapping_add(s2); - acc[3] = acc[3].wrapping_add(s3); - *o = acc; - } - } - d => return Err(PsdError::Unsupported(format!("prediction for depth {d}"))), - } - } - Ok(()) -} - -#[cfg(test)] -mod tests; - -#[cfg(test)] -mod parallel_rle_equivalence { - use super::*; - - /// The parallel row packer's output must be byte-equal to a straightforward sequential - /// encoding of the same plane, across depths, row counts around the chunking and data - /// shapes (runs, noise, all-default). - #[test] - fn parallel_rle_matches_sequential() { - let mut data: u64 = 0x9e37_79b9_7f4a_7c15; - let mut next = move || { - data ^= data << 13; - data ^= data >> 7; - data ^= data << 17; - data - }; - let layout_for = |rows: usize, rb: usize, version: Version| PlaneLayout { planes: 1, width: rb, height: rows, depth: 8, version }; - for (rows, rb, version) in [ - (0usize, 4usize, Version::Psd), - (1, 4, Version::Psd), - (7, 4, Version::Psb), - (64, 4, Version::Psd), - (65, 4, Version::Psb), // chunk boundary - (513, 4, Version::Psd), // more rows than threads on most runners - (2049, 4, Version::Psb), - (2, 4, Version::Psd), // fewer rows than threads - (33, 3000, Version::Psd), - (31, 20000, Version::Psb), - ] { - let pattern: Vec = match rows % 3 { - 0 => vec![7u8; rows * rb], // long runs - 1 => (0..rows * rb).map(|i| (i * 31 % 251) as u8).collect(), // noise-ish - _ => vec![0u8; rows * rb], // all default - }; - let pattern = if pattern.len() != rows * rb { vec![7u8; rows * rb] } else { pattern }; - let mut data = pattern.clone(); - if rows % 4 == 0 { - // sprinkle runs into the noise - for chunk in data.chunks_mut(97) { - if let Some(f) = chunk.first_mut() { - *f = 200; - } - } - } - let _ = next(); - let layout = layout_for(rows, rb, version); - let parallel = encode_rle(&data, &layout).expect("parallel encode"); - let sequential = encode_rle_sequential(&data, &layout).expect("sequential encode"); - assert_eq!(parallel, sequential, "rows {rows} rb {rb}"); - // #6607: measured first, then packed in place: one allocation, exactly the file's size. - assert_eq!(parallel.capacity(), parallel.len(), "rows {rows} rb {rb}: the output grew or was over-allocated"); - } - } - - /// A row too long for a PSD's 16-bit RLE count is the same error from the two-pass encoder. - #[test] - fn rle_rows_past_sixteen_bits_are_refused_in_psd() { - let layout_for = |rows: usize, rb: usize, version: Version| PlaneLayout { planes: 1, width: rb, height: rows, depth: 8, version }; - let rb = 70_000; - let layout = layout_for(3, rb, Version::Psd); - let noise: Vec = (0..3 * rb).map(|i| (i * 31 % 251) as u8).collect(); - assert_eq!(encode_rle(&noise, &layout), Err(PsdError::LimitExceeded("RLE row exceeds 65535 bytes (use PSB)"))); - assert_eq!(encode_rle(&noise, &layout_for(3, rb, Version::Psb)), encode_rle_sequential(&noise, &layout_for(3, rb, Version::Psb))); - } - - /// The pre-parallel encoder, kept as the oracle. - fn encode_rle_sequential(decoded: &[u8], layout: &PlaneLayout) -> Result> { - let rows = layout.rows()?; - let rb = layout.row_bytes(); - let cs = layout.count_size(); - let mut out = vec![0u8; rows * cs]; - let mut enc = Vec::new(); - for row in 0..rows { - let start = enc.len(); - packbits::encode(&decoded[row * rb..(row + 1) * rb], &mut enc); - let n = enc.len() - start; - if cs == 2 { - let n = u16::try_from(n).map_err(|_| PsdError::LimitExceeded("RLE row exceeds 65535 bytes (use PSB)"))?; - out[row * 2..row * 2 + 2].copy_from_slice(&n.to_be_bytes()); - } else { - let n = u32::try_from(n).map_err(|_| PsdError::LimitExceeded("RLE row too large"))?; - out[row * 4..row * 4 + 4].copy_from_slice(&n.to_be_bytes()); - } - } - out.extend_from_slice(&enc); - Ok(out) - } -} diff --git a/crates/psd/src/compression/tests.rs b/crates/psd/src/compression/tests.rs deleted file mode 100644 index 47d7986290..0000000000 --- a/crates/psd/src/compression/tests.rs +++ /dev/null @@ -1,465 +0,0 @@ -use super::*; - -/// The straightforward index-based versions, kept as the oracle for the -/// register-carried rewrites in [`predict`] and [`unpredict`]. -mod reference { - use super::super::*; - - pub fn predict(buf: &mut [u8], layout: &PlaneLayout) { - let rb = layout.row_bytes(); - let w = layout.width; - let mut scratch = Vec::new(); - for row in buf.chunks_exact_mut(rb) { - match layout.depth { - 8 => { - for i in (1..row.len()).rev() { - row[i] = row[i].wrapping_sub(row[i - 1]); - } - } - 16 => { - for i in (1..w).rev() { - let a = u16::from_be_bytes([row[2 * i], row[2 * i + 1]]); - let b = u16::from_be_bytes([row[2 * i - 2], row[2 * i - 1]]); - row[2 * i..2 * i + 2].copy_from_slice(&a.wrapping_sub(b).to_be_bytes()); - } - } - _ => { - scratch.clear(); - scratch.resize(rb, 0); - for i in 0..w { - for k in 0..4 { - scratch[k * w + i] = row[4 * i + k]; - } - } - for i in (1..rb).rev() { - scratch[i] = scratch[i].wrapping_sub(scratch[i - 1]); - } - row.copy_from_slice(&scratch); - } - } - } - } - - pub fn unpredict(buf: &mut [u8], layout: &PlaneLayout) { - let rb = layout.row_bytes(); - let w = layout.width; - let mut scratch = Vec::new(); - for row in buf.chunks_exact_mut(rb) { - match layout.depth { - 8 => { - for i in 1..row.len() { - row[i] = row[i].wrapping_add(row[i - 1]); - } - } - 16 => { - for i in 1..w { - let a = u16::from_be_bytes([row[2 * i], row[2 * i + 1]]); - let b = u16::from_be_bytes([row[2 * i - 2], row[2 * i - 1]]); - row[2 * i..2 * i + 2].copy_from_slice(&a.wrapping_add(b).to_be_bytes()); - } - } - _ => { - for i in 1..rb { - row[i] = row[i].wrapping_add(row[i - 1]); - } - scratch.clear(); - scratch.extend_from_slice(row); - for i in 0..w { - for k in 0..4 { - row[4 * i + k] = scratch[k * w + i]; - } - } - } - } - } - } -} - -#[test] -fn prediction_matches_reference_on_random_planes() { - let mut state = 0x9E37_79B9_7F4A_7C15u64; - let mut next = move || { - state ^= state << 13; - state ^= state >> 7; - state ^= state << 17; - state - }; - for depth in [8u16, 16, 32] { - for width in (1..=70).chain([255, 256, 257, 1000, 1023]) { - for height in [1usize, 2, 3] { - for fill in 0..4 { - let layout = PlaneLayout { planes: 1, width, height, depth, version: Version::Psd }; - let len = layout.row_bytes() * height; - let src: Vec = (0..len) - .map(|_| match fill { - 0 => next() as u8, - 1 => 0xFF, - 2 => [0x00, 0xFF, 0x80, 0x7F][(next() & 3) as usize], - _ => (next() as u8) | 0xF0, - }) - .collect(); - let (mut want, mut got) = (src.clone(), src.clone()); - reference::predict(&mut want, &layout); - predict(&mut got, &layout).unwrap(); - assert_eq!(got, want, "predict depth {depth} width {width} height {height} fill {fill}"); - let (mut want, mut got) = (src.clone(), src); - reference::unpredict(&mut want, &layout); - unpredict(&mut got, &layout).unwrap(); - assert_eq!(got, want, "unpredict depth {depth} width {width} height {height} fill {fill}"); - } - } - } - } -} - -#[test] -fn checked_zip_validates_trailer_size_and_padding_budget() { - let layout = PlaneLayout { planes: 1, width: 3, height: 1, depth: 8, version: Version::Psd }; - for compression in [Compression::Zip, Compression::ZipPrediction] { - let data = encode_planes(compression, &[1, 2, 3], &layout).unwrap(); - for cut in 0..data.len() { - assert!(decode_planes(compression, &data[..cut], &layout).is_err(), "cut {cut}"); - assert!(validate_planes(compression, &data[..cut], &layout).is_err()); - } - let mut corrupt = data; - *corrupt.last_mut().unwrap() ^= 1; - assert!(decode_planes(compression, &corrupt, &layout).is_err()); - assert!(validate_planes(compression, &corrupt, &layout).is_err()); - } - let padded = zip_compress(&[1, 2, 3, 0]); - assert_eq!(decode_planes(Compression::Zip, &padded, &layout).unwrap(), [1, 2, 3]); - let bomb = zip_compress(&vec![0; (1 << 20) + 4]); - assert!(decode_planes(Compression::Zip, &bomb, &layout).is_err()); - assert!(validate_planes(Compression::Zip, &bomb, &layout).is_err()); -} - -fn layout(planes: usize, width: usize, height: usize, depth: u16, version: Version) -> PlaneLayout { - PlaneLayout { planes, width, height, depth, version } -} - -fn pb_roundtrip(src: &[u8]) -> Vec { - let enc = packbits::encode_vec(src); - let dec = packbits::decode(&enc, src.len()).unwrap(); - assert_eq!(dec, src, "encoded: {enc:?}"); - enc -} - -#[test] -fn zip_absurd_declared_size_is_an_error() { - let z = zip_compress(&[7u8; 64]); - for expected in [1usize << 40, usize::MAX / 2, usize::MAX] { - assert!(zip_decompress(&z, expected).is_err(), "expected {expected}"); - } - // Through decode_planes: a tiny ZIP channel claiming a huge (but capped) plane. - let l = layout(1, 1 << 16, 1 << 15, 8, Version::Psb); - assert!(decode_planes(Compression::Zip, &z, &l).is_err()); - assert!(decode_planes(Compression::ZipPrediction, &z, &l).is_err()); - // Past MAX_DECODED_BYTES. - let l = layout(4, 1 << 20, 1 << 20, 32, Version::Psb); - assert!(decode_planes(Compression::Zip, &z, &l).is_err()); - // Garbage that is not zlib at all. - assert!(zip_decompress(&[0xff; 16], 1 << 30).is_err()); -} - -#[test] -fn zip_decompress_exact_and_truncated() { - let src: Vec = (0..3_000_000u32).map(|i| (i % 251) as u8).collect(); - let z = zip_compress(&src); - assert_eq!(zip_decompress(&z, src.len()).unwrap(), src); - // Asking for fewer bytes returns exactly that many. - assert_eq!(zip_decompress(&z, 1000).unwrap(), &src[..1000]); - assert!(zip_decompress(&z, src.len() + 1).is_err()); - assert!(zip_decompress(&z, 0).unwrap().is_empty()); -} - -#[test] -fn packbits_empty() { - assert!(pb_roundtrip(&[]).is_empty()); -} - -#[test] -fn packbits_single() { - assert_eq!(pb_roundtrip(&[7]), vec![0, 7]); -} - -#[test] -fn packbits_run_of_two() { - assert_eq!(pb_roundtrip(&[5, 5]), vec![0xff, 5]); -} - -#[test] -fn packbits_run_128() { - let src = vec![9u8; 128]; - assert_eq!(pb_roundtrip(&src), vec![0x81, 9]); -} - -#[test] -fn packbits_run_129() { - let src = vec![9u8; 129]; - assert_eq!(pb_roundtrip(&src), vec![0x81, 9, 0, 9]); -} - -#[test] -fn packbits_run_130_and_256() { - pb_roundtrip(&[1u8; 130]); - let enc = pb_roundtrip(&[1u8; 256]); - assert_eq!(enc, vec![0x81, 1, 0x81, 1]); -} - -#[test] -fn packbits_literal_128_and_129() { - let src: Vec = (0..128u32).map(|i| i as u8).collect(); - let enc = pb_roundtrip(&src); - assert_eq!(enc[0], 127); - assert_eq!(enc.len(), 129); - let src: Vec = (0..129u32).map(|i| i as u8).collect(); - let enc = pb_roundtrip(&src); - assert_eq!(enc[0], 127); - assert_eq!(enc[129], 0); - assert_eq!(enc.len(), 131); -} - -#[test] -fn packbits_literal_run_boundary() { - pb_roundtrip(&[1, 2, 3, 3, 3, 3, 4, 5]); - pb_roundtrip(&[1, 1, 2, 2, 3, 3]); - pb_roundtrip(&[1, 2, 2, 3]); - pb_roundtrip(&[0, 0, 0, 1, 0, 0, 0]); - let enc = pb_roundtrip(&[1, 2, 3, 3, 3]); - assert_eq!(enc, vec![1, 1, 2, 0xfe, 3]); -} - -#[test] -fn packbits_never_emits_noop() { - for n in 0..300usize { - let src: Vec = (0..n).map(|i| ((i / 3) % 5) as u8).collect(); - let enc = pb_roundtrip(&src); - // Walk the packets to find headers. - let mut i = 0; - while i < enc.len() { - let h = enc[i] as i8; - assert_ne!(h, -128); - i += if h >= 0 { h as usize + 2 } else { 2 }; - } - } -} - -#[test] -fn packbits_decode_noop_skipped() { - assert_eq!(packbits::decode(&[0x80, 0, 42], 1).unwrap(), vec![42]); -} - -#[test] -fn packbits_decode_errors() { - assert!(packbits::decode(&[], 1).is_err()); - assert!(packbits::decode(&[2, 1, 2], 3).is_err()); // literal truncated - assert!(packbits::decode(&[0xfe], 3).is_err()); // run truncated - assert!(packbits::decode(&[0xfd, 1], 3).is_err()); // run overflows (4 > 3) - assert!(packbits::decode(&[3, 1, 2, 3, 4], 3).is_err()); // literal overflows -} - -/// `out.len() + expected` past `usize::MAX` used to panic in debug builds and, in release, -/// wrap and return `Ok` having decoded nothing. -#[test] -fn packbits_decode_into_output_length_overflow_is_an_error() { - let mut out = vec![7u8]; - let r = packbits::decode_into(&[0x00, 0xaa], usize::MAX, &mut out); - assert!(matches!(r, Err(PsdError::LimitExceeded(_))), "{r:?}"); - assert_eq!(out, [7]); - // The largest `expected` that fits still decodes up to the input, then reports it short. - let r = packbits::decode_into(&[0x00, 0xaa], usize::MAX - 1, &mut out); - assert!(matches!(r, Err(PsdError::Decompress(_))), "{r:?}"); - assert_eq!(out, [7, 0xaa]); -} - -#[test] -fn packbits_zero_expected_ignores_input() { - assert_eq!(packbits::decode(&[0xff, 1], 0).unwrap(), Vec::::new()); -} - -#[test] -fn compression_values() { - for v in 0..6 { - assert_eq!(Compression::from_u16(v).as_u16(), v); - } - assert_eq!(Compression::from_u16(9), Compression::Unknown(9)); -} - -fn sample_data(l: &PlaneLayout) -> Vec { - let n = l.decoded_len().unwrap(); - (0..n).map(|i| ((i * 7 + i / 5) % 251) as u8).collect() -} - -#[test] -fn all_codecs_roundtrip_all_depths() { - for version in [Version::Psd, Version::Psb] { - for depth in [1u16, 8, 16, 32] { - for (w, h) in [(0, 0), (1, 1), (3, 2), (17, 5), (0, 3), (4, 0)] { - for planes in [1, 3] { - let l = layout(planes, w, h, depth, version); - let data = sample_data(&l); - for c in Compression::ALL { - let enc = encode_planes(c, &data, &l).unwrap(); - validate_planes(c, &enc, &l).unwrap(); - let dec = decode_planes(c, &enc, &l).unwrap(); - assert_eq!(dec, data, "{c:?} {depth} {w}x{h} {version:?}"); - } - } - } - } - } -} - -#[test] -fn rle_count_width_psd_vs_psb() { - let data = vec![0u8; 4]; - let psd = encode_planes(Compression::Rle, &data, &layout(1, 2, 2, 8, Version::Psd)).unwrap(); - let psb = encode_planes(Compression::Rle, &data, &layout(1, 2, 2, 8, Version::Psb)).unwrap(); - assert_eq!(psd, vec![0, 2, 0, 2, 0xff, 0, 0xff, 0]); - assert_eq!(psb, vec![0, 0, 0, 2, 0, 0, 0, 2, 0xff, 0, 0xff, 0]); - assert!(decode_planes(Compression::Rle, &psd, &layout(1, 2, 2, 8, Version::Psb)).is_err()); -} - -#[test] -fn rle_odd_width() { - let l = layout(1, 7, 3, 16, Version::Psd); - let data = sample_data(&l); - let enc = encode_planes(Compression::Rle, &data, &l).unwrap(); - assert_eq!(decode_planes(Compression::Rle, &enc, &l).unwrap(), data); -} - -#[test] -fn rle_truncated_fails() { - let l = layout(1, 10, 10, 8, Version::Psd); - let data = sample_data(&l); - let enc = encode_planes(Compression::Rle, &data, &l).unwrap(); - for cut in 0..enc.len() { - assert!(decode_planes(Compression::Rle, &enc[..cut], &l).is_err(), "cut {cut}"); - assert!(validate_planes(Compression::Rle, &enc[..cut], &l).is_err(), "cut {cut}"); - } -} - -#[test] -fn zip_truncated_fails() { - let l = layout(1, 10, 10, 8, Version::Psd); - let data = sample_data(&l); - let enc = encode_planes(Compression::Zip, &data, &l).unwrap(); - for cut in 0..enc.len() - 4 { - assert!(decode_planes(Compression::Zip, &enc[..cut], &l).is_err(), "cut {cut}"); - assert!(validate_planes(Compression::Zip, &enc[..cut], &l).is_err(), "cut {cut}"); - } -} - -/// #2926: a ZIP stream declaring more than `MAX_DECODED_BYTES` is not inflated at parse -/// time. Garbage zlib data proves it: decompressing it would fail, skipping it passes, and -/// decoding the layout still reports the size limit. -#[test] -fn zip_over_budget_is_not_decompressed_at_parse() { - let l = layout(4, 300_000, 300_000, 8, Version::Psb); - assert!(l.total_bytes().unwrap() > MAX_DECODED_BYTES); - let garbage = [0xde, 0xad, 0xbe, 0xef, 0x01, 0x02, 0x03]; - for c in [Compression::Zip, Compression::ZipPrediction] { - validate_planes(c, &garbage, &l).unwrap(); - assert!(matches!(decode_planes(c, &garbage, &l), Err(PsdError::LimitExceeded(_))), "{c:?}"); - } - // Within the budget the stream is still checked. - let small = layout(1, 4, 4, 8, Version::Psd); - assert!(validate_planes(Compression::Zip, &garbage, &small).is_err()); -} - -#[test] -fn raw_short_fails() { - let l = layout(2, 3, 3, 8, Version::Psd); - assert!(decode_planes(Compression::Raw, &[0; 17], &l).is_err()); - assert!(decode_planes(Compression::Raw, &[0; 18], &l).is_ok()); -} - -#[test] -fn unknown_compression_errors() { - let l = layout(1, 1, 1, 8, Version::Psd); - assert!(decode_planes(Compression::Unknown(7), &[0], &l).is_err()); - assert!(encode_planes(Compression::Unknown(7), &[0], &l).is_err()); -} - -#[test] -fn encode_rejects_wrong_size() { - let l = layout(1, 2, 2, 8, Version::Psd); - assert!(encode_planes(Compression::Raw, &[0; 3], &l).is_err()); -} - -#[test] -fn decoded_len_limits() { - let l = layout(56, 300_000, 300_000, 32, Version::Psb); - assert!(matches!(l.decoded_len(), Err(PsdError::LimitExceeded(_)))); - assert!(decode_planes(Compression::Rle, &[0; 16], &l).is_err()); -} - -/// Real PSBs go past 2 GiB in one decode (#375): a 30000² RGB merged image and a 33000² -/// 16-bit layer channel. Only checks the limit, so nothing is allocated. -#[test] -#[cfg(target_pointer_width = "64")] -fn decoded_len_allows_large_psb_planes() { - assert_eq!(layout(3, 30_000, 30_000, 8, Version::Psb).decoded_len().unwrap(), 2_700_000_000); - assert_eq!(layout(1, 33_000, 33_000, 16, Version::Psb).decoded_len().unwrap(), 2_178_000_000); - assert!(layout(4, 40_000, 40_000, 16, Version::Psb).decoded_len().is_err()); -} - -#[test] -fn prediction_8bit_known_vector() { - let l = layout(1, 4, 1, 8, Version::Psd); - let mut v = vec![10, 12, 11, 255]; - predict(&mut v, &l).unwrap(); - assert_eq!(v, vec![10, 2, 255, 244]); - unpredict(&mut v, &l).unwrap(); - assert_eq!(v, vec![10, 12, 11, 255]); -} - -#[test] -fn prediction_16bit_known_vector() { - let l = layout(1, 3, 1, 16, Version::Psd); - let mut v = vec![0x01, 0x00, 0x01, 0x05, 0x00, 0x00]; - predict(&mut v, &l).unwrap(); - assert_eq!(v, vec![0x01, 0x00, 0x00, 0x05, 0xfe, 0xfb]); - unpredict(&mut v, &l).unwrap(); - assert_eq!(v, vec![0x01, 0x00, 0x01, 0x05, 0x00, 0x00]); -} - -#[test] -fn prediction_32bit_plane_shuffle() { - // Two samples: AABBCCDD and 11223344 → planes [AA,11][BB,22][CC,33][DD,44] - let l = layout(1, 2, 1, 32, Version::Psd); - let orig = vec![0xAA, 0xBB, 0xCC, 0xDD, 0x11, 0x22, 0x33, 0x44]; - let mut v = orig.clone(); - predict(&mut v, &l).unwrap(); - let planes = [0xAAu8, 0x11, 0xBB, 0x22, 0xCC, 0x33, 0xDD, 0x44]; - let mut expect = planes; - for i in (1..8).rev() { - expect[i] = expect[i].wrapping_sub(expect[i - 1]); - } - assert_eq!(v, expect); - unpredict(&mut v, &l).unwrap(); - assert_eq!(v, orig); -} - -#[test] -fn prediction_32bit_floats_roundtrip() { - let l = layout(1, 5, 2, 32, Version::Psd); - let mut v = Vec::new(); - for i in 0..10 { - v.extend_from_slice(&(i as f32 * 0.37 - 1.0).to_be_bytes()); - } - let enc = encode_planes(Compression::ZipPrediction, &v, &l).unwrap(); - assert_eq!(decode_planes(Compression::ZipPrediction, &enc, &l).unwrap(), v); -} - -#[test] -fn zip_bomb_is_bounded() { - let big = zip_compress(&vec![0u8; 1 << 20]); - let out = zip_decompress(&big, 10).unwrap(); - assert_eq!(out.len(), 10); -} - -#[test] -fn zip_garbage_errors() { - assert!(zip_decompress(&[1, 2, 3, 4], 4).is_err()); -} diff --git a/crates/psd/src/csh.rs b/crates/psd/src/csh.rs deleted file mode 100644 index a024eb675d..0000000000 --- a/crates/psd/src/csh.rs +++ /dev/null @@ -1,127 +0,0 @@ -//! Photoshop custom shape sets (`.csh`, version 2). -//! -//! Container layout: Michel Mariani's public format research, -//! . -//! Geometry uses Adobe's path record format. No external implementation or shape assets copied. - -use crate::error::{PsdError, Result}; -use crate::io::Reader; -use crate::path::{PathData, RECORD_LEN}; - -/// Maximum encoded file size accepted by this reader. -pub const MAX_FILE_BYTES: usize = 64 << 20; -/// Maximum presets in a set. -pub const MAX_SHAPES: usize = 10_000; -/// Maximum path records across all presets, not per shape. -pub const MAX_RECORDS: usize = 1_000_000; -/// Maximum UTF-16 units in a preset name, including its optional trailing null. -pub const MAX_NAME_UNITS: usize = 32_768; - -/// A named shape with its reference bounds and normalized path records. -#[derive(Debug, Clone, PartialEq)] -pub struct CshShape { - /// Display name. - pub name: String, - /// Source shape identifier (usually a UUID). - pub id: String, - /// Reference rectangle: top, left, bottom, right. Coordinates may be negative. - pub bounds: [i32; 4], - /// Coordinates are fractions of the reference rectangle's width and height. - pub path: PathData, -} - -/// Read a complete set before callers modify their preset libraries. -/// Unsupported versions, truncated records and excessive aggregate sizes return errors. -pub fn parse(bytes: &[u8]) -> Result> { - if bytes.len() > MAX_FILE_BYTES { - return Err(PsdError::LimitExceeded("CSH file bytes")); - } - let mut r = Reader::new(bytes); - let signature = r.array()?; - if &signature != b"cush" { - return Err(PsdError::InvalidSignature { expected: "cush", found: signature }); - } - if r.u32()? != 2 { - return Err(PsdError::Unsupported("CSH file version (expected 2)".into())); - } - let count = r.u32()? as usize; - if count > MAX_SHAPES { - return Err(PsdError::LimitExceeded("CSH shape count")); - } - r.check_count(count as u64, 32)?; - let mut shapes = Vec::new(); - let mut remaining_records = MAX_RECORDS; - for _ in 0..count { - let units = r.u32()? as usize; - if units > MAX_NAME_UNITS { - return Err(PsdError::LimitExceeded("CSH name length")); - } - let raw = r.bytes(units * 2)?; - let mut name: Vec = raw.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes(*c)).collect(); - if name.last() == Some(&0) { - name.pop(); - } - let name = String::from_utf16(&name).map_err(|_| PsdError::invalid("CSH name is not valid UTF-16"))?; - let padding = (4 - r.pos() % 4) % 4; - zero_padding(r.bytes(padding)?)?; - if r.u32()? != 1 { - return Err(PsdError::Unsupported("CSH shape version (expected 1)".into())); - } - let length = r.u32()?; - if !length.is_multiple_of(4) { - return Err(PsdError::invalid("CSH shape length must be aligned to four bytes")); - } - let mut shape = r.sub(u64::from(length))?; - let id_len = usize::from(shape.u8()?); - let id = std::str::from_utf8(shape.bytes(id_len)?).map_err(|_| PsdError::invalid("CSH identifier is not UTF-8"))?.to_owned(); - let bounds = [shape.i32()?, shape.i32()?, shape.i32()?, shape.i32()?]; - if bounds[2] <= bounds[0] || bounds[3] <= bounds[1] { - return Err(PsdError::invalid("CSH reference bounds are empty or reversed")); - } - let records = shape.remaining() / RECORD_LEN; - remaining_records = remaining_records.checked_sub(records).ok_or(PsdError::LimitExceeded("CSH aggregate path records"))?; - let path = PathData::from_bytes(shape.bytes(records * RECORD_LEN)?)?; - if shape.remaining() > 3 { - return Err(PsdError::invalid("CSH incomplete path record")); - } - zero_padding(shape.peek_rest())?; - validate_path(&path)?; - shapes.push(CshShape { name, id, bounds, path }); - } - if !r.is_empty() { - return Err(PsdError::invalid("CSH trailing data")); - } - Ok(shapes) -} - -/// Refuse nonzero alignment bytes rather than silently discarding extra payload data as padding. -fn zero_padding(bytes: &[u8]) -> Result<()> { - if bytes.iter().any(|&b| b != 0) { - return Err(PsdError::invalid("CSH nonzero padding")); - } - Ok(()) -} - -/// Enforce subpath structure more strictly than the generic PSD path reader. -/// Each length record must be followed by exactly its declared number of knots, with matching -/// open/closed selectors. Fill and clipboard records are accepted only between subpaths. -/// Rejecting inconsistent records prevents a damaged preset from silently changing geometry. -fn validate_path(path: &PathData) -> Result<()> { - let mut pending = 0u16; - let mut closed = false; - for record in &path.records { - match record.selector { - 0 | 3 if pending == 0 => { - pending = u16::from_be_bytes([record.data[0], record.data[1]]); - closed = record.selector == 0; - } - 1 | 2 | 4 | 5 if pending > 0 && closed == (record.selector <= 2) => pending -= 1, - 6..=8 if pending == 0 => {} - _ => return Err(PsdError::invalid("CSH path selectors or knot counts are inconsistent")), - } - } - if pending != 0 { - return Err(PsdError::invalid("CSH subpath is missing knots")); - } - Ok(()) -} diff --git a/crates/psd/src/descriptor.rs b/crates/psd/src/descriptor.rs deleted file mode 100644 index 8bb5f6bb35..0000000000 --- a/crates/psd/src/descriptor.rs +++ /dev/null @@ -1,867 +0,0 @@ -//! Photoshop ActionDescriptor structures (OSType-tagged key/value trees). -//! -//! These appear inside many tagged blocks (`lfx2`, `SoCo`, `GdFl`, `TySh`, -//! `PlLd`, …), usually wrapped in a [`VersionedDescriptor`] (version 16). -//! Parsing and writing are exact: `write(parse(b)) == b` for well-formed input. - -use crate::error::{PsdError, Result}; -use crate::io::{Reader, WriteExt, read_unicode_units, write_unicode_units}; - -/// Maximum nesting depth accepted when parsing (fuzz safety). -pub const MAX_DEPTH: usize = 64; - -/// A Photoshop Unicode string: UTF-16 code units, preserved exactly -/// (including any trailing NUL and unpaired surrogates). -#[derive(Debug, Clone, PartialEq, Eq, Default, Hash)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct UnicodeString(pub Vec); - -impl UnicodeString { - /// Encodes `s` as UTF-16 without a trailing NUL. - pub fn new(s: &str) -> Self { - UnicodeString(s.encode_utf16().collect()) - } - /// Encodes `s` as UTF-16 followed by a NUL terminator (as Photoshop - /// commonly writes in descriptors). - pub fn new_nul(s: &str) -> Self { - let mut v: Vec = s.encode_utf16().collect(); - v.push(0); - UnicodeString(v) - } - /// Lossy conversion, stripping trailing NULs. - pub fn to_string_lossy(&self) -> String { - let mut end = self.0.len(); - while end > 0 && self.0[end - 1] == 0 { - end -= 1; - } - String::from_utf16_lossy(&self.0[..end]) - } - pub(crate) fn read(r: &mut Reader<'_>) -> Result { - Ok(UnicodeString(read_unicode_units(r)?)) - } - pub(crate) fn write(&self, out: &mut Vec) { - write_unicode_units(out, &self.0); - } -} - -/// Four-character class and key ids Photoshop writes as length-4 strings, never as codes -/// (`00000004 'warp'`, not `00000000 'warp'`). -pub const STRING_IDS: [&str; 3] = ["warp", "list", "time"]; - -/// A class / key / type identifier: a length-prefixed string, where a stored -/// length of 0 means a 4-character code follows. -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub enum Id { - /// Stored as length 0 + 4 bytes. - Code([u8; 4]), - /// Stored as explicit length + bytes (e.g. `"artboardRect"`). - Str(Vec), -} - -impl Id { - /// Convenience constructor: 4-byte strings become [`Id::Code`], others - /// [`Id::Str`]. The four-character names Photoshop stores as length-4 strings - /// ([`STRING_IDS`]: `warp`, `list`, `time`) are [`Id::Str`] too: Photoshop treats the - /// string and the code as different keys and doesn't recognise the code form. - pub fn new(s: &str) -> Self { - let b = s.as_bytes(); - match b { - [a, b, c, d] if !STRING_IDS.contains(&s) => Id::Code([*a, *b, *c, *d]), - _ => Id::Str(b.to_vec()), - } - } - /// The identifier's bytes. - pub fn as_bytes(&self) -> &[u8] { - match self { - Id::Code(c) => c, - Id::Str(s) => s, - } - } - /// `true` if the identifier's bytes equal `s`. - pub fn is(&self, s: &str) -> bool { - self.as_bytes() == s.as_bytes() - } - pub(crate) fn read(r: &mut Reader<'_>) -> Result { - let n = r.u32()?; - if n == 0 { Ok(Id::Code(r.array()?)) } else { Ok(Id::Str(r.bytes_u64(u64::from(n))?.to_vec())) } - } - pub(crate) fn write(&self, out: &mut Vec) { - match self { - Id::Code(c) => { - out.put_u32(0); - out.put(c); - } - Id::Str(s) => { - out.put_u32(s.len() as u32); - out.put(s); - } - } - } -} - -/// A class reference: display name + class id. -#[derive(Debug, Clone, PartialEq, Eq)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct Class { - /// Display name. - pub name: UnicodeString, - /// Class identifier. - pub class_id: Id, -} - -impl Class { - fn read(r: &mut Reader<'_>) -> Result { - Ok(Class { name: UnicodeString::read(r)?, class_id: Id::read(r)? }) - } - fn write(&self, out: &mut Vec) { - self.name.write(out); - self.class_id.write(out); - } -} - -/// One element of an `'obj '` reference. -#[derive(Debug, Clone, PartialEq, Eq)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub enum ReferenceItem { - /// `prop` - Property { - /// Class. - class: Class, - /// Property key. - key: Id, - }, - /// `Clss` - Class(Class), - /// `Enmr` - Enumerated { - /// Class. - class: Class, - /// Enum type. - type_id: Id, - /// Enum value. - value: Id, - }, - /// `rele` - Offset { - /// Class. - class: Class, - /// Offset. - offset: i32, - }, - /// `Idnt`: a class plus an identifier (Photoshop's layer ids in action references). - Identifier { - /// Class. - class: Class, - /// Identifier. - id: u32, - }, - /// `indx`: a class plus a zero-based index. - Index { - /// Class. - class: Class, - /// Index. - index: u32, - }, - /// `name` - Name { - /// Class. - class: Class, - /// Name. - name: UnicodeString, - }, -} - -/// A descriptor value, tagged by its OSType. -#[derive(Debug, Clone, PartialEq)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub enum Value { - /// `obj ` - Reference(Vec), - /// `Objc` - Descriptor(Descriptor), - /// `GlbO` - GlobalObject(Descriptor), - /// `VlLs` - List(Vec), - /// `doub` - Double(f64), - /// `UntF`: unit (`#Ang`, `#Rsl`, `#Rlt`, `#Nne`, `#Prc`, `#Pxl`, …) + value. - UnitFloat { - /// Unit code. - unit: [u8; 4], - /// Value. - value: f64, - }, - /// `UnFl`: unit + array of doubles (used in `ObAr`). - UnitFloats { - /// Unit code. - unit: [u8; 4], - /// Values. - values: Vec, - }, - /// `TEXT` - Text(UnicodeString), - /// `enum` - Enumerated { - /// Enum type. - type_id: Id, - /// Enum value. - value: Id, - }, - /// `long` - Integer(i32), - /// `comp` - LargeInteger(i64), - /// `bool` - Boolean(bool), - /// `type` - Class(Class), - /// `GlbC` - GlobalClass(Class), - /// `alis`: raw alias data. - Alias(Vec), - /// `Pth `: raw file path data (length-prefixed). - Path(Vec), - /// `tdta`: raw data. - RawData(Vec), - /// `ObAr`: object array (best effort; see [`ObjectArray`]). - ObjectArray(ObjectArray), -} - -/// `ObAr` object array. Undocumented in the Adobe spec; the layout follows -/// ag-psd (MIT): a u32 (observed as the element count / version 16) followed -/// by a descriptor-shaped body whose values are usually `UnFl`. -#[derive(Debug, Clone, PartialEq)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct ObjectArray { - /// Leading u32, preserved. - pub prefix: u32, - /// Body. - pub body: Descriptor, -} - -/// An ActionDescriptor: class plus ordered key/value items. -#[derive(Debug, Clone, PartialEq)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct Descriptor { - /// Display name of the class (often empty). - pub name: UnicodeString, - /// Class id. - pub class_id: Id, - /// Items in stored order. - pub items: Vec<(Id, Value)>, -} - -impl Default for Descriptor { - fn default() -> Self { - Descriptor::new("null") - } -} - -impl Descriptor { - /// Creates an empty descriptor of the given class. - pub fn new(class_id: &str) -> Self { - Descriptor { name: UnicodeString::default(), class_id: Id::new(class_id), items: Vec::new() } - } - - /// Builder-style item append. - pub fn with(mut self, key: &str, value: Value) -> Self { - self.items.push((Id::new(key), value)); - self - } - - /// Looks up the first item with key `key`. - pub fn get(&self, key: &str) -> Option<&Value> { - self.items.iter().find(|(k, _)| k.is(key)).map(|(_, v)| v) - } - - /// Parses a descriptor from `data`, which must be consumed entirely. - pub fn from_bytes(data: &[u8]) -> Result { - let mut r = Reader::new(data); - let d = Self::read(&mut r)?; - if !r.is_empty() { - return Err(PsdError::invalid("trailing bytes after descriptor")); - } - Ok(d) - } - - /// Serializes the descriptor. - pub fn to_bytes(&self) -> Vec { - let mut v = Vec::new(); - self.write(&mut v); - v - } - - pub(crate) fn read(r: &mut Reader<'_>) -> Result { - read_descriptor(r, 0) - } - - pub(crate) fn write(&self, out: &mut Vec) { - self.name.write(out); - self.class_id.write(out); - out.put_u32(self.items.len() as u32); - for (k, v) in &self.items { - k.write(out); - write_value(v, out); - } - } -} - -/// A descriptor prefixed by a u32 version (16 in all current Photoshop files). -#[derive(Debug, Clone, PartialEq)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct VersionedDescriptor { - /// Descriptor version (16). - pub version: u32, - /// The descriptor. - pub descriptor: Descriptor, -} - -impl VersionedDescriptor { - /// Wraps a descriptor with version 16. - pub fn new(descriptor: Descriptor) -> Self { - VersionedDescriptor { version: 16, descriptor } - } - /// Parses from bytes (must be fully consumed). - pub fn from_bytes(data: &[u8]) -> Result { - let mut r = Reader::new(data); - let v = Self::read(&mut r)?; - if !r.is_empty() { - return Err(PsdError::invalid("trailing bytes after versioned descriptor")); - } - Ok(v) - } - /// Parses a versioned descriptor at the start of `data`, returning it and - /// the number of bytes consumed (trailing data is allowed, e.g. the warp - /// descriptor after the text descriptor in `TySh`). - pub fn parse_prefix(data: &[u8]) -> Result<(Self, usize)> { - let mut r = Reader::new(data); - let v = Self::read(&mut r)?; - Ok((v, r.pos())) - } - /// Serializes. - pub fn to_bytes(&self) -> Vec { - let mut v = Vec::new(); - self.write(&mut v); - v - } - pub(crate) fn read(r: &mut Reader<'_>) -> Result { - let version = r.u32()?; - if version != 16 { - return Err(PsdError::invalid(format!("descriptor version {version}, expected 16"))); - } - Ok(VersionedDescriptor { version, descriptor: Descriptor::read(r)? }) - } - pub(crate) fn write(&self, out: &mut Vec) { - out.put_u32(self.version); - self.descriptor.write(out); - } -} - -/// Most items preallocated for a file-declared count. `check_count` only -/// guarantees a few bytes per item while parsed items are far larger, so the -/// count must not drive the up-front reservation (or abort on allocation). -const MAX_PREALLOC: usize = 4096; - -pub(crate) fn vec_for_count(n: u32) -> Vec { - Vec::with_capacity((n as usize).min(MAX_PREALLOC)) -} - -fn read_descriptor(r: &mut Reader<'_>, depth: usize) -> Result { - if depth > MAX_DEPTH { - return Err(PsdError::LimitExceeded("descriptor nesting too deep")); - } - let name = UnicodeString::read(r)?; - let class_id = Id::read(r)?; - let n = r.u32()?; - // Each item needs at least 5 (1-byte string key) + 4 (type) + 1 (bool). - r.check_count(u64::from(n), 10)?; - let mut items = vec_for_count(n); - for _ in 0..n { - let key = Id::read(r)?; - let v = read_value(r, depth + 1)?; - items.push((key, v)); - } - Ok(Descriptor { name, class_id, items }) -} - -fn read_value(r: &mut Reader<'_>, depth: usize) -> Result { - if depth > MAX_DEPTH { - return Err(PsdError::LimitExceeded("descriptor nesting too deep")); - } - let ty = r.array::<4>()?; - read_typed_value(r, &ty, depth) -} - -fn read_typed_value(r: &mut Reader<'_>, ty: &[u8; 4], depth: usize) -> Result { - Ok(match ty { - b"obj " => { - let n = r.u32()?; - r.check_count(u64::from(n), 8)?; - let mut items = vec_for_count(n); - for _ in 0..n { - items.push(read_reference_item(r)?); - } - Value::Reference(items) - } - b"Objc" => Value::Descriptor(read_descriptor(r, depth + 1)?), - b"GlbO" => Value::GlobalObject(read_descriptor(r, depth + 1)?), - b"VlLs" => { - let n = r.u32()?; - r.check_count(u64::from(n), 4)?; - let mut items = vec_for_count(n); - for _ in 0..n { - items.push(read_value(r, depth + 1)?); - } - Value::List(items) - } - b"doub" => Value::Double(r.f64()?), - b"UntF" => Value::UnitFloat { unit: r.array()?, value: r.f64()? }, - b"UnFl" => { - let unit = r.array()?; - let n = r.u32()?; - r.check_count(u64::from(n), 8)?; - let mut values = vec_for_count(n); - for _ in 0..n { - values.push(r.f64()?); - } - Value::UnitFloats { unit, values } - } - b"TEXT" => Value::Text(UnicodeString::read(r)?), - b"enum" => Value::Enumerated { type_id: Id::read(r)?, value: Id::read(r)? }, - b"long" => Value::Integer(r.i32()?), - b"comp" => Value::LargeInteger(r.i64()?), - b"bool" => Value::Boolean(r.u8()? != 0), - b"type" => Value::Class(Class::read(r)?), - b"GlbC" => Value::GlobalClass(Class::read(r)?), - b"alis" => { - let n = r.u32()?; - Value::Alias(r.bytes_u64(u64::from(n))?.to_vec()) - } - b"Pth " => { - let n = r.u32()?; - Value::Path(r.bytes_u64(u64::from(n))?.to_vec()) - } - b"tdta" => { - let n = r.u32()?; - Value::RawData(r.bytes_u64(u64::from(n))?.to_vec()) - } - b"ObAr" => { - let prefix = r.u32()?; - Value::ObjectArray(ObjectArray { prefix, body: read_descriptor(r, depth + 1)? }) - } - other => { - return Err(PsdError::Unsupported(format!("descriptor OSType {:?}", String::from_utf8_lossy(other)))); - } - }) -} - -fn read_reference_item(r: &mut Reader<'_>) -> Result { - let ty = r.array::<4>()?; - Ok(match &ty { - b"prop" => ReferenceItem::Property { class: Class::read(r)?, key: Id::read(r)? }, - b"Clss" => ReferenceItem::Class(Class::read(r)?), - b"Enmr" => ReferenceItem::Enumerated { class: Class::read(r)?, type_id: Id::read(r)?, value: Id::read(r)? }, - b"rele" => ReferenceItem::Offset { class: Class::read(r)?, offset: r.i32()? }, - b"Idnt" => ReferenceItem::Identifier { class: Class::read(r)?, id: r.u32()? }, - b"indx" => ReferenceItem::Index { class: Class::read(r)?, index: r.u32()? }, - b"name" => ReferenceItem::Name { class: Class::read(r)?, name: UnicodeString::read(r)? }, - other => { - return Err(PsdError::Unsupported(format!("reference item type {:?}", String::from_utf8_lossy(other)))); - } - }) -} - -fn value_type(v: &Value) -> &'static [u8; 4] { - match v { - Value::Reference(_) => b"obj ", - Value::Descriptor(_) => b"Objc", - Value::GlobalObject(_) => b"GlbO", - Value::List(_) => b"VlLs", - Value::Double(_) => b"doub", - Value::UnitFloat { .. } => b"UntF", - Value::UnitFloats { .. } => b"UnFl", - Value::Text(_) => b"TEXT", - Value::Enumerated { .. } => b"enum", - Value::Integer(_) => b"long", - Value::LargeInteger(_) => b"comp", - Value::Boolean(_) => b"bool", - Value::Class(_) => b"type", - Value::GlobalClass(_) => b"GlbC", - Value::Alias(_) => b"alis", - Value::Path(_) => b"Pth ", - Value::RawData(_) => b"tdta", - Value::ObjectArray(_) => b"ObAr", - } -} - -fn write_value(v: &Value, out: &mut Vec) { - out.put(value_type(v)); - match v { - Value::Reference(items) => { - out.put_u32(items.len() as u32); - for it in items { - write_reference_item(it, out); - } - } - Value::Descriptor(d) | Value::GlobalObject(d) => d.write(out), - Value::List(items) => { - out.put_u32(items.len() as u32); - for it in items { - write_value(it, out); - } - } - Value::Double(d) => out.put_f64(*d), - Value::UnitFloat { unit, value } => { - out.put(unit); - out.put_f64(*value); - } - Value::UnitFloats { unit, values } => { - out.put(unit); - out.put_u32(values.len() as u32); - for v in values { - out.put_f64(*v); - } - } - Value::Text(s) => s.write(out), - Value::Enumerated { type_id, value } => { - type_id.write(out); - value.write(out); - } - Value::Integer(i) => out.put_i32(*i), - Value::LargeInteger(i) => out.put_i64(*i), - Value::Boolean(b) => out.put_u8(u8::from(*b)), - Value::Class(c) | Value::GlobalClass(c) => c.write(out), - Value::Alias(b) | Value::Path(b) | Value::RawData(b) => { - out.put_u32(b.len() as u32); - out.put(b); - } - Value::ObjectArray(o) => { - out.put_u32(o.prefix); - o.body.write(out); - } - } -} - -fn write_reference_item(it: &ReferenceItem, out: &mut Vec) { - match it { - ReferenceItem::Property { class, key } => { - out.put(b"prop"); - class.write(out); - key.write(out); - } - ReferenceItem::Class(c) => { - out.put(b"Clss"); - c.write(out); - } - ReferenceItem::Enumerated { class, type_id, value } => { - out.put(b"Enmr"); - class.write(out); - type_id.write(out); - value.write(out); - } - ReferenceItem::Offset { class, offset } => { - out.put(b"rele"); - class.write(out); - out.put_i32(*offset); - } - ReferenceItem::Identifier { class, id } => { - out.put(b"Idnt"); - class.write(out); - out.put_u32(*id); - } - ReferenceItem::Index { class, index } => { - out.put(b"indx"); - class.write(out); - out.put_u32(*index); - } - ReferenceItem::Name { class, name } => { - out.put(b"name"); - class.write(out); - name.write(out); - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn class(name: &str, id: &str) -> Class { - Class { name: UnicodeString::new(name), class_id: Id::new(id) } - } - - #[test] - fn declared_counts_do_not_drive_preallocation() { - assert!(vec_for_count::(u32::MAX).capacity() <= MAX_PREALLOC); - // A 1M-item list of booleans parses (items grow the vector as needed). - let n = 1_000_000u32; - let mut data = n.to_be_bytes().to_vec(); - for _ in 0..n { - data.extend_from_slice(b"bool\x01"); - } - let mut r = Reader::new(&data); - match read_typed_value(&mut r, b"VlLs", 0).unwrap() { - Value::List(v) => assert_eq!(v.len(), n as usize), - other => panic!("expected list, got {other:?}"), - } - } - - pub(crate) fn sample() -> Descriptor { - Descriptor::new("null") - .with("Nm ", Value::Text(UnicodeString::new_nul("Hello \u{1F600}"))) - .with("Opct", Value::UnitFloat { unit: *b"#Prc", value: 75.5 }) - .with("enab", Value::Boolean(true)) - .with("long", Value::Integer(-5)) - .with("comp", Value::LargeInteger(1 << 40)) - .with("doub", Value::Double(0.25)) - .with("Md ", Value::Enumerated { type_id: Id::new("BlnM"), value: Id::new("Mltp") }) - .with("artboardRect", Value::Descriptor(Descriptor::new("classFloatRect").with("Top ", Value::Double(1.0)))) - .with("list", Value::List(vec![Value::Integer(1), Value::Text(UnicodeString::new("x"))])) - .with( - "null", - Value::Reference(vec![ - ReferenceItem::Property { class: class("", "Lyr "), key: Id::new("Nm ") }, - ReferenceItem::Class(class("", "Dcmn")), - ReferenceItem::Enumerated { class: class("", "Lyr "), type_id: Id::new("Ordn"), value: Id::new("Trgt") }, - ReferenceItem::Offset { class: class("", "Lyr "), offset: -1 }, - ReferenceItem::Identifier { class: class("", "Lyr "), id: 7 }, - ReferenceItem::Index { class: class("\0", "Lyr "), index: 2 }, - ReferenceItem::Name { class: class("", "Lyr "), name: UnicodeString::new("Bg") }, - ]), - ) - .with("type", Value::Class(class("c", "Clr "))) - .with("glbc", Value::GlobalClass(class("c", "RGBC"))) - .with("glbo", Value::GlobalObject(Descriptor::default())) - .with("alis", Value::Alias(vec![1, 2, 3])) - .with("tdta", Value::RawData(vec![0xde, 0xad])) - .with("pth ", Value::Path(vec![9, 9])) - .with( - "ObAr", - Value::ObjectArray(ObjectArray { - prefix: 16, - body: Descriptor::new("rationalPoint").with("Hrzn", Value::UnitFloats { unit: *b"#Pxl", values: vec![1.0, 2.0] }), - }), - ) - } - - #[test] - fn roundtrip_all_types() { - let d = sample(); - let b = d.to_bytes(); - let p = Descriptor::from_bytes(&b).unwrap(); - assert_eq!(p, d); - assert_eq!(p.to_bytes(), b); - } - - /// `warp`, `list` and `time` are written as length-4 strings, as Photoshop writes them, both - /// as keys and as class ids; other four-character names stay codes. Reading accepts both - /// forms, and a code read from a file is written back as a code. - #[test] - fn photoshop_string_ids_are_written_as_strings() { - let str_id = |s: &str| [&[0, 0, 0, 4][..], s.as_bytes()].concat(); - let code_id = |s: &str| [&[0, 0, 0, 0][..], s.as_bytes()].concat(); - for name in STRING_IDS { - assert_eq!(Id::new(name), Id::Str(name.as_bytes().to_vec())); - let d = Descriptor::new(name).with(name, Value::Integer(1)); - let b = d.to_bytes(); - // Name (empty unicode string), then the class id; then the item count and the key. - assert_eq!(&b[4..12], &str_id(name)[..], "class {name}"); - assert_eq!(&b[16..24], &str_id(name)[..], "key {name}"); - let back = Descriptor::from_bytes(&b).unwrap(); - assert_eq!(back, d); - assert!(back.class_id.is(name) && back.get(name).is_some()); - // A file that wrote the code form keeps it, and lookups still find the key. - let mut coded = b.clone(); - coded.splice(16..24, code_id(name)); - let back = Descriptor::from_bytes(&coded).unwrap(); - assert_eq!(back.get(name), Some(&Value::Integer(1))); - assert_eq!(back.to_bytes(), coded); - } - assert_eq!(Id::new("view"), Id::Code(*b"view")); - assert_eq!(Id::new("Txt "), Id::Code(*b"Txt ")); - } - - #[test] - fn versioned_roundtrip() { - let v = VersionedDescriptor::new(sample()); - let b = v.to_bytes(); - assert_eq!(&b[..4], &[0, 0, 0, 16]); - assert_eq!(VersionedDescriptor::from_bytes(&b).unwrap(), v); - } - - #[test] - fn versioned_parse_prefix() { - let v = VersionedDescriptor::new(sample()); - let mut b = v.to_bytes(); - let n = b.len(); - b.extend_from_slice(&[1, 2, 3]); - let (p, used) = VersionedDescriptor::parse_prefix(&b).unwrap(); - assert_eq!(p, v); - assert_eq!(used, n); - } - - #[test] - fn versioned_rejects_other_version() { - let mut b = VersionedDescriptor::new(Descriptor::new("null")).to_bytes(); - b[3] = 15; - assert!(VersionedDescriptor::from_bytes(&b).is_err()); - } - - #[test] - fn id_encoding() { - let mut v = Vec::new(); - Id::new("Nm ").write(&mut v); - assert_eq!(v, [0, 0, 0, 0, b'N', b'm', b' ', b' ']); - let mut v = Vec::new(); - Id::new("abc").write(&mut v); - assert_eq!(v, [0, 0, 0, 3, b'a', b'b', b'c']); - // A 4-byte explicit-length id stays explicit. - let data = [0, 0, 0, 4, b'a', b'b', b'c', b'd']; - let id = Id::read(&mut Reader::new(&data)).unwrap(); - assert_eq!(id, Id::Str(b"abcd".to_vec())); - let mut v = Vec::new(); - id.write(&mut v); - assert_eq!(v, data); - } - - #[test] - fn get_and_lookup() { - let d = sample(); - assert_eq!(d.get("long"), Some(&Value::Integer(-5))); - assert!(d.get("nope").is_none()); - } - - #[test] - fn unicode_lossy() { - assert_eq!(UnicodeString::new_nul("ab").to_string_lossy(), "ab"); - assert_eq!(UnicodeString(vec![0xd800, 0x41]).to_string_lossy(), "\u{fffd}A"); - } - - #[test] - fn truncations_error() { - let b = sample().to_bytes(); - for cut in 0..b.len() { - assert!(Descriptor::from_bytes(&b[..cut]).is_err(), "cut {cut}"); - } - } - - #[test] - fn trailing_bytes_error() { - let mut b = sample().to_bytes(); - b.push(0); - assert!(Descriptor::from_bytes(&b).is_err()); - } - - #[test] - fn unknown_ostype_errors() { - let mut b = Descriptor::new("null").to_bytes(); - let n = b.len(); - b[n - 1] = 1; // one item - b.extend_from_slice(&[0, 0, 0, 0, b'k', b'e', b'y', b' ']); - b.extend_from_slice(b"????"); - assert!(matches!(Descriptor::from_bytes(&b), Err(PsdError::Unsupported(_)))); - } - - #[test] - fn deep_nesting_is_limited() { - let mut d = Descriptor::new("null"); - for _ in 0..(MAX_DEPTH + 5) { - d = Descriptor::new("null").with("in ", Value::Descriptor(d)); - } - let b = d.to_bytes(); - assert!(matches!(Descriptor::from_bytes(&b), Err(PsdError::LimitExceeded(_)))); - } - - #[test] - fn huge_counts_rejected_without_alloc() { - let mut b = Vec::new(); - b.put_u32(0); - b.put_u32(0); - b.put(b"null"); - b.put_u32(u32::MAX); - assert!(Descriptor::from_bytes(&b).is_err()); - } -} - -#[cfg(test)] -pub(crate) mod proptests { - use super::*; - use proptest::prelude::*; - - fn arb_id() -> impl Strategy { - prop_oneof![any::<[u8; 4]>().prop_map(Id::Code), proptest::collection::vec(any::(), 1..12).prop_map(Id::Str),] - } - fn arb_ustr() -> impl Strategy { - proptest::collection::vec(any::(), 0..8).prop_map(UnicodeString) - } - fn arb_class() -> impl Strategy { - (arb_ustr(), arb_id()).prop_map(|(name, class_id)| Class { name, class_id }) - } - fn arb_ref() -> impl Strategy { - prop_oneof![ - (arb_class(), arb_id()).prop_map(|(class, key)| ReferenceItem::Property { class, key }), - arb_class().prop_map(ReferenceItem::Class), - (arb_class(), arb_id(), arb_id()).prop_map(|(class, type_id, value)| ReferenceItem::Enumerated { class, type_id, value }), - (arb_class(), any::()).prop_map(|(class, offset)| ReferenceItem::Offset { class, offset }), - (arb_class(), any::()).prop_map(|(class, id)| ReferenceItem::Identifier { class, id }), - (arb_class(), any::()).prop_map(|(class, index)| ReferenceItem::Index { class, index }), - (arb_class(), arb_ustr()).prop_map(|(class, name)| ReferenceItem::Name { class, name }), - ] - } - fn finite() -> impl Strategy { - -1e12f64..1e12 - } - fn arb_leaf() -> impl Strategy { - prop_oneof![ - finite().prop_map(Value::Double), - (any::<[u8; 4]>(), finite()).prop_map(|(unit, value)| Value::UnitFloat { unit, value }), - (any::<[u8; 4]>(), proptest::collection::vec(finite(), 0..4)).prop_map(|(unit, values)| Value::UnitFloats { unit, values }), - arb_ustr().prop_map(Value::Text), - (arb_id(), arb_id()).prop_map(|(type_id, value)| Value::Enumerated { type_id, value }), - any::().prop_map(Value::Integer), - any::().prop_map(Value::LargeInteger), - any::().prop_map(Value::Boolean), - arb_class().prop_map(Value::Class), - arb_class().prop_map(Value::GlobalClass), - proptest::collection::vec(any::(), 0..8).prop_map(Value::Alias), - proptest::collection::vec(any::(), 0..8).prop_map(Value::RawData), - proptest::collection::vec(any::(), 0..8).prop_map(Value::Path), - proptest::collection::vec(arb_ref(), 0..3).prop_map(Value::Reference), - ] - } - pub(crate) fn arb_descriptor() -> impl Strategy { - let leaf = arb_leaf(); - let value = leaf.prop_recursive(3, 24, 4, |inner| { - prop_oneof![ - proptest::collection::vec(inner.clone(), 0..4).prop_map(Value::List), - (arb_ustr(), arb_id(), proptest::collection::vec((arb_id(), inner.clone()), 0..4)) - .prop_map(|(name, id, items)| Value::Descriptor(Descriptor { name, class_id: id, items })), - (arb_ustr(), arb_id(), proptest::collection::vec((arb_id(), inner.clone()), 0..3)) - .prop_map(|(name, id, items)| Value::GlobalObject(Descriptor { name, class_id: id, items })), - (any::(), arb_id(), proptest::collection::vec((arb_id(), inner), 0..3)).prop_map(|(prefix, id, items)| Value::ObjectArray(ObjectArray { - prefix, - body: Descriptor { name: UnicodeString::default(), class_id: id, items } - })), - ] - }); - (arb_ustr(), arb_id(), proptest::collection::vec((arb_id(), value), 0..6)).prop_map(|(name, id, items)| Descriptor { name, class_id: id, items }) - } - - proptest! { - #[test] - fn descriptor_roundtrip(d in arb_descriptor()) { - let b = d.to_bytes(); - let p = Descriptor::from_bytes(&b).unwrap(); - prop_assert_eq!(&p, &d); - prop_assert_eq!(p.to_bytes(), b); - } - - #[test] - fn descriptor_parse_never_panics(data in proptest::collection::vec(any::(), 0..256)) { - let _ = Descriptor::from_bytes(&data); - let _ = VersionedDescriptor::from_bytes(&data); - } - } -} diff --git a/crates/psd/src/error.rs b/crates/psd/src/error.rs deleted file mode 100644 index 2e4f87d849..0000000000 --- a/crates/psd/src/error.rs +++ /dev/null @@ -1,76 +0,0 @@ -//! Error type for every fallible operation in this crate. - -use thiserror::Error; - -/// Errors produced while parsing, decoding, encoding or writing PSD/PSB data. -/// -/// Parsers never panic on malformed input; every failure is reported through -/// this type. -#[derive(Debug, Error, Clone, PartialEq, Eq)] -#[non_exhaustive] -pub enum PsdError { - /// The input ended before a structure was complete. - #[error("unexpected end of data at offset {offset} (needed {needed} more bytes)")] - UnexpectedEof { - /// Offset (relative to the current section) where the read started. - offset: usize, - /// Number of bytes that were requested but not available. - needed: usize, - }, - /// A four-byte signature did not match. - #[error("invalid signature {found:?}, expected {expected}")] - InvalidSignature { - /// Human-readable description of the expected signature(s). - expected: &'static str, - /// The bytes that were found. - found: [u8; 4], - }, - /// Header version is neither 1 (PSD) nor 2 (PSB). - #[error("unsupported file version {0}")] - UnsupportedVersion(u16), - /// A dimension, count or allocation exceeded the configured safety limits. - #[error("limit exceeded: {0}")] - LimitExceeded(&'static str), - /// A buffer could not be allocated: the system has no memory for it. Unlike - /// [`PsdError::LimitExceeded`] the data may be valid, so the caller can retry smaller. - #[error("out of memory: {0}")] - OutOfMemory(&'static str), - /// Structurally invalid data. - #[error("invalid data: {0}")] - Invalid(String), - /// A compressed stream could not be decoded. - #[error("decompression failed: {0}")] - Decompress(String), - /// Reading from a [`crate::source::ReadAt`] source failed. - #[error("read failed: {0}")] - Read(String), - /// An operation is not supported for this data (e.g. unknown compression). - #[error("unsupported: {0}")] - Unsupported(String), -} - -impl PsdError { - pub(crate) fn invalid(msg: impl Into) -> Self { - PsdError::Invalid(msg.into()) - } - - /// The error a streaming write ([`crate::PsdFile::write_to`]) failed with: the serializer's - /// own error when it was one, otherwise the I/O error's text. - pub(crate) fn from_io(e: std::io::Error) -> Self { - let text = e.to_string(); - match e.into_inner().map(|inner| inner.downcast::()) { - Some(Ok(e)) => *e, - _ => PsdError::Invalid(format!("write failed: {text}")), - } - } -} - -/// A serializer error inside a streaming write ([`crate::PsdFile::write_to`]), as `InvalidData`. -impl From for std::io::Error { - fn from(e: PsdError) -> Self { - std::io::Error::new(std::io::ErrorKind::InvalidData, e) - } -} - -/// Convenience alias. -pub type Result = std::result::Result; diff --git a/crates/psd/src/file.rs b/crates/psd/src/file.rs deleted file mode 100644 index 40e8ef5db3..0000000000 --- a/crates/psd/src/file.rs +++ /dev/null @@ -1,1174 +0,0 @@ -//! The top-level [`PsdFile`] model, parser and writer. - -use std::io::{Seek, Write}; - -use crate::compression::{Compression, MAX_DECODED_BYTES, decode_planes, rle_widening_len, validate_planes}; -use crate::error::{PsdError, Result}; -use crate::header::{ColorMode, Header, Version}; -use crate::image_data::ImageData; -use crate::io::{Reader, Sink, WriteExt}; -use crate::layer::{LayerInfo, LayerRecord}; -use crate::resources::{self, ImageResource, ResourceData, ids}; -use crate::tagged::{TaggedBlock, read_blocks_with, uses_long_length, write_blocks_to}; - -/// The name the reference editor gives the merged transparency among the alpha channel names. -const TRANSPARENCY_NAME: &str = "Transparency"; - -/// Where the layer info was stored in the file. -#[derive(Debug, Clone, PartialEq, Eq, Default)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub enum LayerInfoPlacement { - /// In the layer info field of the layer and mask section (8-bit files). - #[default] - Section, - /// In a global `Lr16` / `Lr32` / `Layr` tagged block (16/32-bit files). - /// The block is re-inserted at `index` among `global_blocks` on write. - GlobalBlock { - /// Position among the global blocks. - index: usize, - /// Block signature (`8BIM` / `8B64`). - signature: [u8; 4], - /// Block key. - key: [u8; 4], - /// Padding after the block (see [`TaggedBlock::padding`]). - padding: Option>, - }, -} - -/// Global layer mask info, stored raw with typed accessors. -#[derive(Debug, Clone, PartialEq, Eq, Default)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct GlobalLayerMask { - /// Raw bytes (without the length field). - pub data: Vec, -} - -impl GlobalLayerMask { - /// The mask Photoshop writes for a layered file that has no global mask of its own: - /// an RGB red overlay at 50% opacity, kind 128 (use the per-layer setting), plus the - /// filler byte. A zero-length `LMsk` block in a TIFF makes Photoshop give up on the - /// layer data (it reads this 14-byte record from the block). - pub fn photoshop_default() -> Self { - GlobalLayerMask { data: vec![0, 0, 0xff, 0xff, 0, 0, 0, 0, 0, 0, 0, 50, 128, 0] } - } - - fn be16(&self, at: usize) -> Option { - self.data.get(at..at + 2).map(|b| u16::from_be_bytes([b[0], b[1]])) - } - /// Overlay color space. - pub fn overlay_color_space(&self) -> Option { - self.be16(0) - } - /// Four color components. - pub fn color_components(&self) -> Option<[u16; 4]> { - Some([self.be16(2)?, self.be16(4)?, self.be16(6)?, self.be16(8)?]) - } - /// Opacity 0..=100. - pub fn opacity(&self) -> Option { - self.be16(10) - } - /// Kind: 0 color selected, 1 color protected, 128 per layer. - pub fn kind(&self) -> Option { - self.data.get(12).copied() - } -} - -/// A complete PSD/PSB file. -/// -/// `PsdFile::from_bytes(b)?.to_bytes()? == b` for unmodified well-formed -/// files, and `from_bytes(to_bytes(f)) == f` for any model `f` not marked -/// [`Self::write_as_psb`] (a marked one reads back as the PSB it was written as). -#[derive(Debug, Clone, PartialEq)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct PsdFile { - /// Header. - pub header: Header, - /// Color mode data (palette for indexed, duotone spec, …). - pub color_mode_data: Vec, - /// Image resources in stored order. - pub resources: Vec, - /// Layer info (`None` when the layer info length is 0 and no - /// `Lr16`/`Lr32` block holds layers). - pub layer_info: Option, - /// Where [`Self::layer_info`] is stored. - pub layer_info_placement: LayerInfoPlacement, - /// Global layer mask (`None` when the field is absent entirely). - pub global_layer_mask: Option, - /// Global additional layer information blocks. - pub global_blocks: Vec, - /// Bytes at the end of the layer and mask section that do not form a block. - pub layer_mask_trailing: Vec, - /// Merged image data. - pub image_data: ImageData, - /// Write this PSD model as PSB (set by [`Self::promote_to_psb`]). The model itself stays a - /// consistent PSD: its channel data keeps two-byte RLE row counts, so everything that reads - /// it with `header.version` decodes it as before. Only [`Self::to_bytes`] and - /// [`Self::encoded_len`] write version 2, long length fields and four-byte row counts. - #[cfg_attr(feature = "serde", serde(default))] - pub write_as_psb: bool, -} - -impl PsdFile { - /// Parses a PSD or PSB file. - pub fn from_bytes(data: &[u8]) -> Result { - Self::from_bytes_and_merged(data, |_| false).map(|(file, _)| file) - } - - /// Parses like [`Self::from_bytes`] and, when `want` asks for it, decodes the merged image - /// (as [`Self::decode_merged`] would) in the same pass. `want` sees the file with everything - /// but the merged image parsed (its `image_data` is still empty). A ZIP merged image is then - /// inflated once for both the parse's check of the stream and the decode, not twice. - /// - /// Errors are those of [`Self::from_bytes`]; a merged image that passes the parse's check but - /// cannot be decoded is the inner `Err`, as [`Self::decode_merged`] would return it. - pub fn from_bytes_and_merged(data: &[u8], want: impl FnOnce(&PsdFile) -> bool) -> Result<(Self, Option>>)> { - Self::from_bytes_and_merged_recovering(data, want, |_| false) - } - - /// Parses like [`Self::from_bytes_and_merged`], but lets `recover` accept damage confined to - /// the merged image after all layer records and channel data have been read successfully. - /// Callers should opt in only when they can reconstruct the document from its layers. - /// - /// The original encoded image bytes are preserved and its parse error is returned as - /// `Some(Err(..))`, even when `want` did not request a decode. The caller must report that - /// error rather than silently discarding the merged image (which may hold extra channels). - /// Header/layer errors, missing compression fields and parse safety-limit errors stay fatal. - pub fn from_bytes_and_merged_recovering( - data: &[u8], - want: impl FnOnce(&PsdFile) -> bool, - recover: impl FnOnce(&PsdFile) -> bool, - ) -> Result<(Self, Option>>)> { - Self::from_bytes_and_merged_preflight(data, |file| Ok(want(file)), recover) - } - - /// Parses with a fallible pixel preflight after layer records are available and before - /// the merged image is read, validated or decoded. A preflight error is always fatal; - /// `recover` only handles damage encountered after an accepted preflight. - pub fn from_bytes_and_merged_preflight( - data: &[u8], - want: impl FnOnce(&PsdFile) -> Result, - recover: impl FnOnce(&PsdFile) -> bool, - ) -> Result<(Self, Option>>)> { - let mut r = Reader::new(data); - let header = Header::read(&mut r)?; - let psb = header.version.is_psb(); - let cmd_len = r.u32()?; - let color_mode_data = r.bytes_u64(u64::from(cmd_len))?.to_vec(); - let resources = resources::read_section(&mut r)?; - - let lm_len = r.len_field(psb)?; - let mut s = r.sub(lm_len)?; - let mut layer_info = None; - let mut global_layer_mask = None; - let mut global_blocks = Vec::new(); - let mut layer_mask_trailing = Vec::new(); - let mut layer_info_placement = LayerInfoPlacement::Section; - if !s.is_empty() { - let li_len = s.len_field(psb)?; - if li_len > 0 { - layer_info = Some(LayerInfo::read_body(s.bytes_u64(li_len)?, header.version)?); - } - if s.remaining() >= 4 { - let n = s.u32()?; - global_layer_mask = Some(GlobalLayerMask { data: s.bytes_u64(u64::from(n))?.to_vec() }); - let target_key = match header.depth { - 16 => Some(b"Lr16"), - 32 => Some(b"Lr32"), - _ => None, - }; - let mut candidate_seen = match target_key { - Some(_) => false, - None => layer_info.as_ref().is_some_and(|i| !i.layers.is_empty()), - }; - let (blocks, trailing) = read_blocks_with(&mut s, header.version, 4, |index, signature, key, data, padding| { - let is_candidate = match target_key { - Some(k) => &key == k, - None => matches!(&key, b"Layr" | b"Lr16" | b"Lr32"), - }; - if candidate_seen || !is_candidate { - return false; - } - candidate_seen = true; - let Ok(info) = LayerInfo::read_body(data, header.version) else { - if target_key.is_some() { - layer_info = None; - } - return false; - }; - layer_info = Some(info); - layer_info_placement = LayerInfoPlacement::GlobalBlock { index, signature, key, padding: padding.clone() }; - true - })?; - global_blocks = blocks; - layer_mask_trailing = trailing; - } else { - layer_mask_trailing = s.peek_rest().to_vec(); - } - } - let image_data = ImageData::new(Compression::Raw, Vec::new()); - let mut file = PsdFile { - header, - color_mode_data, - resources, - layer_info, - layer_info_placement, - global_layer_mask, - global_blocks, - layer_mask_trailing, - image_data, - write_as_psb: false, - }; - let want = want(&file)?; - let merged = file.read_merged_section(&mut r, want, recover)?; - Ok((file, merged)) - } - - /// Reads the merged image section from `rest` (the bytes after the layer and mask section) as - /// [`Self::from_bytes_and_merged_recovering`] does, decoding it. - pub(crate) fn read_merged_recovering(&mut self, rest: &[u8], recover: impl FnOnce(&PsdFile) -> bool) -> Result>>> { - self.read_merged_section(&mut Reader::new(rest), true, recover) - } - - fn read_merged_section(&mut self, r: &mut Reader<'_>, want: bool, recover: impl FnOnce(&PsdFile) -> bool) -> Result>>> { - let file = self; - let mut encoded = r.clone(); - let merged = if want { - file.read_merged_decoded(r).map(Some) - } else { - ImageData::read(r, &file.header).map(|image| { - file.image_data = image; - None - }) - }; - let merged = match merged { - Ok(merged) => merged, - Err(error) => { - if matches!(error, PsdError::LimitExceeded(_) | PsdError::OutOfMemory(_)) || !recover(file) { - return Err(error); - } - let compression = Compression::from_u16(encoded.u16()?); - let source = encoded.peek_rest(); - let mut data = Vec::new(); - data.try_reserve_exact(source.len()).map_err(|_| PsdError::OutOfMemory("not enough memory to retain the damaged merged image"))?; - data.extend_from_slice(source); - file.image_data = ImageData::new(compression, data); - Some(Err(error)) - } - }; - Ok(merged) - } - - /// Reads the merged image into `self.image_data` and decodes it. A ZIP stream within the - /// decode limit is inflated once: by the decode, whose stream check is the one - /// [`ImageData::read`] makes. Only when the decode fails is that check run on its own, to - /// tell a file the parse rejects (the outer `Err`) from one it accepts (the inner `Err`). - fn read_merged_decoded(&mut self, r: &mut Reader<'_>) -> Result>> { - let layout = ImageData::layout(&self.header); - let compression = Compression::from_u16(r.clone().u16()?); - let zip = matches!(compression, Compression::Zip | Compression::ZipPrediction); - if !zip || layout.total_bytes()? > MAX_DECODED_BYTES { - // Nothing is inflated twice here: the parse's check reads sizes only (or skips an - // over-limit stream, which the decode then refuses). - self.image_data = ImageData::read(r, &self.header)?; - return Ok(self.decode_merged()); - } - r.u16()?; - let data = r.peek_rest(); - let decoded = decode_planes(compression, data, &layout); - // An allocation failure is the open's, before the encoded image is copied under the same - // memory pressure. - if matches!(decoded, Err(PsdError::OutOfMemory(_))) { - return decoded.map(Ok); - } - if decoded.is_err() { - validate_planes(compression, data, &layout)?; - } - let mut copy = Vec::new(); - copy.try_reserve_exact(data.len()).map_err(|_| PsdError::OutOfMemory("not enough memory to retain the merged image"))?; - copy.extend_from_slice(data); - self.image_data = ImageData::new(compression, copy); - r.skip(data.len())?; - Ok(decoded) - } - - /// Serializes the file. - pub fn to_bytes(&self) -> Result> { - // Reserve the whole file up front, at its exact length: appending to an empty Vec - // reallocates and copies repeatedly, and a reserve that is a few bytes short makes the - // last append double the buffer, copying the whole file once more (#6607). - let len = usize::try_from(self.encoded_len()?).map_err(|_| PsdError::OutOfMemory("not enough memory to serialize the file"))?; - let mut out = Vec::new(); - out.try_reserve_exact(len).map_err(|_| PsdError::OutOfMemory("not enough memory to serialize the file"))?; - self.write_to(&mut std::io::Cursor::new(&mut out)).map_err(PsdError::from_io)?; - Ok(out) - } - - /// Serializes the file into `w` as it goes, starting at its current position: the bytes of - /// [`Self::to_bytes`] without ever holding them all (#6607). Length fields are back-patched - /// by seeking. A serializer error (a section too large for PSD, too many layers) comes back - /// as `InvalidData` carrying the [`PsdError`]; `w` then holds a partial file. - pub fn write_to(&self, w: &mut W) -> std::io::Result<()> { - let mut out = Sink::new(w)?; - self.write_staging(&mut out)?; - out.finish() - } - - /// [`Self::write_to`] writing out every `stage` staged bytes (tests: tiny stages make every - /// length field a back-patch through the stream). - #[cfg(test)] - pub(crate) fn write_to_staging(&self, w: &mut W, stage: usize) -> std::io::Result<()> { - let mut out = Sink::staging(w, stage)?; - self.write_staging(&mut out)?; - out.finish() - } - - fn write_staging(&self, out: &mut Sink<'_, W>) -> std::io::Result<()> { - let (v, widen) = self.write_form(); - let psb = v.is_psb(); - out.small(|o| { - if widen { - Header { version: v, ..self.header.clone() }.write(o); - } else { - self.header.write(o); - } - o.put_len(self.color_mode_data.len() as u64, false) - })?; - out.put(&self.color_mode_data)?; - out.small(|o| resources::write_section(o, &self.resources))?; - - let in_block = matches!(self.layer_info_placement, LayerInfoPlacement::GlobalBlock { .. }); - // An entirely empty section is written as a zero length. (A section - // containing only a zero layer-info length cannot be distinguished in - // the model and is normalized to this form.) - let section_empty = - self.layer_info.is_none() && self.global_layer_mask.is_none() && self.global_blocks.is_empty() && self.layer_mask_trailing.is_empty(); - let at = out.begin_len(psb)?; - if !section_empty { - let li = out.begin_len(psb)?; - if let (Some(info), false) = (&self.layer_info, in_block) { - info.write_body_to(out, v, widen)?; - } - out.end_len(li, psb)?; - let need_mask_field = self.global_layer_mask.is_some() || !self.global_blocks.is_empty() || (in_block && self.layer_info.is_some()); - if need_mask_field { - let gm = self.global_layer_mask.as_ref().map_or(&[][..], |g| &g.data[..]); - out.small(|o| o.put_len(gm.len() as u64, false))?; - out.put(gm)?; - } - match (&self.layer_info, &self.layer_info_placement) { - (Some(info), LayerInfoPlacement::GlobalBlock { index, signature, key, padding }) => { - let (before, after) = self.global_blocks.split_at((*index).min(self.global_blocks.len())); - write_blocks_to(out, before, v, 4)?; - // Written in place: a temporary LayerInfo payload and a clone of all - // neighboring opaque blocks otherwise double the save's memory. - out.put(signature)?; - out.put(key)?; - let long = uses_long_length(v, key); - let at = out.begin_len(long)?; - let start = out.pos(); - info.write_body_to(out, v, widen)?; - let len = out.pos() - start; - out.end_len(at, long)?; - match padding { - Some(bytes) => out.put(bytes)?, - None => out.zeros(((4 - len % 4) % 4) as usize)?, - } - write_blocks_to(out, after, v, 4)?; - } - _ => write_blocks_to(out, &self.global_blocks, v, 4)?, - } - out.put(&self.layer_mask_trailing)?; - } - out.end_len(at, psb)?; - - out.small(|o| { - o.put_u16(self.image_data.compression.as_u16()); - Ok(()) - })?; - match self.merged_widened_rows(widen)? { - 0 => out.put_encoded(&self.image_data.data, self.image_data.stored.as_ref()), - rows => out.put_widened_encoded(&self.image_data.data, self.image_data.stored.as_ref(), rows), - } - } - - /// The version the file is written as, and whether its RLE count tables widen on the way - /// (a PSD model marked [`Self::write_as_psb`]). - fn write_form(&self) -> (Version, bool) { - let widen = self.write_as_psb && !self.header.version.is_psb(); - (if widen { Version::Psb } else { self.header.version }, widen) - } - - /// The version [`Self::to_bytes`] writes: PSB for a PSD model marked [`Self::write_as_psb`], - /// else `header.version`. - pub fn written_version(&self) -> Version { - self.write_form().0 - } - - /// Rows of the merged image's RLE count table that widen on write (0 when nothing widens). - fn merged_widened_rows(&self, widen: bool) -> Result { - if widen && self.image_data.compression == Compression::Rle { ImageData::layout(&self.header).rows() } else { Ok(0) } - } - - /// Bytes every RLE count table of this PSD model grows by when written as PSB; an error - /// when a table is too short for its rows. - fn rle_widening(&self) -> Result { - let mut n = 0u64; - for layer in self.layers() { - for c in &layer.channels { - match layer.widened_rows(c, true)? { - 0 => {} - rows => n = n.saturating_add(rle_widening_len(c.encoded_len(), rows)?), - } - } - } - match self.merged_widened_rows(true)? { - 0 => Ok(n), - rows => Ok(n.saturating_add(rle_widening_len(self.image_data.encoded_len(), rows)?)), - } - } - - /// The number of bytes [`Self::to_bytes`] writes, computed without serializing the channel - /// data (only the layer records, which are small, are serialized to measure them). Lets a - /// writer check a file against the 2 GB PSD limit before choosing the version. - pub fn encoded_len(&self) -> Result { - let (v, widen) = self.write_form(); - let psb = v.is_psb(); - let len_field = |long: bool| if long { 8u64 } else { 4 }; - let blocks_len = |blocks: &[TaggedBlock]| blocks.iter().fold(0u64, |n, b| n.saturating_add(b.encoded_len_aligned(v, 4) as u64)); - let body_len = |info: &LayerInfo| -> Result { - let unpadded = info.unpadded_len_as(v, widen)?; - let padding = info.padding.as_ref().map_or(unpadded % 2, |p| p.len() as u64); - Ok(unpadded.saturating_add(padding)) - }; - // Header, colour mode data, resources. - let mut n = 26u64.saturating_add(4).saturating_add(self.color_mode_data.len() as u64).saturating_add(resources::section_len(&self.resources)); - - let in_block = matches!(self.layer_info_placement, LayerInfoPlacement::GlobalBlock { .. }); - let section_empty = - self.layer_info.is_none() && self.global_layer_mask.is_none() && self.global_blocks.is_empty() && self.layer_mask_trailing.is_empty(); - n = n.saturating_add(len_field(psb)); - if !section_empty { - n = n.saturating_add(len_field(psb)); - if let (Some(info), false) = (&self.layer_info, in_block) { - n = n.saturating_add(body_len(info)?); - } - let need_mask_field = self.global_layer_mask.is_some() || !self.global_blocks.is_empty() || (in_block && self.layer_info.is_some()); - if need_mask_field { - n = n.saturating_add(4).saturating_add(self.global_layer_mask.as_ref().map_or(0, |g| g.data.len() as u64)); - } - n = n.saturating_add(blocks_len(&self.global_blocks)); - if let (Some(info), LayerInfoPlacement::GlobalBlock { key, padding, .. }) = (&self.layer_info, &self.layer_info_placement) { - let body = body_len(info)?; - let pad = padding.as_ref().map_or((4 - body % 4) % 4, |p| p.len() as u64); - n = n.saturating_add(8).saturating_add(len_field(uses_long_length(v, key))).saturating_add(body).saturating_add(pad); - } - n = n.saturating_add(self.layer_mask_trailing.len() as u64); - } - let merged = match self.merged_widened_rows(widen)? { - 0 => 0, - rows => rle_widening_len(self.image_data.encoded_len(), rows)?, - }; - Ok(n.saturating_add(2).saturating_add(self.image_data.encoded_len()).saturating_add(merged)) - } - - /// Marks a PSD model to be written as the equivalent PSB ([`Self::write_as_psb`]) without - /// touching or re-encoding any pixels: the packed RLE rows are the same in both versions, so - /// [`Self::to_bytes`] only widens the row-count tables of the layer channels and the merged - /// image (two to four bytes per row) and the length fields while it copies them out. Raw and - /// ZIP channel data is the same in both. A PSB model is left as is. - /// - /// Every RLE count table is checked first: an error leaves the model unmarked, and the write - /// cannot fail on a table later. The layer info padding is kept; a writer that pads to an - /// alignment re-pads with [`Self::pad_layer_info_to`]. - pub fn promote_to_psb(&mut self) -> Result<()> { - if self.header.version.is_psb() || self.write_as_psb { - return Ok(()); - } - self.rle_widening()?; - self.write_as_psb = true; - Ok(()) - } - - /// Pads the layer info body to a multiple of `align` bytes as it will be written (see - /// [`LayerInfo::pad_to`]), RLE count tables widened when [`Self::write_as_psb`] is set. - pub fn pad_layer_info_to(&mut self, align: u64) -> Result<()> { - let (v, widen) = self.write_form(); - if let Some(info) = &mut self.layer_info { - info.padding = Some(vec![0; info.pad_len_as(v, widen, align)?]); - } - Ok(()) - } - - /// Layer records in file order (bottom-most first); empty if none. - pub fn layers(&self) -> &[LayerRecord] { - self.layer_info.as_ref().map_or(&[], |i| &i.layers) - } - - /// Mutable layer records (creates an empty layer info if absent). - pub fn layers_mut(&mut self) -> &mut Vec { - &mut self.layer_info.get_or_insert_with(LayerInfo::default).layers - } - - /// First resource with the given id. - pub fn resource(&self, id: u16) -> Option<&ImageResource> { - self.resources.iter().find(|r| r.id == id) - } - - /// The global `Lr16` / `Lr32` / `Layr` block that should have held the layers but could not - /// be parsed, with the parse error. [`Self::from_bytes`] keeps such a block verbatim in - /// [`Self::global_blocks`] and leaves [`Self::layer_info`] `None`, so the file reads as - /// flattened; this tells a reader that its layers were lost rather than absent. - pub fn unreadable_layer_block(&self) -> Option<(&TaggedBlock, PsdError)> { - if self.layer_info.is_some() { - return None; - } - let block = self.global_blocks.iter().find(|b| matches!(&b.key, b"Lr16" | b"Lr32" | b"Layr"))?; - LayerInfo::read_body(&block.data, self.header.version).err().map(|e| (block, e)) - } - - /// First global block with the given key. - pub fn global_block(&self, key: &[u8; 4]) -> Option<&TaggedBlock> { - self.global_blocks.iter().find(|b| &b.key == key) - } - - /// ICC profile bytes (resource 1039). - pub fn icc_profile(&self) -> Option<&[u8]> { - self.resource(ids::ICC_PROFILE).map(|r| &r.data[..]) - } - - /// Resolution info (resource 1005). - pub fn resolution(&self) -> Option { - match self.resource(ids::RESOLUTION_INFO)?.parsed()? { - Ok(ResourceData::ResolutionInfo(r)) => Some(r), - _ => None, - } - } - - /// The "has real merged data" flag of resource 1057, if present. - pub fn has_real_merged_data(&self) -> Option { - match self.resource(ids::VERSION_INFO)?.parsed()? { - Ok(ResourceData::VersionInfo(v)) => Some(v.has_real_merged_data), - _ => None, - } - } - - /// Decodes the merged image into planar big-endian samples. - pub fn decode_merged(&self) -> Result> { - self.image_data.decode(&self.header) - } - - /// `true` if the merged image carries a transparency channel: its first extra channel, when - /// the layer count was stored negative or a "Saving Merged Transparency" global block (see - /// [`TaggedBlock::is_merged_transparency_marker`]) is present. Photoshop marks 32-bit documents - /// with `Mt32` and a positive layer count (psd-tools transparentbg.psd). Photoshop also names - /// the merged transparency "Transparency" and lists it first among the alpha channel names, - /// and some of its 16-bit PSBs carry no other mark (#4654): when the layers sit in a global - /// `Lr16`/`Lr32` block, that name with one name per extra channel marks it too. - /// - /// A file without layer records has one when it is marked, or unless its alpha channel names - /// account for every extra channel without "Transparency" among them: a flat file from the - /// reference editor lists only its alpha and spot channels there (psd-tools - /// cmyk-alpha-spot.psd), while writers of plain RGBA files name nothing. - pub fn merged_has_alpha(&self) -> bool { - let Some(color) = self.header.color_mode.color_channels() else { - return false; - }; - if self.header.channels <= color { - return false; - } - let marked = self.global_blocks.iter().any(TaggedBlock::is_merged_transparency_marker); - match &self.layer_info { - Some(li) => { - let in_block = matches!(self.layer_info_placement, LayerInfoPlacement::GlobalBlock { .. }); - li.merged_alpha || marked || (in_block && self.names_merged_transparency(self.header.channels - color)) - } - None => { - let names = self.alpha_channel_names(); - marked || names.len() != usize::from(self.header.channels - color) || names.first().is_some_and(|n| n == TRANSPARENCY_NAME) - } - } - } - - /// `true` when the alpha channel names hold exactly `extra` names and the first is - /// "Transparency", the reference editor's name for the merged transparency. - fn names_merged_transparency(&self, extra: u16) -> bool { - let names = self.alpha_channel_names(); - names.len() == usize::from(extra) && names.first().is_some_and(|n| n == TRANSPARENCY_NAME) - } - - /// The alpha channel names: the Unicode names (resource 1045), else the Pascal names (1006) - /// older writers and psd-tools store alone, decoded as MacRoman. Empty when the file names no channel. - pub fn alpha_channel_names(&self) -> Vec { - let mut names = Vec::new(); - if let Some(r) = self.resource(1045) { - let mut at = 0usize; - while let Some(len) = r.data.get(at..at.saturating_add(4)).and_then(|b| b.try_into().ok()).map(u32::from_be_bytes) { - at = at.saturating_add(4); - let end = (len as usize).checked_mul(2).and_then(|n| at.checked_add(n)); - let Some(units) = end.and_then(|end| r.data.get(at..end)) else { break }; - let units: Vec = units.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes(*c)).collect(); - names.push(String::from_utf16_lossy(&units).trim_end_matches('\0').to_string()); - at = at.saturating_add(units.len() * 2); - } - } else if let Some(r) = self.resource(1006) { - let mut at = 0usize; - while let Some(&len) = r.data.get(at) { - let Some(name) = r.data.get(at + 1..at + 1 + usize::from(len)) else { break }; - names.push(crate::codepage::decode(name, encoding_rs::MACINTOSH)); - at += 1 + usize::from(len); - } - } - names - } - - /// Checks structural invariants that the writer relies on. - pub fn validate(&self) -> Result<()> { - self.header.validate()?; - if self.header.version == Version::Psd && (self.header.width > 30_000 || self.header.height > 30_000) { - return Err(PsdError::LimitExceeded("PSD dimensions exceed 30000; use PSB")); - } - if self.header.color_mode == ColorMode::Indexed && self.color_mode_data.len() != 768 { - return Err(PsdError::invalid("indexed color mode requires a 768-byte palette")); - } - Ok(()) - } - - /// Reads a file from disk (native targets only). - #[cfg(not(target_arch = "wasm32"))] - pub fn open(path: impl AsRef) -> std::io::Result> { - Ok(Self::from_bytes(&std::fs::read(path)?)) - } - - /// Writes the file to disk (native targets only). - #[cfg(not(target_arch = "wasm32"))] - pub fn save(&self, path: impl AsRef) -> std::io::Result<()> { - let b = self.to_bytes().map_err(std::io::Error::other)?; - std::fs::write(path, b) - } -} - -#[cfg(test)] -mod write_tests { - use super::*; - use crate::{Compression, testgen}; - use std::io::{Seek, Write}; - - /// #6607: the output is allocated once, at its exact length. A reserve a few bytes short of - /// the file made the last append double the buffer, copying the whole file once more. - #[test] - fn serializing_allocates_the_output_once() { - for case in testgen::all_cases() { - let bytes = case.file.to_bytes().unwrap(); - assert_eq!(bytes.capacity(), bytes.len(), "{}: the buffer grew past its first allocation", case.name); - } - } - - /// A stream that records the length of every write it gets. - struct Recorder { - inner: std::io::Cursor>, - writes: Vec, - } - - impl Write for Recorder { - fn write(&mut self, b: &[u8]) -> std::io::Result { - self.writes.push(b.len()); - self.inner.write(b) - } - fn flush(&mut self) -> std::io::Result<()> { - Ok(()) - } - } - - impl Seek for Recorder { - fn seek(&mut self, to: std::io::SeekFrom) -> std::io::Result { - self.inner.seek(to) - } - } - - /// The bytes `file` streams with `stage`-byte staging, after a 3-byte prefix that must stay. - fn streamed(file: &PsdFile, stage: usize) -> Vec { - let mut w = std::io::Cursor::new(b"pre".to_vec()); - w.set_position(3); - file.write_to_staging(&mut w, stage).unwrap(); - let end = w.position(); - let mut bytes = w.into_inner(); - assert_eq!(&bytes[..3], b"pre"); - assert_eq!(end, bytes.len() as u64, "the stream must end at the file's end"); - bytes.drain(..3); - bytes - } - - /// #6607: streaming into a file writes exactly the bytes of `to_bytes`, wherever the stream - /// starts and however little is staged, so every length field is back-patched through the - /// stream (both placements of the layer info, PSD and PSB, every mode and depth). - #[test] - fn streaming_writes_the_same_bytes_with_lengths_patched_by_seeking() { - for case in testgen::all_cases() { - let want = case.file.to_bytes().unwrap(); - for stage in [0, 1, 7, 4096] { - assert!(streamed(&case.file, stage) == want, "{} stage {stage}: streamed bytes differ", case.name); - } - let mut w = std::io::Cursor::new(Vec::new()); - case.file.write_to(&mut w).unwrap(); - assert!(w.into_inner() == want, "{}: write_to differs from to_bytes", case.name); - } - } - - /// A PSD model written as PSB (its RLE count tables widened on the way out) and a model read - /// lazily from a source (its pixel data not in memory) stream the bytes `to_bytes` writes. - #[test] - fn promoted_and_source_backed_files_stream_the_same_bytes() { - for case in testgen::all_cases() { - let mut promoted = case.file.clone(); - if promoted.header.version == Version::Psd { - promoted.promote_to_psb().unwrap(); - let want = promoted.to_bytes().unwrap(); - for stage in [0, 1, 7, 4096] { - assert!(streamed(&promoted, stage) == want, "{} promoted, stage {stage}", case.name); - } - } - let bytes = case.file.to_bytes().unwrap(); - let lazy = PsdFile::from_source(crate::Source::new(bytes.clone())).unwrap(); - for stage in [0, 1, 4096] { - assert!(streamed(&lazy, stage) == bytes, "{} source-backed, stage {stage}", case.name); - } - } - } - - /// Channel data and a merged image still in their source (an imported file's, or what an - /// exporter spilled to scratch) are copied through in short reads, never read whole. - #[test] - fn source_backed_data_is_copied_in_short_reads() { - struct Reads(Vec, std::sync::Mutex); - impl crate::source::ReadAt for Reads { - fn len(&self) -> u64 { - self.0.len() as u64 - } - fn read_at(&self, offset: u64, buf: &mut [u8]) -> Result<()> { - let mut most = self.1.lock().unwrap(); - *most = (*most).max(buf.len()); - self.0.as_slice().read_at(offset, buf) - } - } - let mut file = testgen::small(Version::Psd, Compression::Raw); - file.image_data = ImageData::new(Compression::Raw, vec![5; 300]); - let info = file.layer_info.as_mut().unwrap(); - info.layers[0].channels[0] = crate::layer::ChannelData::new(0, Some(Compression::Raw), vec![6; 301]); - let bytes = file.to_bytes().unwrap(); - let source = std::sync::Arc::new(Reads(bytes.clone(), std::sync::Mutex::new(0))); - let lazy = PsdFile::from_source(crate::Source::from_arc(source.clone())).unwrap(); - *source.1.lock().unwrap() = 0; - let mut w = std::io::Cursor::new(Vec::new()); - lazy.write_to(&mut w).unwrap(); - assert!(w.into_inner() == bytes); - assert!(*source.1.lock().unwrap() <= 7, "read {} bytes at once", source.1.lock().unwrap()); - } - - /// #5333: RLE data still in its source widens to PSB in short reads too, the count table and - /// then the packed rows, one consecutive pass, never the whole merged image or channel at once. - #[test] - fn source_backed_rle_widens_in_short_reads() { - struct Reads(Vec, std::sync::Mutex<(usize, Vec<(u64, usize)>)>); - impl crate::source::ReadAt for Reads { - fn len(&self) -> u64 { - self.0.len() as u64 - } - fn read_at(&self, offset: u64, buf: &mut [u8]) -> Result<()> { - let mut seen = self.1.lock().unwrap(); - seen.0 = seen.0.max(buf.len()); - seen.1.push((offset, buf.len())); - self.0.as_slice().read_at(offset, buf) - } - } - let file = testgen::small(Version::Psd, Compression::Rle); - assert_eq!(file.image_data.compression, Compression::Rle); - let bytes = file.to_bytes().unwrap(); - let mut promoted = file.clone(); - promoted.promote_to_psb().unwrap(); - let want = promoted.to_bytes().unwrap(); - let source = std::sync::Arc::new(Reads(bytes, std::sync::Mutex::new((0, Vec::new())))); - let mut lazy = PsdFile::from_source(crate::Source::from_arc(source.clone())).unwrap(); - assert!(lazy.image_data.stored.is_some(), "the merged image stays in the source"); - lazy.promote_to_psb().unwrap(); - *source.1.lock().unwrap() = (0, Vec::new()); - let mut w = std::io::Cursor::new(Vec::new()); - lazy.write_to(&mut w).unwrap(); - assert!(w.into_inner() == want); - let seen = source.1.lock().unwrap(); - assert!(seen.0 <= 7, "read {} bytes at once", seen.0); - let merged = lazy.image_data.stored.as_ref().unwrap(); - let reads: Vec<_> = seen.1.iter().filter(|(at, _)| (merged.offset..merged.offset + merged.len).contains(at)).collect(); - let mut next = merged.offset; - for &&(at, n) in &reads { - assert_eq!(at, next, "the merged image is read in one consecutive pass"); - next += n as u64; - } - assert_eq!(next, merged.offset + merged.len); - } - - /// #6607: pixel data goes to the stream as it is, never through a staging copy, so a save - /// holds the model and a short buffer, not a second copy of the file. - #[test] - fn streaming_passes_channel_and_merged_data_straight_through() { - let mut file = testgen::small(Version::Psd, Compression::Raw); - let big = 3 * crate::io::STAGE_FOR_TESTS; - file.image_data = ImageData::new(Compression::Raw, vec![5; big]); - let info = file.layer_info.as_mut().unwrap(); - info.layers[0].channels[0] = crate::layer::ChannelData::new(0, Some(Compression::Raw), vec![6; big + 1]); - let mut w = Recorder { inner: std::io::Cursor::new(Vec::new()), writes: Vec::new() }; - file.write_to(&mut w).unwrap(); - assert!(w.writes.contains(&big), "merged image data was staged: {:?}", w.writes); - assert!(w.writes.contains(&(big + 1)), "channel data was staged: {:?}", w.writes); - assert!(w.writes.iter().filter(|&&n| n != big && n != big + 1).all(|&n| n < crate::io::STAGE_FOR_TESTS * 2), "{:?}", w.writes); - let bytes = w.inner.into_inner(); - assert_eq!(PsdFile::from_bytes(&bytes).unwrap(), file); - assert!(bytes == file.to_bytes().unwrap()); - } - - /// A serializer error in the middle of a stream comes back as the same `PsdError` `to_bytes` - /// reports, inside an `InvalidData` I/O error. - #[test] - fn streaming_reports_serializer_errors() { - let mut file = testgen::small(Version::Psd, Compression::Raw); - let info = file.layer_info.as_mut().unwrap(); - let layer = info.layers[0].clone(); - info.layers = vec![layer; 32768]; - let want = file.to_bytes().unwrap_err(); - let e = file.write_to(&mut std::io::Cursor::new(Vec::new())).unwrap_err(); - assert_eq!(e.kind(), std::io::ErrorKind::InvalidData); - assert_eq!(PsdError::from_io(e), want); - // A damaged count table of a promoted file fails the stream as it fails `to_bytes`. - let mut file = testgen::small(Version::Psd, Compression::Rle); - file.promote_to_psb().unwrap(); - file.image_data.data.truncate(3); - let want = file.to_bytes().unwrap_err(); - let e = file.write_to(&mut std::io::Cursor::new(Vec::new())).unwrap_err(); - assert_eq!(PsdError::from_io(e), want); - } - - /// `encoded_len` is exactly what `to_bytes` writes, for every generated file and both layer - /// info placements, with and without padding (#5285). - #[test] - fn encoded_len_matches_the_written_length() { - for case in testgen::all_cases() { - assert_eq!(case.file.encoded_len().unwrap(), case.file.to_bytes().unwrap().len() as u64, "{}", case.name); - } - for version in [Version::Psd, Version::Psb] { - for key in [*b"Lr16", *b"Layr"] { - for padding in [None, Some(vec![]), Some(vec![1, 2, 3])] { - for body_padding in [None, Some(vec![]), Some(vec![9])] { - let mut file = testgen::small(version, Compression::Rle); - file.global_blocks = vec![TaggedBlock::new(*b"zz01", vec![11]), TaggedBlock::new(*b"lnk2", vec![1; 5])]; - file.layer_info_placement = LayerInfoPlacement::GlobalBlock { index: 1, signature: *b"8BIM", key, padding: padding.clone() }; - file.layer_info.as_mut().unwrap().padding = body_padding; - file.layer_mask_trailing = vec![0; 3]; - file.resources.push(ImageResource { name: b"odd".to_vec(), ..ImageResource::new(4000, vec![1, 2, 3]) }); - assert_eq!(file.encoded_len().unwrap(), file.to_bytes().unwrap().len() as u64, "{version:?} {key:?}"); - } - } - } - } - let mut empty = testgen::small(Version::Psd, Compression::Raw); - empty.layer_info = None; - empty.global_layer_mask = None; - empty.global_blocks.clear(); - assert_eq!(empty.encoded_len().unwrap(), empty.to_bytes().unwrap().len() as u64); - } - - /// A promoted PSD writes exactly the PSB written directly from the same pixels, and reads - /// back as that PSB; the model itself stays a PSD that every reader decodes as before (#5285). - #[test] - fn promote_to_psb_writes_the_psb_written_directly() { - let cases = testgen::all_cases(); - let (psd, psb) = cases.split_at(cases.len() / 2); - for (psd, psb) in psd.iter().zip(psb) { - assert_eq!(psd.file.header.version, Version::Psd, "{}", psd.name); - let mut promoted = psd.file.clone(); - promoted.promote_to_psb().unwrap(); - assert!(promoted.write_as_psb); - assert_eq!(promoted.written_version(), Version::Psb); - let bytes = promoted.to_bytes().unwrap(); - assert!(bytes == psb.file.to_bytes().unwrap(), "{}", psd.name); - assert_eq!(promoted.encoded_len().unwrap(), bytes.len() as u64, "{}", psd.name); - assert_eq!(PsdFile::from_bytes(&bytes).unwrap(), psb.file, "{}", psd.name); - // Unchanged as a model: same header, same (two-byte) channel data. - assert_eq!(PsdFile { write_as_psb: false, ..promoted.clone() }, psd.file, "{}", psd.name); - assert_eq!(promoted.decode_merged().ok(), psd.file.decode_merged().ok(), "{}", psd.name); - } - } - - /// Re-padding a promoted model pads what is written, as `pad_to` does for a PSB. - #[test] - fn pad_layer_info_to_pads_the_written_body() { - let mut promoted = testgen::layered(Version::Psd, ColorMode::Rgb, 8, Compression::Rle); - promoted.promote_to_psb().unwrap(); - promoted.pad_layer_info_to(4).unwrap(); - let mut direct = testgen::layered(Version::Psb, ColorMode::Rgb, 8, Compression::Rle); - direct.layer_info.as_mut().unwrap().pad_to(Version::Psb, 4).unwrap(); - assert!(promoted.to_bytes().unwrap() == direct.to_bytes().unwrap()); - } - - /// A damaged RLE table is refused before anything changes: the file stays a valid PSD. - #[test] - fn promote_to_psb_is_atomic() { - let mut file = testgen::small(Version::Psd, Compression::Rle); - // The last layer with pixels ("b", two rows): its red channel loses its row-count table. - let layers = &mut file.layer_info.as_mut().unwrap().layers; - let red = layers[2].channels.iter_mut().find(|c| c.id == 0).unwrap(); - assert_eq!(red.compression, Some(Compression::Rle)); - red.data.truncate(1); - let before = file.clone(); - assert!(file.promote_to_psb().is_err()); - assert_eq!(file, before); - assert!(!file.write_as_psb); - // A table damaged after promotion fails the write instead of panicking. - let mut file = testgen::small(Version::Psd, Compression::Rle); - file.promote_to_psb().unwrap(); - file.image_data.data.truncate(3); - assert!(file.to_bytes().is_err()); - assert!(file.encoded_len().is_err()); - } - - #[test] - fn global_blocks_default_to_four_byte_padding() { - for version in [Version::Psd, Version::Psb] { - for len in 1usize..8 { - let mut file = testgen::small(version, Compression::Raw); - file.global_blocks = vec![TaggedBlock::new(*b"TEST", vec![7; len]), TaggedBlock::new(*b"NEXT", vec![8; 4])]; - let bytes = file.to_bytes().unwrap(); - let start = bytes.windows(8).position(|b| b == b"8BIMTEST").unwrap() + 12; - let next = bytes.windows(8).position(|b| b == b"8BIMNEXT").unwrap(); - assert_eq!(next - start, len.next_multiple_of(4)); - let mut expected = vec![7; len]; - expected.resize(len.next_multiple_of(4), 0); - assert_eq!(&bytes[start..start + expected.len()], expected); - assert_eq!(PsdFile::from_bytes(&bytes).unwrap(), file); - } - } - } - - #[test] - fn streamed_layer_info_equals_a_materialized_tagged_block() { - for version in [Version::Psd, Version::Psb] { - for key in [*b"Lr16", *b"Lr32", *b"Layr"] { - for index in [0, 1, 2, usize::MAX] { - for padding in [None, Some(vec![]), Some(vec![42]), Some(vec![1, 2, 3])] { - let mut file = testgen::small(version, Compression::Raw); - file.global_blocks = vec![TaggedBlock::new(*b"zz01", vec![11]), TaggedBlock::new(*b"zz02", vec![12, 13])]; - file.layer_info_placement = LayerInfoPlacement::GlobalBlock { index, signature: *b"8B64", key, padding: padding.clone() }; - // An odd body catches padding calculated against the final file offset. - file.layer_info.as_mut().unwrap().padding = Some(vec![77]); - let actual = file.to_bytes().unwrap(); - let mut materialized = file.clone(); - let mut data = Vec::new(); - materialized.layer_info.take().unwrap().write_body(&mut data, version).unwrap(); - materialized.global_blocks.insert(index.min(materialized.global_blocks.len()), TaggedBlock { signature: *b"8B64", key, data, padding }); - materialized.layer_info_placement = LayerInfoPlacement::Section; - assert_eq!(actual, materialized.to_bytes().unwrap(), "{version:?} {key:?} {index}"); - } - } - } - } - } - - /// #2926: a PSB whose ZIP merged image declares more than `MAX_DECODED_BYTES` opens without - /// inflating that stream (its layers are still usable); decoding the composite reports the limit. - #[test] - fn over_budget_zip_merged_image_opens_without_inflating() { - let mut bytes = testgen::small(Version::Psb, Compression::Zip).to_bytes().unwrap(); - // Header: height at 14, width at 18 (big-endian u32). - bytes[14..18].copy_from_slice(&300_000u32.to_be_bytes()); - bytes[18..22].copy_from_slice(&300_000u32.to_be_bytes()); - let file = PsdFile::from_bytes(&bytes).unwrap(); - assert!(!file.layers().is_empty()); - assert!(matches!(file.decode_merged(), Err(PsdError::LimitExceeded(_)))); - let (same, merged) = PsdFile::from_bytes_and_merged(&bytes, |_| true).unwrap(); - assert_eq!(same, file); - assert!(matches!(merged, Some(Err(PsdError::LimitExceeded(_))))); - } - - /// Parsing and decoding a ZIP merged image together inflates it once (the parse's stream - /// check is the decode's), with the same model, pixels and errors as the two steps apart. - #[test] - fn merged_zip_image_is_inflated_once_when_decoded_with_the_parse() { - use crate::compression::INFLATES; - let inflates = |f: &dyn Fn()| { - let before = INFLATES.with(std::cell::Cell::get); - f(); - INFLATES.with(std::cell::Cell::get) - before - }; - for compression in [Compression::Zip, Compression::ZipPrediction] { - let bytes = testgen::small(Version::Psd, compression).to_bytes().unwrap(); - let file = PsdFile::from_bytes(&bytes).unwrap(); - let pixels = file.decode_merged().unwrap(); - let apart = inflates(&|| drop(PsdFile::from_bytes(&bytes).unwrap().decode_merged().unwrap())); - let together = inflates(&|| { - let (parsed, merged) = PsdFile::from_bytes_and_merged(&bytes, |f| f.image_data.data.is_empty()).unwrap(); - assert_eq!(parsed, file); - assert_eq!(merged.unwrap().unwrap(), pixels); - }); - assert_eq!(together + 1, apart, "{compression:?}"); - let (parsed, merged) = PsdFile::from_bytes_and_merged(&bytes, |_| false).unwrap(); - assert_eq!((parsed, merged), (file, None)); - // A broken stream (its checksum) is still the parse's error. - let mut broken = bytes.clone(); - *broken.last_mut().unwrap() ^= 0xff; - let alone = PsdFile::from_bytes(&broken).unwrap_err().to_string(); - assert_eq!(PsdFile::from_bytes_and_merged(&broken, |_| true).unwrap_err().to_string(), alone); - } - } - - #[test] - fn global_layer_info_serialization_propagates_layer_limit_errors() { - let mut file = testgen::small(Version::Psd, Compression::Raw); - file.layer_info = Some(LayerInfo { layers: vec![LayerRecord::default(); 32768], ..Default::default() }); - file.layer_info_placement = LayerInfoPlacement::GlobalBlock { index: usize::MAX, signature: *b"8BIM", key: *b"Lr16", padding: None }; - assert!(matches!(file.to_bytes(), Err(PsdError::LimitExceeded(_)))); - } - - #[test] - fn global_layers_preserve_order_padding_and_first_candidate() { - for version in [Version::Psd, Version::Psb] { - for depth in [16, 32] { - let mut file = testgen::layered(version, ColorMode::Rgb, depth, Compression::Raw); - let key = if depth == 16 { *b"Lr16" } else { *b"Lr32" }; - file.global_blocks = vec![TaggedBlock::new(*b"BEFR", vec![1]), TaggedBlock::new(*b"AFTR", vec![2, 3])]; - file.layer_info_placement = LayerInfoPlacement::GlobalBlock { index: 1, signature: *b"8B64", key, padding: Some(vec![7, 8]) }; - file.layer_mask_trailing = vec![9; 4]; - let bytes = file.to_bytes().unwrap(); - let parsed = PsdFile::from_bytes(&bytes).unwrap(); - assert_eq!(parsed, file); - assert_eq!(parsed.to_bytes().unwrap(), bytes); - let mut duplicate = Vec::new(); - file.layer_info.as_ref().unwrap().write_body(&mut duplicate, version).unwrap(); - file.global_blocks.push(TaggedBlock::new(key, duplicate)); - assert_eq!(PsdFile::from_bytes(&file.to_bytes().unwrap()).unwrap(), file); - file.global_blocks.insert(0, TaggedBlock::new(key, vec![255])); - let bytes = file.to_bytes().unwrap(); - let parsed = PsdFile::from_bytes(&bytes).unwrap(); - assert!(parsed.layer_info.is_none()); - assert_eq!(parsed.global_blocks.len(), 5); - assert_eq!(parsed.to_bytes().unwrap(), bytes); - } - } - } - - #[test] - fn global_layers_prioritized_over_compatibility_section() { - for version in [Version::Psd, Version::Psb] { - for depth in [16, 32] { - let file = testgen::layered(version, ColorMode::Rgb, depth, Compression::Raw); - let key = if depth == 16 { *b"Lr16" } else { *b"Lr32" }; - let mut lr_data = Vec::new(); - file.layer_info.as_ref().unwrap().write_body(&mut lr_data, version).unwrap(); - - // Photoshop writes an empty (or 8-bit compatibility) Layer Info section - // alongside the global Lr16/Lr32 block (#4928). - let mut compat_file = file.clone(); - compat_file.layer_info = - Some(LayerInfo { layers: vec![LayerRecord { name: b"CompatibilityLayer".to_vec(), ..Default::default() }], ..Default::default() }); - compat_file.layer_info_placement = LayerInfoPlacement::Section; - compat_file.global_blocks.insert(0, TaggedBlock::new(key, lr_data)); - - let bytes = compat_file.to_bytes().unwrap(); - let parsed = PsdFile::from_bytes(&bytes).unwrap(); - - assert_eq!(parsed.layers().len(), file.layers().len()); - assert_ne!(parsed.layers()[0].name(), "CompatibilityLayer"); - assert_eq!(parsed.layers()[0].name(), file.layers()[0].name()); - assert!(matches!(parsed.layer_info_placement, LayerInfoPlacement::GlobalBlock { key: k, .. } if k == key)); - } - } - } - - /// Without layer records, the extra channels hold a merged transparency unless the alpha - /// channel names (1045, else 1006) name each of them and none first as "Transparency". - #[test] - fn a_flat_file_names_its_extra_channels() { - let mut file = testgen::small(Version::Psd, Compression::Raw); - file.layer_info = None; - file.header.channels = 5; - file.resources.retain(|r| r.id != 1045 && r.id != 1006); - assert!(file.merged_has_alpha(), "nothing named: a plain RGBA file"); - let pascal = |names: &[&str]| names.iter().flat_map(|n| std::iter::once(n.len() as u8).chain(n.bytes())).collect::>(); - let unicode = |names: &[&str]| { - names - .iter() - .flat_map(|n| (n.encode_utf16().count() as u32).to_be_bytes().into_iter().chain(n.encode_utf16().flat_map(u16::to_be_bytes))) - .collect::>() - }; - for (names, transparency) in - [(&["Spot", "Alpha 1"][..], false), (&["Transparency", "Alpha 1"][..], true), (&["Alpha 1"][..], true), (&["A", "B", "C"][..], true)] - { - let mut p = file.clone(); - p.resources.push(ImageResource::new(1006, pascal(names))); - assert_eq!(p.merged_has_alpha(), transparency, "1006 {names:?}"); - assert_eq!(p.alpha_channel_names(), names, "1006"); - let mut u = file.clone(); - u.resources.push(ImageResource::new(1045, unicode(names))); - assert_eq!(u.merged_has_alpha(), transparency, "1045 {names:?}"); - assert_eq!(u.alpha_channel_names(), names, "1045"); - } - // A merged-transparency marker wins over a complete list of names. - let mut marked = file.clone(); - marked.resources.push(ImageResource::new(1006, pascal(&["Spot", "Alpha 1"]))); - marked.global_blocks.push(TaggedBlock::new(*b"Mt32", Vec::new())); - assert!(marked.merged_has_alpha()); - // Truncated names stop at the damage. - let mut cut = file.clone(); - cut.resources.push(ImageResource::new(1006, vec![9, b'a'])); - assert!(cut.alpha_channel_names().is_empty()); - assert!(cut.merged_has_alpha()); - } - - #[test] - fn pascal_channel_names_use_mac_roman_and_unicode_takes_precedence() { - let mut file = testgen::small(Version::Psd, Compression::Raw); - file.resources.retain(|r| !matches!(r.id, 1006 | 1045)); - file.resources.push(ImageResource::new(1006, b"\x04caf\x8e\x03x".to_vec())); - assert_eq!(file.alpha_channel_names(), ["café"]); - file.resources.push(ImageResource::new(1045, vec![0, 0, 0, 1, 0, b'Z'])); - assert_eq!(file.alpha_channel_names(), ["Z"]); - } - - /// A positive layer count plus a merged-transparency marker still makes the first extra - /// channel the transparency, for the specification's keys and for the `MTrn` Photoshop - /// writes in a layered TIFF. - #[test] - fn merged_transparency_markers_mark_the_first_extra_channel() { - let mut file = testgen::small(Version::Psd, Compression::Raw); - if let Some(li) = &mut file.layer_info { - li.merged_alpha = false; - } - assert!(!file.merged_has_alpha()); - for key in [*b"Mtrn", *b"MTrn", *b"Mt16", *b"Mt32"] { - let mut marked = file.clone(); - marked.global_blocks.push(TaggedBlock::new(key, Vec::new())); - assert!(marked.merged_has_alpha(), "{}", String::from_utf8_lossy(&key)); - } - // Other keys, and the marker's letters in another case, do not. - for key in [*b"MTRN", *b"mtrn", *b"Lr16"] { - let mut other = file.clone(); - other.global_blocks.push(TaggedBlock::new(key, Vec::new())); - assert!(!other.merged_has_alpha(), "{}", String::from_utf8_lossy(&key)); - } - } - #[test] - fn preflight_refusal_precedes_merged_validation_and_cannot_be_recovered() { - let file = testgen::small(Version::Psd, Compression::Zip); - let mut bytes = file.to_bytes().unwrap(); - bytes.truncate(bytes.len() - file.image_data.data.len() - 2); - let recover_called = std::cell::Cell::new(false); - let preflight = |_: &PsdFile| Err(PsdError::LimitExceeded("test import budget")); - let recover = |_: &PsdFile| { - recover_called.set(true); - true - }; - assert!(matches!(PsdFile::from_bytes_and_merged_preflight(&bytes, preflight, recover), Err(PsdError::LimitExceeded("test import budget")))); - assert!(matches!( - PsdFile::from_source_and_merged_preflight(crate::Source::new(bytes), preflight, recover), - Err(PsdError::LimitExceeded("test import budget")) - )); - assert!(!recover_called.get()); - } -} diff --git a/crates/psd/src/filter_effects.rs b/crates/psd/src/filter_effects.rs deleted file mode 100644 index cadb3ac8d0..0000000000 --- a/crates/psd/src/filter_effects.rs +++ /dev/null @@ -1,344 +0,0 @@ -//! Smart-filter caches: the global `FEid` / `FXid` ("Filter Effects") block. -//! -//! Layout (Adobe PSD spec, "Filter Effects", checked against Photoshop-authored files): -//! -//! ```text -//! u32 version (1..=3), then one section per smart object, each padded with zeros to a multiple -//! of 4 bytes (the padding is not counted in its length): -//! u64 length, then until that length is used up: -//! Pascal identifier (the smart object's `placed` id in its `SoLd` descriptor, unpadded) -//! u32 item version (1), u64 item length, then inside the item: -//! rect (top, left, bottom, right), u32 depth, u32 max channels, -//! max channels + 2 slots: u32 written flag; if set u64 length, u16 compression, data -//! after the item: u8 flag; if set rect, u64 length, u16 compression, data -//! ``` -//! -//! Photoshop puts one item in each section. Files written by earlier PhotoCraft versions put -//! every item in one section; both read. -//! -//! The slots hold the unfiltered placed pixels (colour channels from slot 0, transparency in the -//! last slot) and the trailing image is the filter mask, in document coordinates. RLE row counts -//! are four bytes wide (PSB style) in every file version. - -use crate::compression::{Compression, PlaneLayout, decode_planes, encode_planes}; -use crate::error::{PsdError, Result}; -use crate::header::Version; -use crate::io::{Reader, WriteExt, read_pascal, write_pascal}; -use crate::layer::Rect; - -/// Most slots an item may declare (Photoshop writes 24 channels + 2). -pub(crate) const MAX_SLOTS: u32 = 64; - -/// One compressed plane of a filter-effects item. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct EffectsPlane { - /// Compression of `data`. - pub compression: Compression, - /// Encoded data (without the compression field). - pub data: Vec, -} - -impl EffectsPlane { - /// Encodes one plane of `w × h` big-endian samples of `depth` bits (RLE uses four-byte counts). - pub fn encode(compression: Compression, decoded: &[u8], w: usize, h: usize, depth: u16) -> Result { - let layout = PlaneLayout { planes: 1, width: w, height: h, depth, version: Version::Psb }; - Ok(EffectsPlane { compression, data: encode_planes(compression, decoded, &layout)? }) - } - - /// Decodes into `w × h` big-endian samples of `depth` bits. A size the encoded data can't - /// possibly hold (PackBits expands at most 64×, zlib about 1032×) is refused before any - /// allocation, so a tiny corrupt block can't ask for gigabytes. - pub fn decode(&self, w: usize, h: usize, depth: u16) -> Result> { - let layout = PlaneLayout { planes: 1, width: w, height: h, depth, version: Version::Psb }; - let n = self.data.len() as u64; - let most = match self.compression { - Compression::Raw => n, - Compression::Rle => n.saturating_mul(64), - _ => n.saturating_mul(1032).saturating_add(64), - }; - if layout.total_bytes()? > most { - return Err(PsdError::invalid("filter effects: plane larger than its data can hold")); - } - decode_planes(self.compression, &self.data, &layout) - } -} - -/// The cache of one smart object's filters. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct FilterEffectsItem { - /// The smart object's `placed` id. - pub id: String, - /// Item version (1). - pub version: u32, - /// Bounds of the cached planes. - pub rect: Rect, - /// Bits per sample. - pub depth: u32, - /// Declared channel count; there are `max_channels + 2` slots. - pub max_channels: u32, - /// The slots (`None` = not written). - pub slots: Vec>, - /// The filter mask: bounds and plane. - pub mask: Option<(Rect, EffectsPlane)>, -} - -impl FilterEffectsItem { - /// The decoded filter mask (8/16/32-bit big-endian samples) with its bounds. - pub fn decoded_mask(&self) -> Option<(Rect, Vec)> { - let (r, p) = self.mask.as_ref()?; - let (w, h) = r.size().ok()?; - let depth = u16::try_from(self.depth).ok()?; - Some((*r, p.decode(w, h, depth).ok()?)) - } -} - -/// A parsed `FEid`/`FXid` block. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct FilterEffects { - /// Block version (1..=3). - pub version: u32, - /// One item per smart object with filters. - pub items: Vec, -} - -fn read_rect(r: &mut Reader<'_>) -> Result { - Ok(Rect { top: r.i32()?, left: r.i32()?, bottom: r.i32()?, right: r.i32()? }) -} - -fn write_rect(out: &mut Vec, r: &Rect) { - out.put_i32(r.top); - out.put_i32(r.left); - out.put_i32(r.bottom); - out.put_i32(r.right); -} - -fn read_plane(r: &mut Reader<'_>) -> Result { - let len = r.u64()?; - if len < 2 { - return Err(PsdError::invalid("filter effects: plane shorter than its compression field")); - } - let mut sub = r.sub(len)?; - let compression = Compression::from_u16(sub.u16()?); - Ok(EffectsPlane { compression, data: sub.peek_rest().to_vec() }) -} - -/// One item: identifier, the cached planes and the filter mask after them. -fn read_item(body: &mut Reader<'_>) -> Result { - let id = String::from_utf8_lossy(&read_pascal(body, 1)?).into_owned(); - let version = body.u32()?; - let item_len = body.u64()?; - let mut it = body.sub(item_len)?; - let rect = read_rect(&mut it)?; - let depth = it.u32()?; - let max_channels = it.u32()?; - if max_channels > MAX_SLOTS { - return Err(PsdError::LimitExceeded("filter effects: too many channels")); - } - let mut slots = Vec::with_capacity(max_channels as usize + 2); - for _ in 0..max_channels + 2 { - slots.push(if it.u32()? != 0 { Some(read_plane(&mut it)?) } else { None }); - } - if !it.is_empty() { - return Err(PsdError::invalid(format!("filter effects item {id}: {} unexpected bytes", it.remaining()))); - } - let mask = if body.is_empty() || body.u8()? == 0 { None } else { Some((read_rect(body)?, read_plane(body)?)) }; - Ok(FilterEffectsItem { id, version, rect, depth, max_channels, slots, mask }) -} - -fn write_plane(out: &mut Vec, p: &EffectsPlane) { - out.put_u64(p.data.len() as u64 + 2); - out.put_u16(p.compression.as_u16()); - out.put(&p.data); -} - -impl FilterEffects { - /// Parses block data. Trailing zero padding is allowed. - pub fn parse(data: &[u8]) -> Result { - let mut r = Reader::new(data); - let version = r.u32()?; - if !(1..=3).contains(&version) { - return Err(PsdError::invalid(format!("filter effects version {version}"))); - } - let mut items = Vec::new(); - loop { - let len = r.u64()?; - let mut body = r.sub(len)?; - while !body.is_empty() { - items.push(read_item(&mut body)?); - } - // Sections are padded to 4 bytes; the last one's padding may be cut short. - let pad = ((4 - len % 4) % 4).min(r.remaining() as u64); - r.skip(pad as usize)?; - let rest = r.peek_rest(); - // Padding is at most three bytes. Check the length before scanning the suffix for - // zeros: otherwise each iteration scans the whole remaining buffer, which is - // quadratic when a block contains many empty sections. - if rest.len() <= 3 && rest.iter().all(|&b| b == 0) { - break; - } - if rest.len() < 8 { - return Err(PsdError::invalid(format!("filter effects: {} unexpected bytes after the data", rest.len()))); - } - } - Ok(FilterEffects { version, items }) - } - - /// Serializes block data as Photoshop writes it: one section per item, each padded to 4 bytes. - pub fn to_bytes(&self) -> Vec { - let mut out = Vec::new(); - out.put_u32(self.version); - for it in &self.items { - let mut body = Vec::new(); - write_pascal(&mut body, it.id.as_bytes(), 1); - body.put_u32(it.version); - let at = body.begin_len(true); - write_rect(&mut body, &it.rect); - body.put_u32(it.depth); - body.put_u32(it.max_channels); - for i in 0..it.max_channels as usize + 2 { - match it.slots.get(i).and_then(Option::as_ref) { - Some(p) => { - body.put_u32(1); - write_plane(&mut body, p); - } - None => body.put_u32(0), - } - } - // A 64-bit length never overflows. - let _ = body.end_len(at, true); - match &it.mask { - Some((r, p)) => { - body.put_u8(1); - write_rect(&mut body, r); - write_plane(&mut body, p); - } - None => body.put_u8(0), - } - out.put_u64(body.len() as u64); - out.put(&body); - out.resize(out.len().next_multiple_of(4), 0); - } - if self.items.is_empty() { - // One empty section, so the block still parses. - out.put_u64(0); - } - out - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn item(id: &str, mask: bool) -> FilterEffectsItem { - let rect = Rect { top: 1, left: 2, bottom: 5, right: 6 }; - let plane = |v: u8| EffectsPlane::encode(Compression::Rle, &[v; 16], 4, 4, 8).unwrap(); - let mut slots = vec![None; 26]; - slots[0] = Some(plane(10)); - slots[25] = Some(plane(255)); - FilterEffectsItem { id: id.into(), version: 1, rect, depth: 8, max_channels: 24, slots, mask: mask.then(|| (rect, plane(128))) } - } - - /// The unpadded body of `it`'s section. - fn section(it: &FilterEffectsItem) -> Vec { - let b = FilterEffects { version: 3, items: vec![it.clone()] }.to_bytes(); - let len = u64::from_be_bytes(b[4..12].try_into().unwrap()) as usize; - b[12..12 + len].to_vec() - } - - #[test] - fn round_trips() { - let fx = FilterEffects { version: 3, items: vec![item("a-1", true), item("b-2", false)] }; - let bytes = fx.to_bytes(); - let back = FilterEffects::parse(&bytes).unwrap(); - assert_eq!(back, fx); - assert_eq!(back.items[0].decoded_mask().unwrap().1, vec![128; 16]); - assert_eq!(back.items[0].slots[0].as_ref().unwrap().decode(4, 4, 8).unwrap(), vec![10; 16]); - // No items still writes a section, so the block reads back. - let empty = FilterEffects { version: 3, items: Vec::new() }; - assert_eq!(FilterEffects::parse(&empty.to_bytes()).unwrap(), empty); - // Padding is allowed, garbage is not. - let mut padded = bytes.clone(); - padded.extend([0, 0]); - assert!(FilterEffects::parse(&padded).is_ok()); - padded.push(7); - assert!(FilterEffects::parse(&padded).is_err()); - } - - /// Photoshop writes one length-prefixed section per smart object, each padded to 4 bytes - /// outside its length. A document with two filtered smart objects has two sections. - #[test] - fn reads_one_section_per_item() { - let (a, b) = (item("a-1", true), item("b-2", false)); - let (sa, sb) = (section(&a), section(&b)); - assert_ne!(sa.len() % 4, 0, "the first section must need padding"); - let mut bytes = 3u32.to_be_bytes().to_vec(); - for s in [&sa, &sb] { - bytes.extend((s.len() as u64).to_be_bytes()); - bytes.extend(s.iter()); - bytes.resize(bytes.len().next_multiple_of(4), 0); - } - let fx = FilterEffects::parse(&bytes).unwrap(); - assert_eq!(fx.items, vec![a, b]); - // Written back the same way, byte for byte. - assert_eq!(fx.to_bytes(), bytes); - } - - /// Older PhotoCraft files hold every item in one section; they still read. - #[test] - fn reads_several_items_in_one_section() { - let items = vec![item("a-1", true), item("b-2", false)]; - let mut body = Vec::new(); - for it in &items { - body.extend(section(it)); - } - let mut bytes = 3u32.to_be_bytes().to_vec(); - bytes.extend((body.len() as u64).to_be_bytes()); - bytes.extend(&body); - assert_eq!(FilterEffects::parse(&bytes).unwrap().items, items); - } - - #[test] - fn mutations_never_panic() { - let bytes = FilterEffects { version: 3, items: vec![item("x", true), item("y", false)] }.to_bytes(); - let mut state = 0x9E37_79B9_7F4A_7C15u64; - let mut next = || { - state ^= state << 13; - state ^= state >> 7; - state ^= state << 17; - state - }; - for _ in 0..2000 { - let mut b = bytes.clone(); - for _ in 0..1 + next() % 4 { - let i = (next() as usize) % b.len(); - b[i] = next() as u8; - } - if let Ok(fx) = FilterEffects::parse(&b) { - for it in &fx.items { - let _ = it.decoded_mask(); - for p in it.slots.iter().flatten() { - let _ = p.decode(4, 4, 8); - } - } - let _ = fx.to_bytes(); - } - } - } - - #[test] - fn malformed_data_is_an_error() { - let bytes = FilterEffects { version: 3, items: vec![item("x", true)] }.to_bytes(); - // Cut anywhere before the end of the section (only its padding may be missing). - for n in 0..12 + section(&item("x", true)).len() { - assert!(FilterEffects::parse(&bytes[..n]).is_err(), "truncated at {n}"); - } - let mut bad = bytes.clone(); - bad[3] = 9; // version - assert!(FilterEffects::parse(&bad).is_err()); - let mut huge = bytes; - let at = 4 + 8 + 2 + 4 + 8 + 16 + 4; // max channels - huge[at..at + 4].copy_from_slice(&u32::MAX.to_be_bytes()); - assert!(FilterEffects::parse(&huge).is_err()); - } -} diff --git a/crates/psd/src/fixtures.rs b/crates/psd/src/fixtures.rs deleted file mode 100644 index a8bd3d43c9..0000000000 --- a/crates/psd/src/fixtures.rs +++ /dev/null @@ -1,566 +0,0 @@ -//! Large-file stress fixtures (#216, #375, #5107), generated at test time and never committed. -//! -//! Two kinds: -//! -//! * [`Streamed`]: multi-gigabyte PSD/PSB files written straight from the Adobe PSD spec, row by -//! row, independent of [`PsdBuilder`] (so they don't inherit its limits, and the generator needs -//! a few megabytes of memory however large the file). One full-canvas layer (or none) plus the -//! merged image; every sample follows [`sample`], so a reader can check any pixel without the -//! file. [`PSD_2GB`] is the largest file Photoshop saves as PSD; [`PSB_CASES`] are 2.5–5 GB. -//! * [`Grid`]: layered documents built with [`PsdBuilder`]: a background and a grid of layers in -//! groups, with masks, a hidden layer, a closed group and drop shadows. [`LAYERED_20MP`] is the -//! 20 MP photo-sized document, [`LAYOUT_150`] the 150-layer A4 layout. [`Grid::expected`] is -//! the merged colour at any point away from the shadows ([`Grid::sample_points`] lists some). -//! -//! Photoshop's limits, which the presets respect: a PSD is at most 30,000 px a side and 2 GB; a -//! PSB at most 300,000 px a side. - -use std::io::{self, Write}; - -use crate::builder::{GroupSpec, LayerSpec, MaskSpec, PixelData, PsdBuilder}; -use crate::descriptor::{Descriptor, Id, Value, VersionedDescriptor}; -use crate::error::Result; -use crate::file::PsdFile; -use crate::header::Version; -use crate::layer::Rect; -use crate::tagged::TaggedBlock; - -/// Photoshop's PSD size cap: files of 2 GB or more must be PSB. -pub const PSD_MAX_FILE_BYTES: u64 = 2 * 1024 * 1024 * 1024; -/// Photoshop's PSD side cap (px). -pub const PSD_MAX_SIDE: u32 = 30_000; - -/// The 8-bit sample of channel `c` (0..3 colour, 3 = alpha) at (`x`, `y`) in a [`Streamed`] file. -/// RLE files ignore `x` (constant rows compress to almost nothing). 16-bit files store `v * 257`. -pub fn sample(x: u32, y: u32, c: u32, rle: bool) -> u8 { - if c == 3 { - return 255; - } - let x = if rle { 0 } else { x }; - (x.wrapping_mul(7).wrapping_add(y.wrapping_mul(13)).wrapping_add(c.wrapping_mul(101)) & 0xff) as u8 -} - -/// A large RGB file written row by row. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct Streamed { - /// File stem, e.g. `layer8-25k-raw`. - pub name: &'static str, - /// PSD (v1) or PSB (v2). - pub version: Version, - /// Width (px). - pub width: u32, - /// Height (px). - pub height: u32, - /// 8 or 16. - pub depth: u16, - /// RLE (PackBits) or raw. - pub rle: bool, - /// One full-canvas RGBA layer, or a flattened file (merged image only). - pub layered: bool, -} - -const fn psb(name: &'static str, side: u32, depth: u16, rle: bool, layered: bool) -> Streamed { - Streamed { name, version: Version::Psb, width: side, height: side, depth, rle, layered } -} - -/// A 2.02 GB layered PSD just under Photoshop's 2 GB cap: 17,000² px, one raw full-canvas layer -/// (289 MB per channel) and the raw merged image. -pub const PSD_2GB: Streamed = - Streamed { name: "psd-layer8-17k-raw", version: Version::Psd, width: 17_000, height: 17_000, depth: 8, rle: false, layered: true }; - -/// The 4–5 GB PSB generated for #375 and #216. -pub const PSB_CASES: [Streamed; 5] = [ - // Composite of 2.52 GB in one decode. - psb("flat8-29k-rle", 29_000, 8, true, false), - // A 4.8 GB file: offsets past 4 GiB, composite of 4.8 GB. - psb("flat8-40k-raw", 40_000, 8, false, false), - // 4.38 GB file: layer channels of 625 MB, composite of 1.9 GB, data past 4 GiB. - PSB_4GB, - // Layer channels of 900 MB, composite of 2.7 GB. - psb("layer8-30k-rle", 30_000, 8, true, true), - // Layer channels of 2.18 GB each. - psb("layer16-33k-rle", 33_000, 16, true, true), -]; - -/// The layered 4.38 GB PSB (25,000² px, raw): layer data and the merged image past 4 GiB. -pub const PSB_4GB: Streamed = psb("layer8-25k-raw", 25_000, 8, false, true); - -/// PackBits for a row of `n` equal bytes: runs of 128, then the remainder. -fn rle_row(n: u64, b: u8) -> Vec { - let mut v = Vec::new(); - let mut left = n; - while left > 0 { - let run = left.min(128); - // `run` is 1..=128, so 257 - run is 129..=256 and only 256 (run 1) needs the literal form. - if run == 1 { - v.extend_from_slice(&[0, b]); - } else { - v.extend_from_slice(&[(257 - run) as u8, b]); - } - left -= run; - } - v -} - -fn too_big(what: &str) -> io::Error { - io::Error::new(io::ErrorKind::InvalidInput, format!("{what} does not fit a PSD (v1) length field")) -} - -impl Streamed { - /// File name with the right extension. - pub fn file_name(&self) -> String { - format!("{}.{}", self.name, if self.version == Version::Psb { "psb" } else { "psd" }) - } - - fn psb(&self) -> bool { - self.version == Version::Psb - } - - fn row_bytes(&self) -> u64 { - u64::from(self.width) * u64::from(self.depth / 8) - } - - /// Bytes of one RLE row (all rows have the same length). - fn rle_len(&self) -> u64 { - rle_row(self.row_bytes(), 0).len() as u64 - } - - /// Size of a channel block (compression word, RLE counts, rows) of `planes` planes. - fn channel_len(&self, planes: u64) -> u64 { - let rows = planes * u64::from(self.height); - let count = if self.psb() { 4 } else { 2 }; - if self.rle { 2 + rows * count + rows * self.rle_len() } else { 2 + rows * self.row_bytes() } - } - - /// Writes a length field: 8 bytes in PSB, 4 in PSD. - fn put_len(&self, out: &mut Vec, v: u64, what: &str) -> io::Result<()> { - if self.psb() { - out.extend_from_slice(&v.to_be_bytes()); - } else { - out.extend_from_slice(&u32::try_from(v).map_err(|_| too_big(what))?.to_be_bytes()); - } - Ok(()) - } - - /// The layer record of the one layer: transparency (-1), then R, G, B. - fn layer_record(&self) -> io::Result> { - let ch_len = self.channel_len(1); - let name = b"Layer 1"; - let mut rec = Vec::new(); - for v in [0, 0, self.height, self.width] { - rec.extend_from_slice(&v.to_be_bytes()); - } - rec.extend_from_slice(&4u16.to_be_bytes()); - for id in [-1i16, 0, 1, 2] { - rec.extend_from_slice(&id.to_be_bytes()); - self.put_len(&mut rec, ch_len, "a layer channel")?; - } - rec.extend_from_slice(b"8BIMnorm"); - rec.extend_from_slice(&[255, 0, 0, 0]); - // Extra data: no mask, no blending ranges, the Pascal name padded to 4. - let mut extra = Vec::new(); - extra.extend_from_slice(&0u32.to_be_bytes()); - extra.extend_from_slice(&0u32.to_be_bytes()); - extra.push(name.len() as u8); - extra.extend_from_slice(name); - while extra.len() % 4 != 0 { - extra.push(0); - } - rec.extend_from_slice(&(extra.len() as u32).to_be_bytes()); - rec.extend_from_slice(&extra); - Ok(rec) - } - - /// The header, the empty colour-mode and resource sections and the layer section's head - /// (everything before the first channel's data), plus the padding after the layer channels. - fn head(&self) -> io::Result<(Vec, u64)> { - let mut h = Vec::new(); - h.extend_from_slice(b"8BPS"); - h.extend_from_slice(&(if self.psb() { 2u16 } else { 1 }).to_be_bytes()); - h.extend_from_slice(&[0; 6]); - h.extend_from_slice(&3u16.to_be_bytes()); - h.extend_from_slice(&self.height.to_be_bytes()); - h.extend_from_slice(&self.width.to_be_bytes()); - h.extend_from_slice(&self.depth.to_be_bytes()); - h.extend_from_slice(&3u16.to_be_bytes()); - // Colour mode data and image resources: empty. - h.extend_from_slice(&0u32.to_be_bytes()); - h.extend_from_slice(&0u32.to_be_bytes()); - if !self.layered { - self.put_len(&mut h, 0, "the layer section")?; - return Ok((h, 0)); - } - let rec = self.layer_record()?; - let mut layer_info = 2 + rec.len() as u64 + 4 * self.channel_len(1); - let pad = layer_info % 2; - layer_info += pad; - let len_field = if self.psb() { 8 } else { 4 }; - // Layer and mask information: layer info (with its length) + empty global mask (4 bytes). - self.put_len(&mut h, len_field + layer_info + 4, "the layer section")?; - self.put_len(&mut h, layer_info, "the layer info")?; - h.extend_from_slice(&1i16.to_be_bytes()); - h.extend_from_slice(&rec); - Ok((h, pad)) - } - - /// The exact size of the file [`write_to`](Self::write_to) writes, without writing it. - pub fn file_len(&self) -> io::Result { - let (head, pad) = self.head()?; - let layers = if self.layered { 4 * self.channel_len(1) + pad + 4 } else { 0 }; - Ok(head.len() as u64 + layers + self.channel_len(3)) - } - - /// The bytes of one row of channel `ch`. - fn row(&self, y: u32, ch: u32, buf: &mut Vec) { - buf.clear(); - if self.rle { - buf.extend(rle_row(self.row_bytes(), sample(0, y, ch, true))); - return; - } - for x in 0..self.width { - let s = sample(x, y, ch, false); - buf.push(s); - if self.depth == 16 { - buf.push(s); - } - } - } - - /// Writes the compression word, the counts and the rows of `chans`. - fn write_planes(&self, w: &mut impl Write, chans: &[u32]) -> io::Result<()> { - w.write_all(&u16::from(self.rle).to_be_bytes())?; - if self.rle { - let n = self.rle_len(); - let one = if self.psb() { - u32::try_from(n).map_err(|_| too_big("an RLE row"))?.to_be_bytes().to_vec() - } else { - u16::try_from(n).map_err(|_| too_big("an RLE row"))?.to_be_bytes().to_vec() - }; - for _ in 0..chans.len() as u64 * u64::from(self.height) { - w.write_all(&one)?; - } - } - let mut buf = Vec::new(); - for &ch in chans { - for y in 0..self.height { - self.row(y, ch, &mut buf); - w.write_all(&buf)?; - } - } - Ok(()) - } - - /// Streams the file into `w` (buffer it). Returns the bytes written. A PSD whose lengths - /// don't fit its 32-bit fields is refused before anything is written. - pub fn write_to(&self, w: &mut impl Write) -> io::Result { - let len = self.file_len()?; - let (head, pad) = self.head()?; - w.write_all(&head)?; - if self.layered { - // Transparency, then R, G, B. - for ch in [3, 0, 1, 2] { - self.write_planes(w, &[ch])?; - } - w.write_all(&vec![0; pad as usize])?; - w.write_all(&0u32.to_be_bytes())?; - } - self.write_planes(w, &[0, 1, 2])?; - w.flush()?; - Ok(len) - } - - /// Writes the file to `path` (created or truncated). Returns its size. - #[cfg(not(target_arch = "wasm32"))] - pub fn write_file(&self, path: &std::path::Path) -> io::Result { - let mut w = io::BufWriter::with_capacity(8 << 20, std::fs::File::create(path)?); - self.write_to(&mut w) - } - - /// The 8-bit value of channel `c` (3 = alpha) at (`x`, `y`): the layer's and the merged - /// image's (they are the same). - pub fn sample(&self, x: u32, y: u32, c: u32) -> u8 { - sample(x, y, c, self.rle) - } -} - -/// A layered document: a background and a grid of layers, one group per grid row. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct Grid { - /// Canvas width (px). - pub width: u32, - /// Canvas height (px). - pub height: u32, - /// Layers in the grid (the background is extra). - pub layers: u32, - /// Grid columns; a group per row of `cols` layers. - pub cols: u32, -} - -/// A 20 MP (5472 × 3648) layered photo: background, 5 layers in 2 groups (one closed), a -/// mask and a drop shadow. -pub const LAYERED_20MP: Grid = Grid { width: 5472, height: 3648, layers: 5, cols: 3 }; - -/// A 150-layer A4 layout at 300 ppi (2480 × 3508): background + 149 layers in 15 groups, with -/// masks, hidden layers, closed groups and drop shadows. -pub const LAYOUT_150: Grid = Grid { width: 2480, height: 3508, layers: 149, cols: 10 }; - -/// What a grid layer carries. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct GridLayer { - /// Bounds in document coordinates. - pub rect: Rect, - /// Base colour; the layer adds its local x to it (a horizontal ramp). - pub base: [u8; 3], - /// Has a mask showing only the left half. - pub masked: bool, - /// Hidden. - pub hidden: bool, - /// Has a drop shadow (`lfx2`). - pub shadow: bool, -} - -/// The `lfx2` descriptor of a drop shadow: multiply, black, 75 %, 120°, distance 5, size 5. -fn drop_shadow() -> Vec { - let px = |v: f64| Value::UnitFloat { unit: *b"#Pxl", value: v }; - let color = Descriptor::new("RGBC").with("Rd ", Value::Double(0.0)).with("Grn ", Value::Double(0.0)).with("Bl ", Value::Double(0.0)); - let shadow = Descriptor::new("DrSh") - .with("enab", Value::Boolean(true)) - .with("Md ", Value::Enumerated { type_id: Id::new("BlnM"), value: Id::new("Mltp") }) - .with("Clr ", Value::Descriptor(color)) - .with("Opct", Value::UnitFloat { unit: *b"#Prc", value: 75.0 }) - .with("uglg", Value::Boolean(false)) - .with("lagl", Value::UnitFloat { unit: *b"#Ang", value: 120.0 }) - .with("Dstn", px(5.0)) - .with("Ckmt", px(0.0)) - .with("blur", px(5.0)); - let root = Descriptor::new("null") - .with("Scl ", Value::UnitFloat { unit: *b"#Prc", value: 100.0 }) - .with("masterFXSwitch", Value::Boolean(true)) - .with("DrSh", Value::Descriptor(shadow)); - let mut data = 0u32.to_be_bytes().to_vec(); - data.extend(VersionedDescriptor::new(root).to_bytes()); - data -} - -impl Grid { - /// Grid rows (= groups). - pub fn rows(&self) -> u32 { - self.layers.div_ceil(self.cols.max(1)) - } - - fn cell(&self) -> (u32, u32) { - (self.width / self.cols.max(1), self.height / self.rows().max(1)) - } - - /// Gap between a layer and its cell's edge (larger than any shadow's reach). - fn inset(&self) -> u32 { - let (cw, ch) = self.cell(); - (cw.min(ch) / 8).max(1) - } - - /// Layer `i` of the grid (0-based, bottom to top), or `None` past the end. - pub fn layer(&self, i: u32) -> Option { - if i >= self.layers { - return None; - } - let (cw, ch) = self.cell(); - let inset = self.inset(); - let (col, row) = (i % self.cols.max(1), i / self.cols.max(1)); - let rect = - Rect::from_xywh((col * cw + inset) as i32, (row * ch + inset) as i32, cw.saturating_sub(2 * inset).max(1), ch.saturating_sub(2 * inset).max(1)); - let masked = i % 5 == 1; - let hidden = i % 13 == 12; - Some(GridLayer { - rect, - base: [(i * 37 + 20) as u8, (i * 91 + 50) as u8, (i * 53 + 90) as u8], - masked, - hidden, - shadow: i % 7 == 3 && !masked && !hidden, - }) - } - - /// Name of grid layer `i`. - pub fn layer_name(i: u32) -> String { - format!("Layer {}", i + 1) - } - - /// Name of the group of grid row `r`. - pub fn group_name(r: u32) -> String { - format!("Row {}", r + 1) - } - - /// Groups of rows `r % 4 == 3` are closed in the layers panel. - pub fn group_open(r: u32) -> bool { - r % 4 != 3 - } - - /// The background's colour at (`x`, `y`): [`sample`] in runs of 16 px (RLE has runs to find). - pub fn background(x: u32, y: u32) -> [u8; 3] { - [0, 1, 2].map(|c| sample(x / 16, y, c, false)) - } - - /// Grid layer `i`'s pixel colour at document (`x`, `y`), ignoring visibility and mask. - fn layer_color(l: &GridLayer, x: u32) -> [u8; 3] { - let lx = x.wrapping_sub(l.rect.left as u32); - l.base.map(|b| (u32::from(b) + lx) as u8) - } - - /// The merged colour at (`x`, `y`). Exact except within a few pixels of a shadowed layer's - /// edges (the merged image this generator stores leaves shadows out). - pub fn expected(&self, x: u32, y: u32) -> [u8; 3] { - let (cw, ch) = self.cell(); - let (col, row) = (x / cw.max(1), y / ch.max(1)); - if col < self.cols - && let Some(l) = self.layer(row * self.cols + col) - { - let (xi, yi) = (x as i32, y as i32); - let inside = xi >= l.rect.left && xi < l.rect.right && yi >= l.rect.top && yi < l.rect.bottom; - let shown = !l.masked || i64::from(xi) < i64::from(l.rect.left) + l.rect.width() / 2; - if inside && shown && !l.hidden { - return Self::layer_color(&l, x); - } - } - Self::background(x, y) - } - - /// Points to check, away from every shadow: in each of a few layers, its shown half, its - /// masked-out half, and the gap at its cell's corner. - pub fn sample_points(&self) -> Vec<(u32, u32)> { - let step = (self.layers / 12).max(1); - let mut v = vec![(0, 0), (self.width - 1, self.height - 1)]; - for i in (0..self.layers).step_by(step as usize) { - let Some(l) = self.layer(i) else { continue }; - let (w, h) = (l.rect.width() as u32, l.rect.height() as u32); - let (x0, y0) = (l.rect.left as u32, l.rect.top as u32); - v.push((x0 + w / 4, y0 + h / 2)); - v.push((x0 + w * 3 / 4, y0 + h / 2)); - v.push((x0 - self.inset() / 2, y0 - self.inset() / 2)); - } - v - } - - /// Builds the file (8-bit RGB, RLE): layers bottom to top, the merged image from - /// [`expected`](Self::expected). - pub fn build(&self, version: Version) -> Result { - let (w, h) = (self.width, self.height); - let mut b = PsdBuilder::new(w, h).version(version); - let mut bg = Vec::with_capacity(w as usize * h as usize * 4); - for y in 0..h { - for x in 0..w { - bg.extend(Self::background(x, y)); - bg.push(255); - } - } - b.push_layer(LayerSpec::new("Background", 0, 0, w, h, PixelData::Rgba8(bg))); - for row in 0..self.rows() { - b.begin_group(GroupSpec { open: Self::group_open(row), ..GroupSpec::new(Self::group_name(row)) }); - for i in row * self.cols..((row + 1) * self.cols).min(self.layers) { - let Some(l) = self.layer(i) else { continue }; - let (lw, lh) = (l.rect.width() as u32, l.rect.height() as u32); - let mut px = Vec::with_capacity(lw as usize * lh as usize * 4); - for _ in 0..lh { - for lx in 0..lw { - px.extend(Self::layer_color(&l, l.rect.left as u32 + lx)); - px.push(255); - } - } - let mut spec = LayerSpec::new(Self::layer_name(i), l.rect.left, l.rect.top, lw, lh, PixelData::Rgba8(px)); - spec.visible = !l.hidden; - if l.masked { - let data = (0..lh).flat_map(|_| (0..lw).map(move |x| if x < lw / 2 { 255 } else { 0 })).collect(); - spec.mask = Some(MaskSpec { rect: l.rect, data, default_color: 0, disabled: false }); - } - if l.shadow { - spec.extra_blocks.push(TaggedBlock::new(*b"lfx2", drop_shadow())); - } - b.push_layer(spec); - } - b.end_group()?; - } - let mut merged = Vec::with_capacity(w as usize * h as usize * 4); - for y in 0..h { - for x in 0..w { - merged.extend(self.expected(x, y)); - merged.push(255); - } - } - b.composite(PixelData::Rgba8(merged)); - b.build() - } -} - -#[cfg(test)] -#[allow(clippy::unwrap_used, clippy::indexing_slicing)] -mod tests { - use super::*; - - #[test] - fn presets_respect_photoshop_limits() { - let psd = PSD_2GB.file_len().unwrap(); - assert!(psd < PSD_MAX_FILE_BYTES && psd > 2_000_000_000, "{psd}"); - const { assert!(PSD_2GB.width <= PSD_MAX_SIDE && PSD_2GB.height <= PSD_MAX_SIDE) }; - for c in PSB_CASES { - assert!(c.file_len().is_ok() && c.width <= 300_000, "{}", c.name); - } - assert!(PSB_4GB.file_len().unwrap() > 4 << 30); - // A PSD too big for its 32-bit lengths is refused, not wrapped. - let huge = Streamed { width: 33_000, height: 33_000, ..PSD_2GB }; - assert!(huge.file_len().is_err()); - assert!(huge.write_to(&mut io::sink()).is_err()); - } - - /// Small streamed files parse, and their pixels are [`sample`]'s. - #[test] - fn small_streamed_files_parse() { - for version in [Version::Psd, Version::Psb] { - for (rle, layered, depth) in [(false, true, 8), (true, true, 8), (true, false, 16), (false, false, 8)] { - let c = Streamed { name: "t", version, width: 300, height: 7, depth, rle, layered }; - let mut out = Vec::new(); - let n = c.write_to(&mut out).unwrap(); - assert_eq!(n, out.len() as u64, "{c:?}"); - let file = PsdFile::from_bytes(&out).unwrap(); - let merged = file.image_data.decode(&file.header).unwrap(); - let bps = usize::from(depth / 8); - let plane = 300 * 7 * bps; - for (x, y, ch) in [(0u32, 0u32, 0u32), (299, 6, 2), (150, 3, 1)] { - let i = ch as usize * plane + (y as usize * 300 + x as usize) * bps; - assert_eq!(merged[i], c.sample(x, y, ch), "{c:?} ({x}, {y}) {ch}"); - } - assert_eq!(file.layers().len(), usize::from(layered), "{c:?}"); - } - } - } - - #[test] - fn grid_layout_is_consistent() { - for g in [LAYERED_20MP, LAYOUT_150] { - let last = g.layer(g.layers - 1).unwrap(); - assert!(last.rect.right <= g.width as i32 && last.rect.bottom <= g.height as i32); - assert!(g.layer(g.layers).is_none()); - let all: Vec<_> = (0..g.layers).filter_map(|i| g.layer(i)).collect(); - assert!(all.iter().any(|l| l.masked) && all.iter().any(|l| l.shadow)); - for (x, y) in g.sample_points() { - assert!(x < g.width && y < g.height); - } - } - assert!((0..LAYOUT_150.layers).filter_map(|i| LAYOUT_150.layer(i)).any(|l| l.hidden)); - } - - /// A small grid builds, parses, and its merged image is [`Grid::expected`]. - #[test] - fn small_grid_builds() { - let g = Grid { width: 120, height: 90, layers: 14, cols: 4 }; - let file = g.build(Version::Psd).unwrap(); - let bytes = file.to_bytes().unwrap(); - let file = PsdFile::from_bytes(&bytes).unwrap(); - // Background + 14 layers + 4 groups (two records each). - assert_eq!(file.layers().len(), 1 + 14 + 8); - let merged = file.image_data.decode(&file.header).unwrap(); - for (x, y) in g.sample_points() { - let want = g.expected(x, y); - for c in 0..3 { - assert_eq!(merged[c * 120 * 90 + (y * 120 + x) as usize], want[c], "({x}, {y}) {c}"); - } - } - } -} diff --git a/crates/psd/src/grd.rs b/crates/psd/src/grd.rs deleted file mode 100644 index bfa2cd91c7..0000000000 --- a/crates/psd/src/grd.rs +++ /dev/null @@ -1,299 +0,0 @@ -//! Photoshop gradient files (`.grd`, version 5: Photoshop 6 and later). -//! -//! Layout from public descriptions of the format: `8BGR`, version u16 (5), then a versioned -//! ActionDescriptor whose `GrdL` list holds `Grdn` objects, each with a `Grad` object: -//! `Nm ` name, `GrdF` form (`CstS` custom stops or `ClNs` noise), `Clrs` colour stops -//! (`Clr ` colour, `Type` user/foreground/background, `Lctn` 0..4096, `Mdpn` midpoint %) and -//! `Trns` transparency stops (`Opct` %, `Lctn`, `Mdpn`). Older (version 3) files are rejected. -//! Colours are returned in the model they were stored in; converting them is the caller's job. - -use crate::descriptor::{Descriptor, Value, VersionedDescriptor}; -use crate::error::{PsdError, Result}; - -/// Most gradients read from one file. -pub const MAX_GRADIENTS: usize = 10_000; -/// Most stops read per gradient. -pub const MAX_STOPS: usize = 1_000; - -/// A stop colour as stored. -#[derive(Debug, Clone, Copy, PartialEq)] -pub enum GrdColor { - /// 0..1 RGB (from 0..255 components). - Rgb([f32; 3]), - /// Hue degrees, saturation and brightness 0..1. - Hsb([f32; 3]), - /// 0..1 C, M, Y, K ink. - Cmyk([f32; 4]), - /// L 0..100, a, b. - Lab([f32; 3]), - /// 0..1 ink (0 = white). - Gray(f32), - /// The live foreground colour. - Foreground, - /// The live background colour. - Background, -} - -/// One colour stop. -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct GrdStop { - /// 0..1. - pub location: f32, - /// 0..1 (0.5 = centred). - pub midpoint: f32, - /// Colour. - pub color: GrdColor, -} - -/// One gradient. -#[derive(Debug, Clone, PartialEq, Default)] -pub struct GrdGradient { - /// Name. - pub name: String, - /// Noise gradient (`ClNs`): stops are absent; callers usually skip or approximate it. - pub noise: bool, - /// Colour stops, in file order. - pub stops: Vec, - /// Transparency stops: (location 0..1, opacity 0..1, midpoint 0..1). - pub opacity: Vec<(f32, f32, f32)>, -} - -/// The raw number behind a key. It is deliberately *not* filtered: every caller passes the result -/// through `finite`, so filtering here as well would give two guards with two different answers to -/// the same input, and mutation testing showed the second one was unreachable. -fn num(d: &Descriptor, k: &str) -> Option { - match d.get(k)? { - Value::Double(v) => Some(*v), - Value::UnitFloat { value, .. } => Some(*value), - Value::Integer(v) => Some(f64::from(*v)), - Value::LargeInteger(v) => Some(*v as f64), - _ => None, - } -} -/// The single guard every number passes through on its way to an `f32`. Only a NaN is truly -/// without a value, so only a NaN returns `None`; the caller then falls back to the default its -/// own field documents (opacity opaque, a midpoint centred, a channel zero). -/// -/// Everything else — including `±inf` and a value finite as `f64` but larger than `f32::MAX` -/// (`1e300`, which would otherwise overflow the cast to an infinity) — is *saturated* to the -/// representable range before the cast. That keeps the mapping monotonic: a bigger input never -/// yields a smaller output, so "too large" lands at the top of the range rather than snapping to a -/// field default and inverting the result. -fn finite(v: f64) -> Option { - if v.is_nan() { - return None; - } - Some(v.clamp(f64::from(f32::MIN), f64::from(f32::MAX)) as f32) -} -fn f(d: &Descriptor, k: &str) -> f32 { - finite(num(d, k).unwrap_or(0.0)).unwrap_or(0.0) -} -fn clamp01(v: f32) -> f32 { - v.clamp(0.0, 1.0) -} -/// A hue in degrees. An in-range value (0..=360, including exactly 360) is kept verbatim; only an -/// out-of-range value wraps with `rem_euclid`, so 370 -> 10 and -10 -> 350. Wrapping every value -/// would alter a legal 360 to 0 (and f32 round-off on a tiny negative can itself land on 360), -/// changing the colour at the boundary. The input is already finite, so the result stays finite. -fn hue(h: f32) -> f32 { - if (0.0..=360.0).contains(&h) { h } else { h.rem_euclid(360.0) } -} - -fn obj<'a>(d: &'a Descriptor, k: &str) -> Option<&'a Descriptor> { - match d.get(k)? { - Value::Descriptor(o) | Value::GlobalObject(o) => Some(o), - _ => None, - } -} -fn list<'a>(d: &'a Descriptor, k: &str) -> &'a [Value] { - match d.get(k) { - Some(Value::List(v)) => v, - _ => &[], - } -} -fn enum_is(d: &Descriptor, k: &str, v: &str) -> bool { - matches!(d.get(k), Some(Value::Enumerated { value, .. }) if value.is(v)) -} - -fn color(c: &Descriptor) -> Option { - let id = c.class_id.as_bytes(); - Some(match id { - // Every component documented as 0..=1 is clamped here, like every other range in this - // module: a hostile file can store any integer at all, and dividing by 100 or 255 does not - // bring it back into range on its own. - b"RGBC" => GrdColor::Rgb([f(c, "Rd ") / 255.0, f(c, "Grn ") / 255.0, f(c, "Bl ") / 255.0].map(clamp01)), - // Hue is a circular degree value in 0..=360; see `hue`. An in-range value (including - // exactly 360) is kept, only an out-of-range one wraps, so a hostile hue cannot become a - // different, wrong colour at the boundary. - b"HSBC" => GrdColor::Hsb([hue(f(c, "H ")), (f(c, "Strt") / 100.0).clamp(0.0, 1.0), (f(c, "Brgh") / 100.0).clamp(0.0, 1.0)]), - b"CMYC" => GrdColor::Cmyk([f(c, "Cyn ") / 100.0, f(c, "Mgnt") / 100.0, f(c, "Ylw ") / 100.0, f(c, "Blck") / 100.0].map(clamp01)), - // L* is 0..=100; a*/b* are signed and bounded to the CIELAB gamut (-128..=127) so a hostile - // value cannot hand a downstream colour converter an out-of-gamut number to overflow on. - b"LbCl" => GrdColor::Lab([f(c, "Lmnc").clamp(0.0, 100.0), f(c, "A ").clamp(-128.0, 127.0), f(c, "B ").clamp(-128.0, 127.0)]), - b"Grsc" => GrdColor::Gray(clamp01(f(c, "Gry ") / 100.0)), - _ => return None, - }) -} - -fn location(d: &Descriptor) -> f32 { - (f(d, "Lctn") / 4096.0).clamp(0.0, 1.0) -} -fn midpoint(d: &Descriptor) -> f32 { - finite(num(d, "Mdpn").unwrap_or(50.0) / 100.0).unwrap_or(0.5).clamp(0.0, 1.0) -} - -fn gradient(g: &Descriptor) -> GrdGradient { - let name = match g.get("Nm ") { - Some(Value::Text(t)) => t.to_string_lossy(), - _ => String::new(), - }; - let mut out = GrdGradient { name, noise: enum_is(g, "GrdF", "ClNs"), ..Default::default() }; - for v in list(g, "Clrs").iter().take(MAX_STOPS) { - let Value::Descriptor(s) = v else { continue }; - let c = if enum_is(s, "Type", "FrgC") { - Some(GrdColor::Foreground) - } else if enum_is(s, "Type", "BckC") { - Some(GrdColor::Background) - } else { - obj(s, "Clr ").and_then(color) - }; - if let Some(color) = c { - out.stops.push(GrdStop { location: location(s), midpoint: midpoint(s), color }); - } - } - for v in list(g, "Trns").iter().take(MAX_STOPS) { - let Value::Descriptor(s) = v else { continue }; - out.opacity.push((location(s), finite(num(s, "Opct").unwrap_or(100.0) / 100.0).unwrap_or(1.0).clamp(0.0, 1.0), midpoint(s))); - } - out -} - -/// Parses a `.grd` file. -pub fn parse(data: &[u8]) -> Result> { - let sig = data.get(0..4).ok_or(PsdError::UnexpectedEof { offset: 0, needed: 4 })?; - if sig != b"8BGR" { - let mut found = [0u8; 4]; - found.copy_from_slice(sig); - return Err(PsdError::InvalidSignature { expected: "8BGR", found }); - } - let version = data.get(4..6).map(|b| u16::from_be_bytes([b[0], b[1]])).ok_or(PsdError::UnexpectedEof { offset: 4, needed: 2 })?; - if version != 5 { - return Err(PsdError::Unsupported(format!("gradient file version {version} (only version 5, Photoshop 6 and later)"))); - } - let (vd, _) = VersionedDescriptor::parse_prefix(data.get(6..).unwrap_or_default())?; - let out: Vec = list(&vd.descriptor, "GrdL") - .iter() - .take(MAX_GRADIENTS) - .filter_map(|v| match v { - Value::Descriptor(d) => obj(d, "Grad").map(gradient), - _ => None, - }) - .collect(); - if out.is_empty() { - return Err(PsdError::invalid("the gradient file holds no gradients")); - } - Ok(out) -} - -/// Writes a version 5 `.grd` file (RGB / foreground / background stops; used by tests). -pub fn write(gradients: &[GrdGradient]) -> Vec { - use crate::descriptor::{Id, UnicodeString}; - let en = |t: &str, v: &str| Value::Enumerated { type_id: Id::new(t), value: Id::new(v) }; - let items = gradients - .iter() - .map(|g| { - let stops = g - .stops - .iter() - .map(|s| { - let (ty, c) = match s.color { - GrdColor::Foreground => ("FrgC", None), - GrdColor::Background => ("BckC", None), - GrdColor::Rgb([r, gg, b]) => ("UsrS", Some((r, gg, b))), - _ => ("UsrS", Some((0.0, 0.0, 0.0))), - }; - let mut d = Descriptor::new("Clrt"); - if let Some((r, gg, b)) = c { - let rgb = Descriptor::new("RGBC") - .with("Rd ", Value::Double(f64::from(r) * 255.0)) - .with("Grn ", Value::Double(f64::from(gg) * 255.0)) - .with("Bl ", Value::Double(f64::from(b) * 255.0)); - d = d.with("Clr ", Value::Descriptor(rgb)); - } - Value::Descriptor( - d.with("Type", en("Clry", ty)) - .with("Lctn", Value::Integer((s.location * 4096.0).round() as i32)) - .with("Mdpn", Value::Integer((s.midpoint * 100.0).round() as i32)), - ) - }) - .collect(); - let trns = g - .opacity - .iter() - .map(|&(l, o, m)| { - Value::Descriptor( - Descriptor::new("TrnS") - .with("Opct", Value::UnitFloat { unit: *b"#Prc", value: f64::from(o) * 100.0 }) - .with("Lctn", Value::Integer((l * 4096.0).round() as i32)) - .with("Mdpn", Value::Integer((m * 100.0).round() as i32)), - ) - }) - .collect(); - let grad = Descriptor::new("Grdn") - .with("Nm ", Value::Text(UnicodeString::new_nul(&g.name))) - .with("GrdF", en("GrdF", if g.noise { "ClNs" } else { "CstS" })) - .with("Intr", Value::Double(4096.0)) - .with("Clrs", Value::List(stops)) - .with("Trns", Value::List(trns)); - Value::Descriptor(Descriptor::new("Grdn").with("Grad", Value::Descriptor(grad))) - }) - .collect(); - let mut out = b"8BGR".to_vec(); - out.extend_from_slice(&5u16.to_be_bytes()); - out.extend_from_slice(&VersionedDescriptor::new(Descriptor::new("null").with("GrdL", Value::List(items))).to_bytes()); - out -} - -#[cfg(test)] -mod tests { - use super::*; - - fn sample() -> Vec { - vec![ - GrdGradient { - name: "Sunset ✓".into(), - noise: false, - stops: vec![ - GrdStop { location: 0.0, midpoint: 0.5, color: GrdColor::Foreground }, - GrdStop { location: 0.25, midpoint: 0.3, color: GrdColor::Rgb([1.0, 0.5, 0.0]) }, - GrdStop { location: 1.0, midpoint: 0.5, color: GrdColor::Background }, - ], - opacity: vec![(0.0, 1.0, 0.5), (1.0, 0.25, 0.5)], - }, - GrdGradient { name: "Noise".into(), noise: true, ..Default::default() }, - ] - } - - #[test] - fn round_trip() { - let b = write(&sample()); - assert_eq!(parse(&b).unwrap(), sample()); - } - - #[test] - fn hostile_input_errors() { - assert!(parse(b"").is_err()); - assert!(parse(b"8BGR\0\x03").is_err()); - assert!(parse(b"8BPS\0\x05").is_err()); - let b = write(&sample()); - for cut in 0..b.len() { - let _ = parse(&b[..cut]); - } - for i in 0..b.len() { - let mut c = b.clone(); - c[i] ^= 0x5a; - let _ = parse(&c); - } - assert!(parse(&write(&[])).is_err()); - } -} diff --git a/crates/psd/src/hdr.rs b/crates/psd/src/hdr.rs deleted file mode 100644 index 2b576c2ba4..0000000000 --- a/crates/psd/src/hdr.rs +++ /dev/null @@ -1,93 +0,0 @@ -//! HDR toning settings: the Color Mode Data of 32-bit-per-channel documents. -//! -//! Adobe's specification only documents the Color Mode Data section for -//! indexed and duotone images, but every 32-bit file Photoshop saves carries -//! an `hdrt` record there (the defaults of Image › Mode › 8/16 Bits/Channel › -//! HDR Toning), and current versions append an `hdra` record. Photoshop 2026 -//! refuses to open a 32-bit file whose Color Mode Data is empty or not in this -//! form ("the open options are incorrect", #291), so writers must emit it. -//! -//! The layout below was established by observing Photoshop-saved files (the -//! `photocraft-corpus` oracles); fields whose meaning is not known are written -//! with the values Photoshop writes for a new document. - -use crate::io::WriteExt; - -/// Key of the HDR toning record. -pub const TONING_KEY: &[u8; 4] = b"hdrt"; -/// Key of the record current Photoshop versions append after the toning record. -pub const EXTRA_KEY: &[u8; 4] = b"hdra"; - -/// `true` if `data` (a file's Color Mode Data) starts with an HDR toning record. -pub fn is_toning_data(data: &[u8]) -> bool { - data.starts_with(TONING_KEY) -} - -/// The Color Mode Data Photoshop writes for a new 32-bit document: a version 3 -/// `hdrt` record (method 2, preset "Linear", an identity toning curve) followed -/// by a version 6 `hdra` record. 110 bytes. -pub fn default_toning_data() -> Vec { - let mut out = Vec::with_capacity(110); - // `hdrt`, version 3. - out.put(TONING_KEY); - out.put_u32(3); - out.put(&0.23f32.to_be_bytes()); - out.put_u32(2); // toning method - // Preset name: Unicode string (UTF-16 code units including the terminator). - let name: Vec = "Linear".encode_utf16().chain(std::iter::once(0)).collect(); - out.put_u32(name.len() as u32); - for u in name { - out.put_u16(u); - } - // Toning curve: two points, (0, 0) and (255, 255), both corners. - out.put_u16(2); - out.put_u16(2); - for (input, output) in [(0u16, 0u16), (255, 255)] { - out.put_u16(output); - out.put_u16(input); - } - out.put(&[1, 1]); - out.put(&[0; 8]); - out.put(&16.0f32.to_be_bytes()); - out.put_u32(1); - out.put_u32(0); - out.put(&1.0f32.to_be_bytes()); - // `hdra`, version 6. - out.put(EXTRA_KEY); - out.put_u32(6); - out.put_u32(0); - out.put(&20.0f32.to_be_bytes()); - out.put(&30.0f32.to_be_bytes()); - out.put_u32(0); - out.put_u32(0); - out.put(&1.0f32.to_be_bytes()); - out.put(&[0; 6]); - out -} - -/// The Color Mode Data a writer should emit for a file of `depth` bits per -/// channel when it has nothing of its own to keep: the default HDR toning -/// records for 32-bit files, nothing otherwise (indexed and duotone data come -/// from the document). -pub fn color_mode_data_for_depth(depth: u16) -> Vec { - if depth == 32 { default_toning_data() } else { Vec::new() } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn default_record_layout() { - let d = default_toning_data(); - assert_eq!(d.len(), 110); - assert!(is_toning_data(&d)); - assert_eq!(&d[72..76], EXTRA_KEY); - // Version fields. - assert_eq!(&d[4..8], &3u32.to_be_bytes()); - assert_eq!(&d[76..80], &6u32.to_be_bytes()); - assert!(color_mode_data_for_depth(8).is_empty()); - assert!(color_mode_data_for_depth(16).is_empty()); - assert_eq!(color_mode_data_for_depth(32), d); - } -} diff --git a/crates/psd/src/header.rs b/crates/psd/src/header.rs deleted file mode 100644 index d99555be2b..0000000000 --- a/crates/psd/src/header.rs +++ /dev/null @@ -1,254 +0,0 @@ -//! File header: signature, version, channels, dimensions, depth and color mode. - -use crate::error::{PsdError, Result}; -use crate::io::{Reader, WriteExt}; - -/// Maximum width/height accepted by the parser (PSB limit). -pub const MAX_DIMENSION: u32 = 300_000; -/// Maximum number of channels accepted (spec: 1..=56). -pub const MAX_CHANNELS: u16 = 56; - -/// File format version. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub enum Version { - /// Version 1: Photoshop Document (32-bit section lengths). - Psd, - /// Version 2: Large Document Format (64-bit lengths for some sections). - Psb, -} - -impl Version { - /// `true` for PSB. - pub fn is_psb(self) -> bool { - matches!(self, Version::Psb) - } - /// Numeric version as stored in the header. - pub fn as_u16(self) -> u16 { - match self { - Version::Psd => 1, - Version::Psb => 2, - } - } -} - -/// Document color mode. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub enum ColorMode { - /// 0 - Bitmap, - /// 1 - Grayscale, - /// 2 - Indexed, - /// 3 - Rgb, - /// 4 - Cmyk, - /// 7 - Multichannel, - /// 8 - Duotone, - /// 9 - Lab, - /// Any other value, preserved. - Unknown(u16), -} - -impl ColorMode { - /// Converts from the stored numeric value. - pub fn from_u16(v: u16) -> Self { - match v { - 0 => ColorMode::Bitmap, - 1 => ColorMode::Grayscale, - 2 => ColorMode::Indexed, - 3 => ColorMode::Rgb, - 4 => ColorMode::Cmyk, - 7 => ColorMode::Multichannel, - 8 => ColorMode::Duotone, - 9 => ColorMode::Lab, - other => ColorMode::Unknown(other), - } - } - /// Numeric value as stored. - pub fn as_u16(self) -> u16 { - match self { - ColorMode::Bitmap => 0, - ColorMode::Grayscale => 1, - ColorMode::Indexed => 2, - ColorMode::Rgb => 3, - ColorMode::Cmyk => 4, - ColorMode::Multichannel => 7, - ColorMode::Duotone => 8, - ColorMode::Lab => 9, - ColorMode::Unknown(v) => v, - } - } - /// Number of color (non-alpha) channels for the mode, when fixed. - pub fn color_channels(self) -> Option { - match self { - ColorMode::Bitmap | ColorMode::Grayscale | ColorMode::Indexed | ColorMode::Duotone => Some(1), - ColorMode::Rgb | ColorMode::Lab => Some(3), - ColorMode::Cmyk => Some(4), - ColorMode::Multichannel | ColorMode::Unknown(_) => None, - } - } -} - -/// The 26-byte file header. -#[derive(Debug, Clone, PartialEq, Eq)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct Header { - /// PSD or PSB. - pub version: Version, - /// Reserved bytes (spec: must be zero). Preserved for exact round-trip. - pub reserved: [u8; 6], - /// Number of channels in the merged image, including alpha channels (1..=56). - pub channels: u16, - /// Height in pixels. - pub height: u32, - /// Width in pixels. - pub width: u32, - /// Bits per channel: 1, 8, 16 or 32. - pub depth: u16, - /// Color mode. - pub color_mode: ColorMode, -} - -impl Header { - /// Creates a header with zeroed reserved bytes. - pub fn new(version: Version, width: u32, height: u32, channels: u16, depth: u16, color_mode: ColorMode) -> Self { - Header { version, reserved: [0; 6], channels, height, width, depth, color_mode } - } - - /// Bytes per row of one channel plane. - pub fn row_bytes(&self) -> usize { - row_bytes(self.width as usize, self.depth) - } - - pub(crate) fn read(r: &mut Reader<'_>) -> Result { - let sig = r.array::<4>()?; - if &sig != b"8BPS" { - return Err(PsdError::InvalidSignature { expected: "8BPS", found: sig }); - } - let version = match r.u16()? { - 1 => Version::Psd, - 2 => Version::Psb, - v => return Err(PsdError::UnsupportedVersion(v)), - }; - let reserved = r.array::<6>()?; - let channels = r.u16()?; - let height = r.u32()?; - let width = r.u32()?; - let depth = r.u16()?; - let color_mode = ColorMode::from_u16(r.u16()?); - let h = Header { version, reserved, channels, height, width, depth, color_mode }; - h.validate()?; - Ok(h) - } - - /// Checks the header against spec constraints and safety limits. - pub fn validate(&self) -> Result<()> { - if self.channels == 0 || self.channels > MAX_CHANNELS { - return Err(PsdError::invalid(format!("channel count {} out of range 1..=56", self.channels))); - } - if self.width == 0 || self.height == 0 { - return Err(PsdError::invalid(format!("image dimensions {}x{} must be at least 1x1", self.width, self.height))); - } - if self.width > MAX_DIMENSION || self.height > MAX_DIMENSION { - return Err(PsdError::LimitExceeded("image dimensions exceed 300000")); - } - if !matches!(self.depth, 1 | 8 | 16 | 32) { - return Err(PsdError::invalid(format!("unsupported bit depth {}", self.depth))); - } - Ok(()) - } - - pub(crate) fn write(&self, out: &mut Vec) { - out.put(b"8BPS"); - out.put_u16(self.version.as_u16()); - out.put(&self.reserved); - out.put_u16(self.channels); - out.put_u32(self.height); - out.put_u32(self.width); - out.put_u16(self.depth); - out.put_u16(self.color_mode.as_u16()); - } -} - -/// Bytes per row for `width` samples at `depth` bits. -pub fn row_bytes(width: usize, depth: u16) -> usize { - match depth { - 1 => width.div_ceil(8), - d => width.saturating_mul(usize::from(d / 8).max(1)), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn bytes(h: &Header) -> Vec { - let mut v = Vec::new(); - h.write(&mut v); - v - } - - #[test] - fn header_roundtrip() { - let h = Header::new(Version::Psb, 1000, 20, 4, 16, ColorMode::Cmyk); - let b = bytes(&h); - assert_eq!(b.len(), 26); - assert_eq!(Header::read(&mut Reader::new(&b)).unwrap(), h); - } - - #[test] - fn bad_signature() { - let mut b = bytes(&Header::new(Version::Psd, 1, 1, 1, 8, ColorMode::Grayscale)); - b[0] = b'X'; - assert!(matches!(Header::read(&mut Reader::new(&b)), Err(PsdError::InvalidSignature { .. }))); - } - - #[test] - fn bad_version() { - let mut b = bytes(&Header::new(Version::Psd, 1, 1, 1, 8, ColorMode::Grayscale)); - b[5] = 3; - assert_eq!(Header::read(&mut Reader::new(&b)), Err(PsdError::UnsupportedVersion(3))); - } - - #[test] - fn limits() { - for h in [ - Header::new(Version::Psd, 300_001, 1, 1, 8, ColorMode::Rgb), - Header::new(Version::Psd, 0, 1, 1, 8, ColorMode::Rgb), - Header::new(Version::Psd, 1, 0, 1, 8, ColorMode::Rgb), - Header::new(Version::Psb, 0, 0, 1, 8, ColorMode::Rgb), - Header::new(Version::Psd, 1, 1, 0, 8, ColorMode::Rgb), - Header::new(Version::Psd, 1, 1, 57, 8, ColorMode::Rgb), - Header::new(Version::Psd, 1, 1, 3, 12, ColorMode::Rgb), - ] { - let b = bytes(&h); - assert!(Header::read(&mut Reader::new(&b)).is_err(), "{h:?}"); - } - } - - #[test] - fn color_modes_roundtrip() { - for v in 0..20u16 { - assert_eq!(ColorMode::from_u16(v).as_u16(), v); - } - assert_eq!(ColorMode::from_u16(5), ColorMode::Unknown(5)); - assert_eq!(ColorMode::Rgb.color_channels(), Some(3)); - } - - #[test] - fn row_bytes_values() { - assert_eq!(row_bytes(9, 1), 2); - assert_eq!(row_bytes(8, 1), 1); - assert_eq!(row_bytes(0, 1), 0); - assert_eq!(row_bytes(3, 8), 3); - assert_eq!(row_bytes(3, 16), 6); - assert_eq!(row_bytes(3, 32), 12); - } -} diff --git a/crates/psd/src/image_data.rs b/crates/psd/src/image_data.rs deleted file mode 100644 index 9e27af2917..0000000000 --- a/crates/psd/src/image_data.rs +++ /dev/null @@ -1,98 +0,0 @@ -//! Merged (composite) image data section. - -use std::borrow::Cow; - -use crate::compression::{Compression, PlaneLayout, decode_planes, encode_planes, validate_planes}; -use crate::error::Result; -use crate::header::Header; -use crate::io::Reader; -use crate::source::Stored; - -/// The merged image: compression plus the encoded bytes of all channels -/// (to the end of the file), kept verbatim. -/// -/// As with [`crate::ChannelData`], a file parsed by -/// [`PsdFile::from_source`](crate::PsdFile::from_source) may leave the bytes in the source -/// (`stored`, with `data` empty); equality compares the bytes wherever they are. -#[derive(Debug, Clone)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct ImageData { - /// Compression method for the whole image. - pub compression: Compression, - /// Encoded data. For RLE: the byte-count table for every row of every - /// channel followed by the rows. - pub data: Vec, - /// Where the encoded bytes are when they were left in the source. (Not serialized.) - #[cfg_attr(feature = "serde", serde(skip))] - pub stored: Option, -} - -impl PartialEq for ImageData { - fn eq(&self, other: &Self) -> bool { - if self.compression != other.compression || self.encoded_len() != other.encoded_len() { - return false; - } - match (self.encoded(), other.encoded()) { - (Ok(a), Ok(b)) => a == b, - _ => false, - } - } -} - -impl Eq for ImageData {} - -impl ImageData { - /// A merged image whose encoded bytes are in memory. - pub fn new(compression: Compression, data: Vec) -> Self { - ImageData { compression, data, stored: None } - } - - /// The encoded bytes: borrowed from `data`, or read from the source. - pub fn encoded(&self) -> Result> { - crate::source::encoded(&self.data, self.stored.as_ref()) - } - - /// Number of encoded bytes, wherever they are. - pub fn encoded_len(&self) -> u64 { - self.stored.as_ref().map_or(self.data.len() as u64, |s| s.len) - } - - /// Reads bytes left in the source into `data`. - pub fn load(&mut self) -> Result<()> { - if let Some(s) = &self.stored { - self.data = s.read()?; - self.stored = None; - } - Ok(()) - } - - /// Plane layout for the merged image described by `header`. - pub fn layout(header: &Header) -> PlaneLayout { - PlaneLayout { - planes: usize::from(header.channels), - width: header.width as usize, - height: header.height as usize, - depth: header.depth, - version: header.version, - } - } - - /// Encodes planar data (`channels × height × row_bytes`). - pub fn encode(compression: Compression, decoded: &[u8], header: &Header) -> Result { - Ok(ImageData::new(compression, encode_planes(compression, decoded, &Self::layout(header))?)) - } - - /// Decodes all channels into planar big-endian samples. - pub fn decode(&self, header: &Header) -> Result> { - decode_planes(self.compression, &self.encoded()?, &Self::layout(header)) - } - - pub(crate) fn read(r: &mut Reader<'_>, header: &Header) -> Result { - let compression = Compression::from_u16(r.u16()?); - let data = r.peek_rest(); - validate_planes(compression, data, &Self::layout(header))?; - let data = data.to_vec(); - r.skip(data.len())?; - Ok(ImageData::new(compression, data)) - } -} diff --git a/crates/psd/src/io.rs b/crates/psd/src/io.rs deleted file mode 100644 index 8537b74649..0000000000 --- a/crates/psd/src/io.rs +++ /dev/null @@ -1,518 +0,0 @@ -//! Bounds-checked big-endian reader and writer helpers. - -use std::io::{Seek, SeekFrom, Write}; - -use crate::error::{PsdError, Result}; - -/// A name as UTF-16 code units, capped at `max` **without ever splitting a surrogate pair**. -/// `encode_utf16().take(max)` would cut between the high and low half of a pair above U+FFFF and -/// write ill-formed UTF-16 to disk, so whole scalar values are added until one would not fit. -/// Both the `.aco` and `.ase` writers share this; keeping it in one place is deliberate, so the -/// surrogate fix cannot drift between the two formats. -pub(crate) fn utf16_units(name: &str, max: usize) -> Vec { - let mut units = Vec::new(); - for c in name.chars() { - if units.len() + c.len_utf16() > max { - break; - } - let mut buf = [0u16; 2]; - units.extend_from_slice(c.encode_utf16(&mut buf)); - } - units -} - -/// A cursor over a byte slice. Every read is bounds checked. Integers are big-endian (the PSD -/// byte order) unless the reader was made with [`Reader::new_little`], which only the TIFF -/// byte-order transcoder uses ([`crate::tiff`]). -#[derive(Debug, Clone)] -pub(crate) struct Reader<'a> { - data: &'a [u8], - pos: usize, - little: bool, -} - -impl<'a> Reader<'a> { - pub(crate) fn new(data: &'a [u8]) -> Self { - Reader { data, pos: 0, little: false } - } - - /// A reader of little-endian integers (Photoshop data inside an Intel-order TIFF). - pub(crate) fn new_little(data: &'a [u8]) -> Self { - Reader { data, pos: 0, little: true } - } - - pub(crate) fn pos(&self) -> usize { - self.pos - } - - pub(crate) fn remaining(&self) -> usize { - self.data.len() - self.pos - } - - pub(crate) fn is_empty(&self) -> bool { - self.remaining() == 0 - } - - /// Bytes from the current position without consuming. - pub(crate) fn peek_rest(&self) -> &'a [u8] { - &self.data[self.pos..] - } - - pub(crate) fn bytes(&mut self, n: usize) -> Result<&'a [u8]> { - if n > self.remaining() { - return Err(PsdError::UnexpectedEof { offset: self.pos, needed: n - self.remaining() }); - } - let s = &self.data[self.pos..self.pos + n]; - self.pos += n; - Ok(s) - } - - /// Reads `n` bytes where `n` comes from a (possibly 64-bit) length field. - pub(crate) fn bytes_u64(&mut self, n: u64) -> Result<&'a [u8]> { - let n = usize::try_from(n).map_err(|_| PsdError::LimitExceeded("length exceeds address space"))?; - self.bytes(n) - } - - pub(crate) fn sub(&mut self, n: u64) -> Result> { - let little = self.little; - Ok(Reader { data: self.bytes_u64(n)?, pos: 0, little }) - } - - pub(crate) fn skip(&mut self, n: usize) -> Result<()> { - self.bytes(n).map(|_| ()) - } - - pub(crate) fn array(&mut self) -> Result<[u8; N]> { - let s = self.bytes(N)?; - let mut out = [0u8; N]; - out.copy_from_slice(s); - Ok(out) - } - - pub(crate) fn u8(&mut self) -> Result { - Ok(self.array::<1>()?[0]) - } - pub(crate) fn u16(&mut self) -> Result { - let b = self.array()?; - Ok(if self.little { u16::from_le_bytes(b) } else { u16::from_be_bytes(b) }) - } - pub(crate) fn i16(&mut self) -> Result { - let b = self.array()?; - Ok(if self.little { i16::from_le_bytes(b) } else { i16::from_be_bytes(b) }) - } - pub(crate) fn u32(&mut self) -> Result { - let b = self.array()?; - Ok(if self.little { u32::from_le_bytes(b) } else { u32::from_be_bytes(b) }) - } - pub(crate) fn i32(&mut self) -> Result { - let b = self.array()?; - Ok(if self.little { i32::from_le_bytes(b) } else { i32::from_be_bytes(b) }) - } - pub(crate) fn u64(&mut self) -> Result { - let b = self.array()?; - Ok(if self.little { u64::from_le_bytes(b) } else { u64::from_be_bytes(b) }) - } - pub(crate) fn i64(&mut self) -> Result { - let b = self.array()?; - Ok(if self.little { i64::from_le_bytes(b) } else { i64::from_be_bytes(b) }) - } - pub(crate) fn f64(&mut self) -> Result { - let b = self.array()?; - Ok(if self.little { f64::from_le_bytes(b) } else { f64::from_be_bytes(b) }) - } - - /// Reads a length field that is 4 bytes, or 8 bytes when `long`. - pub(crate) fn len_field(&mut self, long: bool) -> Result { - if long { self.u64() } else { Ok(u64::from(self.u32()?)) } - } - - /// Returns `Err` unless `count * min_item_size` bytes remain. Used to - /// validate element counts before allocating. - pub(crate) fn check_count(&self, count: u64, min_item_size: u64) -> Result<()> { - let need = count.saturating_mul(min_item_size); - if need > self.remaining() as u64 { - return Err(PsdError::UnexpectedEof { offset: self.pos, needed: usize::try_from(need - self.remaining() as u64).unwrap_or(usize::MAX) }); - } - Ok(()) - } -} - -/// Big-endian writing helpers on `Vec`. -pub(crate) trait WriteExt { - fn put_u8(&mut self, v: u8); - fn put_u16(&mut self, v: u16); - fn put_i16(&mut self, v: i16); - fn put_u32(&mut self, v: u32); - fn put_i32(&mut self, v: i32); - fn put_u64(&mut self, v: u64); - fn put_i64(&mut self, v: i64); - fn put_f64(&mut self, v: f64); - fn put(&mut self, b: &[u8]); - /// Writes a length field (4 or 8 bytes). - fn put_len(&mut self, v: u64, long: bool) -> Result<()>; - /// Reserves a length placeholder, returning its position. - fn begin_len(&mut self, long: bool) -> usize; - /// Back-patches a placeholder with the number of bytes written since. - fn end_len(&mut self, at: usize, long: bool) -> Result<()>; -} - -impl WriteExt for Vec { - fn put_u8(&mut self, v: u8) { - self.push(v); - } - fn put_u16(&mut self, v: u16) { - self.extend_from_slice(&v.to_be_bytes()); - } - fn put_i16(&mut self, v: i16) { - self.extend_from_slice(&v.to_be_bytes()); - } - fn put_u32(&mut self, v: u32) { - self.extend_from_slice(&v.to_be_bytes()); - } - fn put_i32(&mut self, v: i32) { - self.extend_from_slice(&v.to_be_bytes()); - } - fn put_u64(&mut self, v: u64) { - self.extend_from_slice(&v.to_be_bytes()); - } - fn put_i64(&mut self, v: i64) { - self.extend_from_slice(&v.to_be_bytes()); - } - fn put_f64(&mut self, v: f64) { - self.extend_from_slice(&v.to_be_bytes()); - } - fn put(&mut self, b: &[u8]) { - self.extend_from_slice(b); - } - fn put_len(&mut self, v: u64, long: bool) -> Result<()> { - if long { - self.put_u64(v); - } else { - let v = u32::try_from(v).map_err(|_| PsdError::LimitExceeded("section too large for 32-bit length (use PSB)"))?; - self.put_u32(v); - } - Ok(()) - } - fn begin_len(&mut self, long: bool) -> usize { - let at = self.len(); - self.extend_from_slice(if long { &[0u8; 8] } else { &[0u8; 4] }); - at - } - fn end_len(&mut self, at: usize, long: bool) -> Result<()> { - let hdr = if long { 8 } else { 4 }; - let n = (self.len() - at - hdr) as u64; - if long { - self[at..at + 8].copy_from_slice(&n.to_be_bytes()); - } else { - let v = u32::try_from(n).map_err(|_| PsdError::LimitExceeded("section too large for 32-bit length (use PSB)"))?; - self[at..at + 4].copy_from_slice(&v.to_be_bytes()); - } - Ok(()) - } -} - -/// Staged bytes [`Sink`] collects before writing them out in one call. -const STAGE: usize = 64 << 10; -/// Bytes of stored channel data read per piece by [`Sink::put_encoded`]. -const COPY_CHUNK: u64 = if cfg!(test) { 7 } else { 8 << 20 }; -#[cfg(test)] -pub(crate) const STAGE_FOR_TESTS: usize = STAGE; - -/// Writes the file to a seekable stream as it is serialized (#6607): the small parts (headers, -/// records, lengths) are staged in a short buffer, while pixel data and other long slices go -/// straight to the stream, so the whole file never sits in memory. A length placeholder -/// ([`Sink::begin_len`]) that was already written out is back-patched by seeking. The bytes are -/// exactly the ones the `Vec` writers ([`WriteExt`]) produce. -pub(crate) struct Sink<'w, W: Write + Seek + ?Sized> { - w: &'w mut W, - /// Bytes not yet written; they start at stream position `base`. - buf: Vec, - base: u64, - /// Staged bytes that trigger a write ([`STAGE`]; tests use less). - stage: usize, -} - -impl<'w, W: Write + Seek + ?Sized> Sink<'w, W> { - /// A sink writing at `w`'s current position. - pub(crate) fn new(w: &'w mut W) -> std::io::Result { - Self::staging(w, STAGE) - } - - /// [`Self::new`] writing out once `stage` bytes are staged. - pub(crate) fn staging(w: &'w mut W, stage: usize) -> std::io::Result { - let base = w.stream_position()?; - Ok(Sink { w, buf: Vec::new(), base, stage }) - } - - /// The stream position of the next byte. - pub(crate) fn pos(&self) -> u64 { - self.base + self.buf.len() as u64 - } - - /// Appends a small part built by `f` with the `Vec` writers. Lengths `f` back-patches stay - /// inside what it appends. - pub(crate) fn small(&mut self, f: impl FnOnce(&mut Vec) -> Result<()>) -> std::io::Result<()> { - f(&mut self.buf)?; - if self.buf.len() >= self.stage { self.flush() } else { Ok(()) } - } - - /// Appends `b`, straight to the stream when it is long. - pub(crate) fn put(&mut self, b: &[u8]) -> std::io::Result<()> { - if self.buf.len() + b.len() < self.stage { - self.buf.extend_from_slice(b); - return Ok(()); - } - self.flush()?; - self.w.write_all(b)?; - self.base += b.len() as u64; - Ok(()) - } - - /// Appends RLE channel data whose 2-byte row-count table widens to 4 bytes (a PSD model - /// written as PSB): the table is widened in staged pieces, the packed rows go straight - /// through: each two-byte count becomes `00 00` and the count, the packed rows are unchanged. - pub(crate) fn put_widened_rle(&mut self, data: &[u8], rows: usize) -> std::io::Result<()> { - let table_len = usize::try_from(crate::compression::rle_widening(data, rows)?).map_err(|_| PsdError::LimitExceeded("row count overflow"))?; - let (table, packed) = data.split_at_checked(table_len).ok_or_else(|| PsdError::invalid("RLE row-count table out of range"))?; - for part in table.chunks(STAGE / 2) { - self.widen_counts(part)?; - } - self.put(packed) - } - - /// [`Self::put_widened_rle`] for data held in memory (`data`) or, when `stored`, still in its - /// source: the count table and then the packed rows are read in [`COPY_CHUNK`]-sized parts - /// (#5333), so a staged merged image or channel is never read back whole to widen it. - pub(crate) fn put_widened_encoded(&mut self, data: &[u8], stored: Option<&crate::source::Stored>, rows: usize) -> std::io::Result<()> { - let Some(s) = stored else { return self.put_widened_rle(data, rows) }; - let table_len = crate::compression::rle_widening_len(s.len, rows)?; - // Whole counts per part; the parts follow each other, so a staged range is read in one pass. - let table_part = (COPY_CHUNK / 2).max(1) * 2; - let mut at = 0u64; - while at < s.len { - let n = if at < table_len { (table_len - at).min(table_part) } else { (s.len - at).min(COPY_CHUNK) }; - let part = s.read_part(at, n)?; - if at < table_len { - self.widen_counts(&part)? - } else { - self.put(&part)? - } - at += n; - } - Ok(()) - } - - /// Two-byte RLE row counts as four-byte ones (`00 00` and the count), staged. - fn widen_counts(&mut self, counts: &[u8]) -> std::io::Result<()> { - self.small(|o| { - for count in counts.as_chunks::<2>().0 { - o.extend_from_slice(&[0, 0, count[0], count[1]]); - } - Ok(()) - }) - } - - /// Appends encoded bytes held in memory (`data`) or, when `stored`, still in their source: - /// those are copied through in [`COPY_CHUNK`] pieces, so a channel or merged image kept on - /// disk (an imported file's, or one an exporter spilled to scratch) is never read whole. - pub(crate) fn put_encoded(&mut self, data: &[u8], stored: Option<&crate::source::Stored>) -> std::io::Result<()> { - let Some(s) = stored else { return self.put(data) }; - let mut at = 0u64; - while at < s.len { - let n = (s.len - at).min(COPY_CHUNK); - let part = s.read_part(at, n)?; - self.put(&part)?; - at += n; - } - Ok(()) - } - - /// `n` zero bytes (padding, at most a few). - pub(crate) fn zeros(&mut self, n: usize) -> std::io::Result<()> { - self.small(|b| { - b.extend(std::iter::repeat_n(0u8, n)); - Ok(()) - }) - } - - /// Reserves a length placeholder (4 or 8 bytes), returning its stream position. - pub(crate) fn begin_len(&mut self, long: bool) -> std::io::Result { - let at = self.pos(); - self.zeros(if long { 8 } else { 4 })?; - Ok(at) - } - - /// Back-patches the placeholder at `at` with the number of bytes written since. - pub(crate) fn end_len(&mut self, at: u64, long: bool) -> std::io::Result<()> { - let hdr = if long { 8 } else { 4 }; - let n = self.pos() - at - hdr; - let bytes = if long { - n.to_be_bytes().to_vec() - } else { - u32::try_from(n).map_err(|_| PsdError::LimitExceeded("section too large for 32-bit length (use PSB)"))?.to_be_bytes().to_vec() - }; - if at >= self.base { - let i = (at - self.base) as usize; - self.buf[i..i + bytes.len()].copy_from_slice(&bytes); - return Ok(()); - } - self.flush()?; - self.w.seek(SeekFrom::Start(at))?; - self.w.write_all(&bytes)?; - self.w.seek(SeekFrom::Start(self.base))?; - Ok(()) - } - - fn flush(&mut self) -> std::io::Result<()> { - self.w.write_all(&self.buf)?; - self.base += self.buf.len() as u64; - self.buf.clear(); - Ok(()) - } - - /// Writes out what is still staged. Dropping a sink without it loses those bytes. - pub(crate) fn finish(mut self) -> std::io::Result<()> { - self.flush()?; - self.w.flush() - } -} - -/// Runs a streaming writer on the end of `out`, the `Vec` writers' way of sharing it. -pub(crate) fn append_with(out: &mut Vec, f: impl FnOnce(&mut Sink<'_, std::io::Cursor<&mut Vec>>) -> std::io::Result<()>) -> Result<()> { - let at = out.len() as u64; - let mut c = std::io::Cursor::new(out); - c.set_position(at); - let mut s = Sink::new(&mut c).map_err(PsdError::from_io)?; - f(&mut s).map_err(PsdError::from_io)?; - s.finish().map_err(PsdError::from_io) -} - -/// Reads a Pascal string (length byte + bytes) whose total size is padded to -/// a multiple of `align`. Returns the raw name bytes. -pub(crate) fn read_pascal(r: &mut Reader<'_>, align: usize) -> Result> { - let n = r.u8()? as usize; - let name = r.bytes(n)?.to_vec(); - let total = 1 + n; - let pad = (align - total % align) % align; - r.skip(pad)?; - Ok(name) -} - -pub(crate) fn write_pascal(out: &mut Vec, name: &[u8], align: usize) { - let n = name.len().min(255); - out.put_u8(n as u8); - out.put(&name[..n]); - let total = 1 + n; - let pad = (align - total % align) % align; - out.extend(std::iter::repeat_n(0u8, pad)); -} - -/// Decodes legacy (Pascal) names: UTF-8 when valid, otherwise Latin-1. -pub(crate) fn decode_legacy_name(b: &[u8]) -> String { - match std::str::from_utf8(b) { - Ok(s) => s.to_owned(), - Err(_) => b.iter().map(|&c| c as char).collect(), - } -} - -/// Encodes a name for a legacy Pascal field: non-ASCII chars become `?`. -pub(crate) fn encode_legacy_name(s: &str) -> Vec { - s.chars().map(|c| if c.is_ascii() && !c.is_ascii_control() { c as u8 } else { b'?' }).take(255).collect() -} - -/// Reads a Photoshop Unicode string: u32 code-unit count + UTF-16BE units. -pub(crate) fn read_unicode_units(r: &mut Reader<'_>) -> Result> { - let n = r.u32()? as u64; - r.check_count(n, 2)?; - let mut v = Vec::with_capacity(n as usize); - for _ in 0..n { - v.push(r.u16()?); - } - Ok(v) -} - -pub(crate) fn write_unicode_units(out: &mut Vec, units: &[u16]) { - out.put_u32(units.len() as u32); - for &u in units { - out.put_u16(u); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn reader_reads_big_endian() { - let data = [0x00, 0x01, 0xff, 0xfe, 0x00, 0x00, 0x00, 0x02]; - let mut r = Reader::new(&data); - assert_eq!(r.u16().unwrap(), 1); - assert_eq!(r.i16().unwrap(), -2); - assert_eq!(r.u32().unwrap(), 2); - assert!(r.is_empty()); - assert!(matches!(r.u8(), Err(PsdError::UnexpectedEof { .. }))); - } - - #[test] - fn reader_eof_reports_needed() { - let mut r = Reader::new(&[1, 2]); - match r.u32() { - Err(PsdError::UnexpectedEof { offset, needed }) => { - assert_eq!(offset, 0); - assert_eq!(needed, 2); - } - other => panic!("{other:?}"), - } - } - - #[test] - fn check_count_rejects_huge() { - let r = Reader::new(&[0; 8]); - assert!(r.check_count(u64::MAX, 4).is_err()); - assert!(r.check_count(2, 4).is_ok()); - assert!(r.check_count(3, 4).is_err()); - } - - #[test] - fn len_patch_roundtrip() { - let mut v = Vec::new(); - let at = v.begin_len(false); - v.put(&[1, 2, 3]); - v.end_len(at, false).unwrap(); - assert_eq!(v, [0, 0, 0, 3, 1, 2, 3]); - let mut v = Vec::new(); - let at = v.begin_len(true); - v.put(&[9]); - v.end_len(at, true).unwrap(); - assert_eq!(v, [0, 0, 0, 0, 0, 0, 0, 1, 9]); - } - - #[test] - fn pascal_padding() { - for (name, align, total) in [(&b""[..], 2, 2), (&b"a"[..], 2, 2), (&b"ab"[..], 2, 4), (&b""[..], 4, 4), (&b"abc"[..], 4, 4), (&b"abcd"[..], 4, 8)] { - let mut v = Vec::new(); - write_pascal(&mut v, name, align); - assert_eq!(v.len(), total, "{name:?} align {align}"); - let mut r = Reader::new(&v); - assert_eq!(read_pascal(&mut r, align).unwrap(), name); - assert!(r.is_empty()); - } - } - - #[test] - fn legacy_names() { - assert_eq!(decode_legacy_name(b"Layer 1"), "Layer 1"); - assert_eq!(decode_legacy_name(&[0x41, 0xe9]), "A\u{e9}"); - assert_eq!(encode_legacy_name("h\u{e9}llo"), b"h?llo"); - } - - #[test] - fn unicode_units_roundtrip() { - let mut v = Vec::new(); - write_unicode_units(&mut v, &[0x48, 0x69, 0]); - let mut r = Reader::new(&v); - assert_eq!(read_unicode_units(&mut r).unwrap(), vec![0x48, 0x69, 0]); - let mut r = Reader::new(&[0xff, 0xff, 0xff, 0xff]); - assert!(read_unicode_units(&mut r).is_err()); - } -} diff --git a/crates/psd/src/layer.rs b/crates/psd/src/layer.rs deleted file mode 100644 index 90f6550863..0000000000 --- a/crates/psd/src/layer.rs +++ /dev/null @@ -1,889 +0,0 @@ -//! Layer records, layer masks, blending ranges and per-layer channel data. - -use std::borrow::Cow; - -use crate::blend::BlendMode; -use crate::compression::{Compression, PlaneLayout, decode_planes, encode_planes, rle_widening_len}; -use crate::error::{PsdError, Result}; -use crate::header::{MAX_DIMENSION, Version}; -use crate::io::{Reader, Sink, WriteExt, append_with, decode_legacy_name, read_pascal, write_pascal}; -use crate::source::Stored; -use crate::tagged::{BlockData, SectionDivider, SectionType, TaggedBlock, read_blocks, uses_long_length, write_blocks}; - -/// Channel id of the transparency mask. -pub const CHANNEL_TRANSPARENCY: i16 = -1; -/// Channel id of the user-supplied layer mask. -pub const CHANNEL_USER_MASK: i16 = -2; -/// Channel id of the real user-supplied layer mask (when both a user mask and -/// a vector mask are present). -pub const CHANNEL_REAL_USER_MASK: i16 = -3; - -/// Maximum number of channels per layer accepted by the parser. -pub const MAX_LAYER_CHANNELS: u16 = 64; - -/// A rectangle as stored in PSD files (top, left, bottom, right). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct Rect { - /// Top edge (inclusive). - pub top: i32, - /// Left edge (inclusive). - pub left: i32, - /// Bottom edge (exclusive). - pub bottom: i32, - /// Right edge (exclusive). - pub right: i32, -} - -impl Rect { - /// Rectangle from origin and size. - pub fn from_xywh(left: i32, top: i32, width: u32, height: u32) -> Self { - Rect { top, left, bottom: top.saturating_add(height.min(i32::MAX as u32) as i32), right: left.saturating_add(width.min(i32::MAX as u32) as i32) } - } - /// Width (may be negative for malformed data). - pub fn width(&self) -> i64 { - i64::from(self.right) - i64::from(self.left) - } - /// Height (may be negative for malformed data). - pub fn height(&self) -> i64 { - i64::from(self.bottom) - i64::from(self.top) - } - /// `true` when width or height is zero or negative. - pub fn is_empty(&self) -> bool { - self.width() <= 0 || self.height() <= 0 - } - /// Validated `(width, height)` as `usize`. - pub fn size(&self) -> Result<(usize, usize)> { - let (w, h) = (self.width(), self.height()); - if w < 0 || h < 0 { - return Err(PsdError::invalid("rectangle has negative size")); - } - if w > i64::from(MAX_DIMENSION) || h > i64::from(MAX_DIMENSION) { - return Err(PsdError::LimitExceeded("layer dimensions exceed 300000")); - } - Ok((w as usize, h as usize)) - } - fn read(r: &mut Reader<'_>) -> Result { - Ok(Rect { top: r.i32()?, left: r.i32()?, bottom: r.i32()?, right: r.i32()? }) - } - fn write(&self, out: &mut Vec) { - out.put_i32(self.top); - out.put_i32(self.left); - out.put_i32(self.bottom); - out.put_i32(self.right); - } -} - -/// Layer record flag byte. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct LayerFlags(pub u8); - -impl LayerFlags { - /// Bit 0. - pub const TRANSPARENCY_PROTECTED: u8 = 1 << 0; - /// Bit 1 (set = hidden). - pub const HIDDEN: u8 = 1 << 1; - /// Bit 2 (obsolete). - pub const OBSOLETE: u8 = 1 << 2; - /// Bit 3: bit 4 carries useful information. - pub const BIT4_USEFUL: u8 = 1 << 3; - /// Bit 4: pixel data irrelevant to the appearance of the document. - pub const PIXEL_DATA_IRRELEVANT: u8 = 1 << 4; - - fn bit(self, b: u8) -> bool { - self.0 & b != 0 - } - fn set(&mut self, b: u8, on: bool) { - if on { - self.0 |= b; - } else { - self.0 &= !b; - } - } - /// Transparency protected. - pub fn transparency_protected(self) -> bool { - self.bit(Self::TRANSPARENCY_PROTECTED) - } - /// Hidden. - pub fn hidden(self) -> bool { - self.bit(Self::HIDDEN) - } - /// Bit 4 is meaningful. - pub fn bit4_useful(self) -> bool { - self.bit(Self::BIT4_USEFUL) - } - /// Pixel data irrelevant (only meaningful when `bit4_useful`). - pub fn pixel_data_irrelevant(self) -> bool { - self.bit(Self::PIXEL_DATA_IRRELEVANT) - } - /// Sets hidden. - pub fn set_hidden(&mut self, v: bool) { - self.set(Self::HIDDEN, v); - } - /// Sets transparency protection. - pub fn set_transparency_protected(&mut self, v: bool) { - self.set(Self::TRANSPARENCY_PROTECTED, v); - } - /// Sets pixel-data-irrelevant (also sets bit 3). - pub fn set_pixel_data_irrelevant(&mut self, v: bool) { - self.set(Self::BIT4_USEFUL, true); - self.set(Self::PIXEL_DATA_IRRELEVANT, v); - } -} - -/// Optional mask parameters (present when mask flag bit 4 is set). -#[derive(Debug, Clone, Copy, PartialEq, Default)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct MaskParameters { - /// Parameter flags byte (bit 0 user density, 1 user feather, 2 vector - /// density, 3 vector feather). - pub flags: u8, - /// User mask density. - pub user_density: Option, - /// User mask feather. - pub user_feather: Option, - /// Vector mask density. - pub vector_density: Option, - /// Vector mask feather. - pub vector_feather: Option, -} - -/// "Real" user mask fields (present in 36+-byte mask records). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct RealMask { - /// Real flags. - pub flags: u8, - /// Real user mask background (0 or 255). - pub background: u8, - /// Real mask rectangle. - pub rect: Rect, -} - -/// Layer mask / adjustment layer data. -#[derive(Debug, Clone, PartialEq)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct LayerMask { - /// Mask rectangle. - pub rect: Rect, - /// Default color outside the rectangle (0 or 255). - pub default_color: u8, - /// Flags: bit 0 position relative to layer, 1 disabled, 2 invert - /// (obsolete), 3 user mask came from rendering other data, 4 parameters - /// applied. - pub flags: u8, - /// Mask parameters (when flag bit 4 is set). - pub parameters: Option, - /// Real user mask fields. - pub real: Option, - /// Remaining bytes (the 2 padding bytes of the 20-byte form, etc.). - pub trailing: Vec, - /// When both are present, the real-mask fields come before the mask parameters. Photoshop - /// writes this order (the published spec lists the parameters first); `false` keeps a file - /// that uses the spec's order byte-stable. - #[cfg_attr(feature = "serde", serde(default = "default_true"))] - pub real_first: bool, -} - -#[cfg(feature = "serde")] -fn default_true() -> bool { - true -} - -/// Byte length of the mask parameter values announced by the parameter flags byte `pf`. -pub(crate) fn mask_parameter_bytes(pf: u8) -> usize { - usize::from(pf & 1 != 0) + 8 * usize::from(pf & 2 != 0) + usize::from(pf & 4 != 0) + 8 * usize::from(pf & 8 != 0) -} - -impl LayerMask { - /// Mask flag: position relative to layer. - pub const FLAG_RELATIVE: u8 = 1 << 0; - /// Mask flag: disabled. - pub const FLAG_DISABLED: u8 = 1 << 1; - /// Mask flag: invert when blending (obsolete). - pub const FLAG_INVERT: u8 = 1 << 2; - /// Mask flag: parameters present. - pub const FLAG_PARAMETERS: u8 = 1 << 4; - - /// The simple 20-byte form. - pub fn new(rect: Rect, default_color: u8, flags: u8) -> Self { - LayerMask { rect, default_color, flags: flags & !Self::FLAG_PARAMETERS, parameters: None, real: None, trailing: vec![0, 0], real_first: true } - } - /// `true` if the disabled flag is set. - pub fn disabled(&self) -> bool { - self.flags & Self::FLAG_DISABLED != 0 - } - - /// Whether `d` (a whole mask record with mask parameters) stores the real-mask fields before - /// the parameters, as Photoshop does. The spec's order wins when it reads as parameters - /// followed by padding alone (no real mask), the one layout the two orders can share. When - /// both orders fit the record's length, Photoshop's is taken unless its reading is - /// implausible (a real-mask background that is neither 0 nor 255, or parameter flag bits - /// the spec does not define) while the spec's reading with a real mask is sound. - pub(crate) fn real_before_parameters(d: &[u8]) -> bool { - // Parameters at `pf_at`, then 0..=3 bytes of padding, and nothing else. - let fits = - |pf_at: usize| d.get(pf_at).is_some_and(|&pf| (pf_at + 1 + mask_parameter_bytes(pf)..=pf_at + 4 + mask_parameter_bytes(pf)).contains(&d.len())); - if fits(18) { - return false; - } - let photoshop = fits(36); - if !photoshop { - return false; - } - let known_flags = |pf: u8| pf & 0xF0 == 0; - let real_background_ok = |at: usize| d.get(at + 1).is_some_and(|&bg| bg == 0 || bg == 255); - // Photoshop's reading: real-mask fields at 18, parameters at 36. - let photoshop_plausible = real_background_ok(18) && d.get(36).is_some_and(|&pf| known_flags(pf)); - // The spec's reading: parameters at 18, then the 18 real-mask bytes, then padding. - let spec_plausible = d.get(18).is_some_and(|&pf| { - let real_at = 19 + mask_parameter_bytes(pf); - known_flags(pf) && (real_at + 18..=real_at + 21).contains(&d.len()) && real_background_ok(real_at) - }); - photoshop_plausible || !spec_plausible - } - - fn parse(d: &[u8]) -> Result { - let mut r = Reader::new(d); - let rect = Rect::read(&mut r)?; - let default_color = r.u8()?; - let flags = r.u8()?; - let read_real = |r: &mut Reader<'_>| -> Result> { - Ok(if r.remaining() >= 18 { Some(RealMask { flags: r.u8()?, background: r.u8()?, rect: Rect::read(r)? }) } else { None }) - }; - let has_parameters = flags & Self::FLAG_PARAMETERS != 0; - let real_first = has_parameters && Self::real_before_parameters(d); - let mut real = if real_first { read_real(&mut r)? } else { None }; - let parameters = if has_parameters { - let pf = r.u8()?; - Some(MaskParameters { - flags: pf, - user_density: if pf & 1 != 0 { Some(r.u8()?) } else { None }, - user_feather: if pf & 2 != 0 { Some(r.f64()?) } else { None }, - vector_density: if pf & 4 != 0 { Some(r.u8()?) } else { None }, - vector_feather: if pf & 8 != 0 { Some(r.f64()?) } else { None }, - }) - } else { - None - }; - if !real_first { - real = read_real(&mut r)?; - } - // The order only matters when both are present; otherwise default to Photoshop's. - let real_first = real_first || real.is_none() || parameters.is_none(); - Ok(LayerMask { rect, default_color, flags, parameters, real, trailing: r.peek_rest().to_vec(), real_first }) - } - - /// Bytes [`Self::write`] produces. - fn encoded_len(&self) -> u64 { - let real = if self.real.is_some() { 18 } else { 0 }; - let parameters = self.parameters.as_ref().map_or(0, |p| { - 1 + u64::from(p.user_density.is_some()) - + 8 * u64::from(p.user_feather.is_some()) - + u64::from(p.vector_density.is_some()) - + 8 * u64::from(p.vector_feather.is_some()) - }); - 18 + real + parameters + self.trailing.len() as u64 - } - - fn write(&self, out: &mut Vec) { - self.rect.write(out); - out.put_u8(self.default_color); - out.put_u8(self.flags); - let write_real = |out: &mut Vec| { - if let Some(real) = &self.real { - out.put_u8(real.flags); - out.put_u8(real.background); - real.rect.write(out); - } - }; - if self.real_first { - write_real(out); - } - if let Some(p) = &self.parameters { - out.put_u8(p.flags); - if let Some(v) = p.user_density { - out.put_u8(v); - } - if let Some(v) = p.user_feather { - out.put_f64(v); - } - if let Some(v) = p.vector_density { - out.put_u8(v); - } - if let Some(v) = p.vector_feather { - out.put_f64(v); - } - } - if !self.real_first { - write_real(out); - } - out.put(&self.trailing); - } -} - -/// The layer mask data field of a layer record. -#[derive(Debug, Clone, PartialEq, Default)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub enum MaskData { - /// Length 0. - #[default] - None, - /// Parsed mask. - Mask(LayerMask), - /// Unparseable mask data, preserved. - Raw(Vec), -} - -/// Layer blending ranges ("Blend If"), stored raw. The data is a sequence of -/// 8-byte entries: composite gray first, then one per channel; each entry is -/// source (black lo, black hi, white lo, white hi) then destination. -#[derive(Debug, Clone, PartialEq, Eq, Default)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct BlendingRanges { - /// Raw bytes. - pub data: Vec, -} - -/// One blending range entry. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct BlendRange { - /// Source range: black low, black high, white low, white high. - pub source: [u8; 4], - /// Destination range. - pub dest: [u8; 4], -} - -impl BlendingRanges { - /// Default "full" ranges for composite + `channels` channels. - pub fn full(channels: usize) -> Self { - let mut data = Vec::with_capacity((channels + 1) * 8); - for _ in 0..=channels { - data.extend_from_slice(&[0, 0, 255, 255, 0, 0, 255, 255]); - } - BlendingRanges { data } - } - /// Parsed entries (incomplete trailing bytes are ignored). - pub fn ranges(&self) -> Vec { - self.data.as_chunks::<8>().0.iter().map(|c| BlendRange { source: [c[0], c[1], c[2], c[3]], dest: [c[4], c[5], c[6], c[7]] }).collect() - } -} - -/// Encoded image data for one layer channel. -/// -/// The encoded bytes are in `data`, or, for a file parsed by -/// [`PsdFile::from_source`](crate::PsdFile::from_source), still in the source at `stored` (and -/// `data` is empty). Every method here reads them from wherever they are; two channels are equal -/// when their bytes are, wherever each one keeps them. -#[derive(Debug, Clone)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct ChannelData { - /// Channel id: 0.. color channels, -1 transparency, -2 user mask, - /// -3 real user mask. - pub id: i16, - /// Compression; `None` when the stored length was 0 (no compression - /// field at all). - pub compression: Option, - /// Encoded bytes following the compression field, kept verbatim (empty while `stored`). - pub data: Vec, - /// Where the encoded bytes are when they were left in the source: see [`Self::load`]. - /// (Not serialized: load the channel first.) - #[cfg_attr(feature = "serde", serde(skip))] - pub stored: Option, -} - -impl PartialEq for ChannelData { - fn eq(&self, other: &Self) -> bool { - if self.id != other.id || self.compression != other.compression || self.encoded_len() != other.encoded_len() { - return false; - } - match (self.encoded(), other.encoded()) { - (Ok(a), Ok(b)) => a == b, - _ => false, - } - } -} - -impl Eq for ChannelData {} - -impl ChannelData { - /// Encodes planar samples (`height × row_bytes`). - pub fn encode(id: i16, compression: Compression, decoded: &[u8], width: usize, height: usize, depth: u16, version: Version) -> Result { - let layout = PlaneLayout { planes: 1, width, height, depth, version }; - Ok(ChannelData::new(id, Some(compression), encode_planes(compression, decoded, &layout)?)) - } - - /// A channel whose encoded bytes are in memory. - pub fn new(id: i16, compression: Option, data: Vec) -> Self { - ChannelData { id, compression, data, stored: None } - } - - /// The encoded bytes (after the compression field): borrowed from `data`, or read from the - /// source when they were left there. - pub fn encoded(&self) -> Result> { - crate::source::encoded(&self.data, self.stored.as_ref()) - } - - /// Number of encoded bytes, wherever they are. - pub fn encoded_len(&self) -> u64 { - self.stored.as_ref().map_or(self.data.len() as u64, |s| s.len) - } - - /// Reads bytes left in the source into `data`, so the channel no longer needs its source. - pub fn load(&mut self) -> Result<()> { - if let Some(s) = &self.stored { - self.data = s.read()?; - self.stored = None; - } - Ok(()) - } - - /// Decodes into planar big-endian samples. - pub fn decode(&self, width: usize, height: usize, depth: u16, version: Version) -> Result> { - let layout = PlaneLayout { planes: 1, width, height, depth, version }; - match self.compression { - Some(c) => decode_planes(c, &self.encoded()?, &layout), - None => { - if layout.decoded_len()? == 0 { - Ok(Vec::new()) - } else { - Err(PsdError::invalid("channel has no data")) - } - } - } - } - - /// Replaces the channel contents, re-encoding with `compression`. - #[allow(clippy::too_many_arguments)] - pub fn set_decoded(&mut self, compression: Compression, decoded: &[u8], width: usize, height: usize, depth: u16, version: Version) -> Result<()> { - *self = Self::encode(self.id, compression, decoded, width, height, depth, version)?; - Ok(()) - } - - fn stored_len(&self) -> u64 { - match self.compression { - Some(_) => 2 + self.encoded_len(), - None => 0, - } - } -} - -/// A layer record together with its channel image data. -#[derive(Debug, Clone, PartialEq)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct LayerRecord { - /// Layer bounds. - pub rect: Rect, - /// Channels in stored order, including encoded image data. - pub channels: Vec, - /// Blend mode. - pub blend_mode: BlendMode, - /// Opacity 0..=255. - pub opacity: u8, - /// Clipping: 0 base, 1 non-base (clipped to layer below). - pub clipping: u8, - /// Flags. - pub flags: LayerFlags, - /// Filler byte (zero), preserved. - pub filler: u8, - /// Layer mask data. - pub mask: MaskData, - /// Blending ranges. - pub blending_ranges: BlendingRanges, - /// Legacy Pascal name bytes (padded to 4 in the file). - pub name: Vec, - /// Additional layer information blocks. - pub blocks: Vec, - /// Bytes at the end of the extra-data field that do not form a block. - pub extra_trailing: Vec, -} - -impl Default for LayerRecord { - fn default() -> Self { - LayerRecord { - rect: Rect::default(), - channels: Vec::new(), - blend_mode: BlendMode::Normal, - opacity: 255, - clipping: 0, - flags: LayerFlags(0), - filler: 0, - mask: MaskData::None, - blending_ranges: BlendingRanges::default(), - name: Vec::new(), - blocks: Vec::new(), - extra_trailing: Vec::new(), - } - } -} - -impl LayerRecord { - /// Layer name, preferring the Unicode `luni` block over the legacy name. - pub fn name(&self) -> String { - for b in &self.blocks { - if &b.key == b"luni" - && let Some(Ok(BlockData::UnicodeName(n))) = b.parsed() - { - return n; - } - } - decode_legacy_name(&self.name) - } - - /// First block with the given key. - pub fn block(&self, key: &[u8; 4]) -> Option<&TaggedBlock> { - self.blocks.iter().find(|b| &b.key == key) - } - - /// Mutable access to the first block with the given key. - pub fn block_mut(&mut self, key: &[u8; 4]) -> Option<&mut TaggedBlock> { - self.blocks.iter_mut().find(|b| &b.key == key) - } - - /// Section divider (`lsct`, falling back to `lsdk`), if present and valid. - pub fn section_divider(&self) -> Option { - for key in [b"lsct", b"lsdk"] { - if let Some(Ok(BlockData::SectionDivider(s))) = self.block(key).and_then(TaggedBlock::parsed) { - return Some(s); - } - } - None - } - - /// Section type (Other if no divider). - pub fn section_type(&self) -> SectionType { - self.section_divider().map_or(SectionType::Other, |s| s.kind) - } - - /// `true` unless the hidden flag is set. - pub fn is_visible(&self) -> bool { - !self.flags.hidden() - } - - /// Layer id from `lyid`. - pub fn layer_id(&self) -> Option { - match self.block(b"lyid")?.parsed()? { - Ok(BlockData::LayerId(v)) => Some(v), - _ => None, - } - } - - /// Fill opacity from `iOpa` (255 if absent). - pub fn fill_opacity(&self) -> u8 { - match self.block(b"iOpa").and_then(TaggedBlock::parsed) { - Some(Ok(BlockData::FillOpacity(v))) => v, - _ => 255, - } - } - - /// Parsed layer mask, if any. - pub fn layer_mask(&self) -> Option<&LayerMask> { - match &self.mask { - MaskData::Mask(m) => Some(m), - _ => None, - } - } - - /// Channel with the given id. - pub fn channel(&self, id: i16) -> Option<&ChannelData> { - self.channels.iter().find(|c| c.id == id) - } - - /// Rectangle that a channel's pixels cover: the layer rect for color and - /// transparency channels, the mask rect for -2, the real mask rect for -3. - pub fn channel_rect(&self, id: i16) -> Rect { - match (id, &self.mask) { - (CHANNEL_USER_MASK, MaskData::Mask(m)) => m.rect, - (CHANNEL_REAL_USER_MASK, MaskData::Mask(m)) => m.real.map_or(m.rect, |r| r.rect), - _ => self.rect, - } - } - - /// Decodes a channel's samples (planar, big-endian). - pub fn decode_channel(&self, id: i16, depth: u16, version: Version) -> Result> { - let ch = self.channel(id).ok_or_else(|| PsdError::invalid(format!("layer has no channel {id}")))?; - let (w, h) = self.channel_rect(id).size()?; - ch.decode(w, h, depth, version) - } - - pub(crate) fn read(r: &mut Reader<'_>, version: Version) -> Result<(Self, Vec)> { - let rect = Rect::read(r)?; - let nch = r.u16()?; - if nch > MAX_LAYER_CHANNELS { - return Err(PsdError::LimitExceeded("too many channels in layer")); - } - let mut channels = Vec::with_capacity(usize::from(nch)); - let mut lens = Vec::with_capacity(usize::from(nch)); - for _ in 0..nch { - let id = r.i16()?; - let len = r.len_field(version.is_psb())?; - channels.push(ChannelData::new(id, None, Vec::new())); - lens.push(len); - } - let sig = r.array::<4>()?; - if &sig != b"8BIM" { - return Err(PsdError::InvalidSignature { expected: "8BIM (layer blend mode)", found: sig }); - } - let blend_mode = BlendMode::from_key(r.array()?); - let opacity = r.u8()?; - let clipping = r.u8()?; - let flags = LayerFlags(r.u8()?); - let filler = r.u8()?; - let extra_len = r.u32()?; - let mut x = r.sub(u64::from(extra_len))?; - let mask_len = x.u32()?; - let mask_bytes = x.bytes_u64(u64::from(mask_len))?; - let mask = if mask_bytes.is_empty() { - MaskData::None - } else { - match LayerMask::parse(mask_bytes) { - Ok(m) => MaskData::Mask(m), - Err(_) => MaskData::Raw(mask_bytes.to_vec()), - } - }; - let br_len = x.u32()?; - let blending_ranges = BlendingRanges { data: x.bytes_u64(u64::from(br_len))?.to_vec() }; - let name = read_pascal(&mut x, 4)?; - let (blocks, extra_trailing) = read_blocks(&mut x, version)?; - Ok((LayerRecord { rect, channels, blend_mode, opacity, clipping, flags, filler, mask, blending_ranges, name, blocks, extra_trailing }, lens)) - } - - /// Rows of a channel's RLE count table that grow when a PSD model is written as PSB - /// (`widen`, see [`crate::PsdFile::promote_to_psb`]); 0 for other channels. - pub(crate) fn widened_rows(&self, c: &ChannelData, widen: bool) -> Result { - if widen && c.compression == Some(Compression::Rle) { Ok(self.channel_rect(c.id).size()?.1) } else { Ok(0) } - } - - /// Bytes [`Self::write`] produces, computed without serializing. Errors where `write` would. - fn encoded_len(&self, version: Version) -> Result { - let too_big = || PsdError::LimitExceeded("section too large for 32-bit length (use PSB)"); - u16::try_from(self.channels.len()).map_err(|_| PsdError::LimitExceeded("too many channels"))?; - let channel_len: u64 = if version.is_psb() { 8 } else { 4 }; - if !version.is_psb() && self.channels.iter().any(|c| u32::try_from(c.stored_len()).is_err()) { - return Err(too_big()); - } - let mask = match &self.mask { - MaskData::None => 0, - MaskData::Mask(mask) => mask.encoded_len(), - MaskData::Raw(b) => b.len() as u64, - }; - u32::try_from(mask).map_err(|_| too_big())?; - let ranges = self.blending_ranges.data.len() as u64; - u32::try_from(ranges).map_err(|_| too_big())?; - let name = 1 + self.name.len().min(255) as u64; - let name = name.next_multiple_of(4); - let mut extra = (4 + mask).saturating_add(4 + ranges).saturating_add(name).saturating_add(self.extra_trailing.len() as u64); - for b in &self.blocks { - if !uses_long_length(version, &b.key) && u32::try_from(b.data.len()).is_err() { - return Err(too_big()); - } - extra = extra.saturating_add(b.encoded_len(version) as u64); - } - u32::try_from(extra).map_err(|_| too_big())?; - // rect, channel count, channel entries, signature + blend key, four flag bytes, extra length. - Ok((16 + 2 + self.channels.len() as u64 * (2 + channel_len) + 8 + 4 + 4).saturating_add(extra)) - } - - fn write(&self, out: &mut Vec, version: Version, widen: bool) -> Result<()> { - self.rect.write(out); - let nch = u16::try_from(self.channels.len()).map_err(|_| PsdError::LimitExceeded("too many channels"))?; - out.put_u16(nch); - for c in &self.channels { - out.put_i16(c.id); - let rows = self.widened_rows(c, widen)?; - let grown = if rows == 0 { 0 } else { rle_widening_len(c.encoded_len(), rows)? }; - out.put_len(c.stored_len().saturating_add(grown), version.is_psb())?; - } - out.put(b"8BIM"); - out.put(&self.blend_mode.key()); - out.put_u8(self.opacity); - out.put_u8(self.clipping); - out.put_u8(self.flags.0); - out.put_u8(self.filler); - let at = out.begin_len(false); - let m = out.begin_len(false); - match &self.mask { - MaskData::None => {} - MaskData::Mask(mask) => mask.write(out), - MaskData::Raw(b) => out.put(b), - } - out.end_len(m, false)?; - out.put_len(self.blending_ranges.data.len() as u64, false)?; - out.put(&self.blending_ranges.data); - write_pascal(out, &self.name, 4); - write_blocks(out, &self.blocks, version, 2)?; - out.put(&self.extra_trailing); - out.end_len(at, false) - } -} - -/// Layer info: layer records plus their channel image data. -#[derive(Debug, Clone, PartialEq, Default)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct LayerInfo { - /// The layer count was stored negative: the first alpha channel of the - /// merged image contains the transparency of the merged result. - pub merged_alpha: bool, - /// Layer records, bottom-most first (file order). - pub layers: Vec, - /// Bytes after the channel data. `None` = default (zero pad to even). - pub padding: Option>, -} - -impl LayerInfo { - /// Parses a layer info body (the bytes covered by its length field). - pub(crate) fn read_body(data: &[u8], version: Version) -> Result { - let mut r = Reader::new(data); - let count = r.i16()?; - let merged_alpha = count < 0; - let n = count.unsigned_abs(); - // Minimum record size: 16 rect + 2 + 4 sig + 4 key + 4 + 4 extra len. - r.check_count(u64::from(n), 34)?; - let mut layers = Vec::with_capacity(usize::from(n)); - let mut all_lens = Vec::with_capacity(usize::from(n)); - for _ in 0..n { - let (rec, lens) = LayerRecord::read(&mut r, version)?; - layers.push(rec); - all_lens.push(lens); - } - for (rec, lens) in layers.iter_mut().zip(all_lens) { - for (ch, len) in rec.channels.iter_mut().zip(lens) { - if len == 0 { - continue; - } - if len == 1 { - return Err(PsdError::invalid("channel data length 1")); - } - ch.compression = Some(Compression::from_u16(r.u16()?)); - ch.data = r.bytes_u64(len - 2)?.to_vec(); - } - } - let body_len = r.pos(); - let rest = r.peek_rest(); - let padding = if rest.len() == body_len % 2 && rest.iter().all(|&b| b == 0) { None } else { Some(rest.to_vec()) }; - Ok(LayerInfo { merged_alpha, layers, padding }) - } - - /// Length of the body (layer count, records and channel data) without - /// its trailing padding. - pub fn unpadded_len(&self, version: Version) -> Result { - self.unpadded_len_as(version, false) - } - - /// [`Self::unpadded_len`] of the body written with its RLE count tables widened (`widen`). - pub(crate) fn unpadded_len_as(&self, version: Version, widen: bool) -> Result { - // Computed from the field sizes: serializing the records just to measure them would copy - // every tagged block. Widening changes channel data only: the record's length fields keep - // their width. - let mut n = 2u64; - for l in &self.layers { - n = n.saturating_add(l.encoded_len(version)?); - } - for l in &self.layers { - for c in l.channels.iter().filter(|c| c.compression.is_some()) { - let rows = l.widened_rows(c, widen)?; - let grown = if rows == 0 { 0 } else { rle_widening_len(c.encoded_len(), rows)? }; - n = n.saturating_add(2).saturating_add(c.encoded_len()).saturating_add(grown); - } - } - Ok(n) - } - - /// Sets the padding so the body length is a multiple of `align`, as - /// Photoshop writes it (4). Readers that step over a global `Lr16`/`Lr32` - /// block in 4-byte units (psd-tools) misread the blocks after a body that - /// is only padded to even. - pub fn pad_to(&mut self, version: Version, align: u64) -> Result<()> { - self.padding = Some(vec![0; self.pad_len(version, align)?]); - Ok(()) - } - - /// Number of zero bytes [`Self::pad_to`] would set, without touching `self`. - pub(crate) fn pad_len(&self, version: Version, align: u64) -> Result { - self.pad_len_as(version, false, align) - } - - /// [`Self::pad_len`] for the body written with its RLE count tables widened (`widen`). - pub(crate) fn pad_len_as(&self, version: Version, widen: bool, align: u64) -> Result { - let align = align.max(1); - let len = self.unpadded_len_as(version, widen)?; - Ok(((align - len % align) % align) as usize) - } - - /// Bytes of channel data and their compression fields: the part of the body worth reserving up - /// front (the records are small next to the pixels). Saturates instead of overflowing. - /// (Saves reserve their exact length now, #6607; the tests still check it as a lower bound.) - #[cfg(test)] - pub(crate) fn channel_data_len(&self) -> usize { - self.layers - .iter() - .flat_map(|l| &l.channels) - .filter(|c| c.compression.is_some()) - .fold(0usize, |n, c| n.saturating_add(2).saturating_add(usize::try_from(c.encoded_len()).unwrap_or(usize::MAX))) - } - - /// Serializes the body without any trailing padding (the padding field is ignored). - pub(crate) fn write_unpadded(&self, out: &mut Vec, version: Version) -> Result<()> { - append_with(out, |s| self.write_unpadded_to(s, version, false)) - } - - /// Serializes the body (without a length field). - #[cfg(test)] - pub(crate) fn write_body(&self, out: &mut Vec, version: Version) -> Result<()> { - append_with(out, |s| self.write_body_to(s, version, false)) - } - - /// [`Self::write_unpadded`] to a streaming [`Sink`], widening the RLE count tables of a PSD - /// model written as PSB (`widen`) on the way out: the records are staged, the channel data - /// goes straight through (#6607). - pub(crate) fn write_unpadded_to( - &self, - out: &mut Sink<'_, W>, - version: Version, - widen: bool, - ) -> std::io::Result<()> { - let n = i16::try_from(self.layers.len()).map_err(|_| PsdError::LimitExceeded("more than 32767 layers"))?; - out.small(|o| { - o.put_i16(if self.merged_alpha { -n } else { n }); - Ok(()) - })?; - for l in &self.layers { - out.small(|o| l.write(o, version, widen))?; - } - for l in &self.layers { - for c in &l.channels { - if let Some(comp) = c.compression { - out.small(|o| { - o.put_u16(comp.as_u16()); - Ok(()) - })?; - match l.widened_rows(c, widen)? { - 0 => out.put_encoded(&c.data, c.stored.as_ref())?, - rows => out.put_widened_encoded(&c.data, c.stored.as_ref(), rows)?, - } - } - } - } - Ok(()) - } - - /// The body (without a length field) to a streaming [`Sink`], widening RLE count tables when - /// `widen` (see [`Self::write_unpadded_to`]). - pub(crate) fn write_body_to( - &self, - out: &mut Sink<'_, W>, - version: Version, - widen: bool, - ) -> std::io::Result<()> { - let start = out.pos(); - self.write_unpadded_to(out, version, widen)?; - match &self.padding { - Some(p) => out.put(p), - None => out.zeros(((out.pos() - start) % 2) as usize), - } - } -} - -#[cfg(test)] -mod tests; diff --git a/crates/psd/src/layer/tests.rs b/crates/psd/src/layer/tests.rs deleted file mode 100644 index c831b1c6d2..0000000000 --- a/crates/psd/src/layer/tests.rs +++ /dev/null @@ -1,374 +0,0 @@ -use super::*; - -fn sample_record(version: Version) -> LayerRecord { - let rect = Rect::from_xywh(-3, 2, 5, 4); - let px: Vec = (0..20).collect(); - let mut channels = Vec::new(); - for (id, comp) in [(-1, Compression::Rle), (0, Compression::Raw), (1, Compression::Zip), (2, Compression::ZipPrediction)] { - channels.push(ChannelData::encode(id, comp, &px, 5, 4, 8, version).unwrap()); - } - let mask = LayerMask::new(Rect::from_xywh(0, 0, 2, 2), 255, LayerMask::FLAG_RELATIVE); - channels.push(ChannelData::encode(-2, Compression::Rle, &[1, 2, 3, 4], 2, 2, 8, version).unwrap()); - LayerRecord { - rect, - channels, - blend_mode: BlendMode::Screen, - opacity: 200, - clipping: 1, - flags: LayerFlags(LayerFlags::HIDDEN), - mask: MaskData::Mask(mask), - blending_ranges: BlendingRanges::full(3), - name: b"abc".to_vec(), - blocks: vec![TaggedBlock::unicode_name("\u{e4}bc"), TaggedBlock::layer_id(5)], - ..Default::default() - } -} - -fn rt(info: &LayerInfo, version: Version) -> Vec { - let mut out = Vec::new(); - info.write_body(&mut out, version).unwrap(); - let back = LayerInfo::read_body(&out, version).unwrap(); - assert_eq!(&back, info); - let mut again = Vec::new(); - back.write_body(&mut again, version).unwrap(); - assert_eq!(again, out); - out -} - -#[test] -fn empty_layer_info() { - let out = rt(&LayerInfo::default(), Version::Psd); - assert_eq!(out, vec![0, 0]); -} - -#[test] -fn record_roundtrip_psd_psb() { - for v in [Version::Psd, Version::Psb] { - let info = LayerInfo { merged_alpha: true, layers: vec![sample_record(v), LayerRecord::default()], padding: None }; - let out = rt(&info, v); - assert_eq!(out.len() % 2, 0); - assert_eq!(&out[..2], &(-2i16).to_be_bytes()); - } -} - -#[test] -fn psb_channel_lengths_are_8_bytes() { - let mut rec = LayerRecord::default(); - rec.channels.push(ChannelData::new(0, Some(Compression::Raw), vec![])); - let info = LayerInfo { layers: vec![rec], ..Default::default() }; - let psd = rt(&info, Version::Psd); - let psb = rt(&info, Version::Psb); - assert_eq!(psb.len(), psd.len() + 4); -} - -#[test] -fn channel_decode_via_record() { - let rec = sample_record(Version::Psd); - let px: Vec = (0..20).collect(); - for id in [-1, 0, 1, 2] { - assert_eq!(rec.decode_channel(id, 8, Version::Psd).unwrap(), px); - } - assert_eq!(rec.decode_channel(-2, 8, Version::Psd).unwrap(), vec![1, 2, 3, 4]); - assert!(rec.decode_channel(7, 8, Version::Psd).is_err()); -} - -#[test] -fn record_accessors() { - let rec = sample_record(Version::Psd); - assert_eq!(rec.name(), "\u{e4}bc"); - assert!(!rec.is_visible()); - assert_eq!(rec.layer_id(), Some(5)); - assert_eq!(rec.fill_opacity(), 255); - assert_eq!(rec.section_type(), SectionType::Other); - assert!(rec.layer_mask().is_some()); - assert_eq!(rec.channel_rect(-2), Rect::from_xywh(0, 0, 2, 2)); - assert_eq!(rec.channel_rect(0), rec.rect); - let mut plain = LayerRecord { name: b"legacy".to_vec(), ..Default::default() }; - assert_eq!(plain.name(), "legacy"); - plain.blocks.push(TaggedBlock::fill_opacity(10)); - assert_eq!(plain.fill_opacity(), 10); - plain.block_mut(b"iOpa").unwrap().data[0] = 11; - assert_eq!(plain.fill_opacity(), 11); -} - -#[test] -fn mask_variants_roundtrip() { - let base = LayerMask::new(Rect::from_xywh(1, 2, 3, 4), 0, 0); - let with_real = LayerMask { real: Some(RealMask { flags: 1, background: 255, rect: Rect::from_xywh(0, 0, 9, 9) }), trailing: vec![], ..base.clone() }; - let with_params = LayerMask { - flags: LayerMask::FLAG_PARAMETERS, - parameters: Some(MaskParameters { - flags: 0b1111, - user_density: Some(128), - user_feather: Some(2.5), - vector_density: Some(64), - vector_feather: Some(0.5), - }), - real: Some(RealMask::default()), - trailing: vec![0, 0], - ..base.clone() - }; - let partial_params = LayerMask { - flags: LayerMask::FLAG_PARAMETERS, - parameters: Some(MaskParameters { flags: 0b0010, user_feather: Some(1.0), ..Default::default() }), - trailing: vec![], - ..base.clone() - }; - for m in [base, with_real, with_params, partial_params] { - let mut out = Vec::new(); - m.write(&mut out); - assert_eq!(LayerMask::parse(&out).unwrap(), m); - } -} - -/// Photoshop stores the real-mask fields before the mask parameters (psd-tools -/// mask-density-layervectormask.psd): user + vector mask with both densities set. -#[test] -fn real_mask_before_parameters_photoshop_layout() { - let mut d = Vec::new(); - Rect::from_xywh(15, -1, 18, 10).write(&mut d); - d.extend([0, LayerMask::FLAG_PARAMETERS | 8]); - d.extend([0, 255]); - Rect::from_xywh(0, 0, 32, 8).write(&mut d); - d.extend([0b0101, 64, 64, 0]); - let m = LayerMask::parse(&d).unwrap(); - assert_eq!(m.real.map(|r| r.rect), Some(Rect::from_xywh(0, 0, 32, 8))); - assert_eq!(m.real.map(|r| r.background), Some(255)); - let p = m.parameters.unwrap(); - assert_eq!((p.user_density, p.vector_density), (Some(64), Some(64))); - assert_eq!(m.trailing, vec![0]); - assert!(m.real_first); - let mut out = Vec::new(); - m.write(&mut out); - assert_eq!(out, d); -} - -/// A density-only record in the spec's order (#949): the parameter flags byte at 18 says one -/// value byte, so the spec's reading excludes the 38-byte length only if the real mask is -/// ignored, while byte 36 (inside the real rectangle's `right`) is 0 and makes Photoshop's -/// reading fit too. Photoshop's reading puts the density (64) where a 0-or-255 background -/// belongs, so the spec's order wins and the density survives. -#[test] -fn density_only_spec_layout_is_not_read_as_photoshop_order() { - for (density, right) in [(64u8, 1u32), (200, 1), (64, 300), (1, 7)] { - let mut d = Vec::new(); - Rect::from_xywh(0, 0, 1, 1).write(&mut d); - d.extend([255, LayerMask::FLAG_PARAMETERS]); - d.extend([0b0001, density]); - d.extend([0, 0]); - Rect::from_xywh(0, 0, right, 1).write(&mut d); - let m = LayerMask::parse(&d).unwrap(); - assert!(!m.real_first, "density {density}, right {right}: {m:?}"); - assert_eq!(m.parameters.unwrap().user_density, Some(density)); - assert_eq!(m.real, Some(RealMask { flags: 0, background: 0, rect: Rect::from_xywh(0, 0, right, 1) })); - assert!(m.trailing.is_empty()); - let mut out = Vec::new(); - m.write(&mut out); - assert_eq!(out, d); - } - // Photoshop's order with the same lengths still reads as Photoshop's order. - let mut d = Vec::new(); - Rect::from_xywh(0, 0, 1, 1).write(&mut d); - d.extend([255, LayerMask::FLAG_PARAMETERS]); - d.extend([0, 255]); - Rect::from_xywh(0, 0, 1, 1).write(&mut d); - d.extend([0b0001, 64]); - let m = LayerMask::parse(&d).unwrap(); - assert!(m.real_first, "{m:?}"); - assert_eq!(m.parameters.unwrap().user_density, Some(64)); - assert_eq!(m.real.map(|r| r.background), Some(255)); - let mut out = Vec::new(); - m.write(&mut out); - assert_eq!(out, d); -} - -/// The spec's order (parameters, then real fields) still parses and stays byte-stable. -#[test] -fn real_mask_after_parameters_spec_layout() { - let mut d = Vec::new(); - Rect::from_xywh(1, 2, 3, 4).write(&mut d); - d.extend([255, LayerMask::FLAG_PARAMETERS]); - d.extend([0b0011, 200]); - d.extend(1.5f64.to_be_bytes()); - d.extend([0, 255]); - Rect::from_xywh(0, 0, 3, 3).write(&mut d); - let m = LayerMask::parse(&d).unwrap(); - assert!(!m.real_first); - assert_eq!(m.real.map(|r| r.rect), Some(Rect::from_xywh(0, 0, 3, 3))); - assert_eq!(m.parameters.unwrap().user_feather, Some(1.5)); - let mut out = Vec::new(); - m.write(&mut out); - assert_eq!(out, d); - // Parameters alone (no real mask) keep reading in the spec's order. - let mut d = Vec::new(); - Rect::from_xywh(1, 2, 3, 4).write(&mut d); - d.extend([255, LayerMask::FLAG_PARAMETERS, 0b1010]); - d.extend(1.0f64.to_be_bytes()); - d.extend(2.0f64.to_be_bytes()); - d.extend([0, 0]); - let m = LayerMask::parse(&d).unwrap(); - assert_eq!(m.real, None); - assert_eq!(m.parameters.map(|p| (p.user_feather, p.vector_feather)), Some((Some(1.0), Some(2.0)))); -} - -#[test] -fn mask_sizes() { - let mut out = Vec::new(); - LayerMask::new(Rect::default(), 0, 0).write(&mut out); - assert_eq!(out.len(), 20); - let m = LayerMask { real: Some(RealMask::default()), trailing: vec![], ..LayerMask::new(Rect::default(), 0, 0) }; - let mut out = Vec::new(); - m.write(&mut out); - assert_eq!(out.len(), 36); -} - -#[test] -fn short_mask_kept_raw() { - let mut rec = LayerRecord { mask: MaskData::Raw(vec![1, 2, 3]), ..Default::default() }; - rec.channels.clear(); - let info = LayerInfo { layers: vec![rec], ..Default::default() }; - rt(&info, Version::Psd); -} - -#[test] -fn flags_helpers() { - let mut f = LayerFlags::default(); - f.set_hidden(true); - f.set_transparency_protected(true); - assert!(f.hidden() && f.transparency_protected()); - f.set_pixel_data_irrelevant(true); - assert!(f.bit4_useful() && f.pixel_data_irrelevant()); - f.set_hidden(false); - assert!(!f.hidden()); -} - -#[test] -fn blending_ranges_parse() { - let br = BlendingRanges::full(3); - assert_eq!(br.data.len(), 32); - let r = br.ranges(); - assert_eq!(r.len(), 4); - assert_eq!(r[0].source, [0, 0, 255, 255]); -} - -#[test] -fn rect_helpers() { - let r = Rect::from_xywh(-5, -6, 10, 3); - assert_eq!((r.width(), r.height()), (10, 3)); - assert_eq!(r.size().unwrap(), (10, 3)); - assert!(!r.is_empty()); - let bad = Rect { top: 5, left: 0, bottom: 0, right: 1 }; - assert!(bad.size().is_err()); - assert!(bad.is_empty()); - let huge = Rect { top: 0, left: i32::MIN, bottom: 1, right: i32::MAX }; - assert!(huge.size().is_err()); -} - -#[test] -fn truncation_errors() { - let info = LayerInfo { layers: vec![sample_record(Version::Psd)], ..Default::default() }; - let mut out = Vec::new(); - info.write_body(&mut out, Version::Psd).unwrap(); - // The final byte may be the (optional) even padding; stop before it. - for cut in 0..out.len() - 1 { - assert!(LayerInfo::read_body(&out[..cut], Version::Psd).is_err(), "cut {cut}"); - } -} - -#[test] -fn bad_blend_signature() { - let info = LayerInfo { layers: vec![LayerRecord::default()], ..Default::default() }; - let mut out = Vec::new(); - info.write_body(&mut out, Version::Psd).unwrap(); - // count(2) + rect(16) + nch(2) = 20 → signature - out[20] = b'X'; - assert!(matches!(LayerInfo::read_body(&out, Version::Psd), Err(PsdError::InvalidSignature { .. }))); -} - -#[test] -fn too_many_layers_rejected() { - let out = 1000i16.to_be_bytes(); - assert!(LayerInfo::read_body(&out, Version::Psd).is_err()); -} - -#[test] -fn zero_length_channel() { - let mut rec = LayerRecord::default(); - rec.channels.push(ChannelData::new(0, None, vec![])); - let info = LayerInfo { layers: vec![rec], ..Default::default() }; - rt(&info, Version::Psd); - assert_eq!(info.layers[0].decode_channel(0, 8, Version::Psd).unwrap(), Vec::::new()); -} - -/// The computed length matches the bytes written for every mask form, long names, custom block -/// padding, long-length blocks and trailing bytes. -#[test] -fn unpadded_len_matches_the_written_body() { - for version in [Version::Psd, Version::Psb] { - let mut layers = vec![sample_record(version), LayerRecord::default()]; - for (params, real, real_first) in [(true, true, true), (true, true, false), (true, false, true), (false, true, true)] { - let mut mask = LayerMask::new(Rect::from_xywh(1, 2, 3, 4), 0, LayerMask::FLAG_PARAMETERS); - mask.parameters = - params.then_some(MaskParameters { flags: 0x0f, user_density: Some(1), user_feather: Some(2.5), vector_density: Some(3), vector_feather: None }); - mask.real = real.then_some(RealMask { flags: 0, background: 255, rect: Rect::from_xywh(0, 0, 1, 1) }); - mask.real_first = real_first; - mask.trailing = vec![0; 3]; - let mut rec = sample_record(version); - rec.mask = MaskData::Mask(mask); - rec.name = vec![b'n'; 300]; - rec.blocks.push(TaggedBlock { signature: *b"8BIM", key: *b"lsct", data: vec![1; 5], padding: Some(vec![0; 3]) }); - rec.blocks.push(TaggedBlock { signature: *b"8BIM", key: *b"Lr16", data: vec![2; 7], padding: None }); - rec.extra_trailing = vec![9; 2]; - layers.push(rec); - } - layers.push(LayerRecord { mask: MaskData::Raw(vec![5; 11]), name: b"ab".to_vec(), ..Default::default() }); - let info = LayerInfo { layers, ..Default::default() }; - let mut out = Vec::new(); - info.write_unpadded(&mut out, version).unwrap(); - assert_eq!(info.unpadded_len(version).unwrap(), out.len() as u64, "{version:?}"); - } -} - -/// Padding without cloning the layers writes the same bytes as `pad_to` on a clone, and the -/// channel data length hint is exact. -#[test] -fn unpadded_write_matches_pad_to_on_a_clone() { - use crate::{ColorMode, Compression, testgen}; - for version in [Version::Psd, Version::Psb] { - for depth in [8u16, 16, 32] { - for compression in [Compression::Raw, Compression::Rle] { - let file = testgen::layered(version, ColorMode::Rgb, depth, compression); - let mut info = file.layer_info.clone().unwrap(); - for align in [1u64, 2, 4, 8] { - let mut cloned = info.clone(); - cloned.pad_to(version, align).unwrap(); - let mut want = Vec::new(); - cloned.write_body(&mut want, version).unwrap(); - let pad = info.pad_len(version, align).unwrap(); - let mut got = Vec::new(); - info.write_unpadded(&mut got, version).unwrap(); - assert!(got.len() >= info.channel_data_len()); - got.resize(got.len() + pad, 0); - assert_eq!(got, want, "{version:?} {depth} {compression:?} align {align}"); - } - info.padding = Some(vec![7, 7]); - let mut with_padding = Vec::new(); - info.write_body(&mut with_padding, version).unwrap(); - assert!(with_padding.ends_with(&[7, 7])); - } - } - } -} - -#[test] -fn nondefault_padding_kept() { - let info = LayerInfo { padding: Some(vec![0, 0]), ..Default::default() }; - let out = rt(&info, Version::Psd); - assert_eq!(out.len(), 4); -} - -#[test] -fn unknown_blend_and_blocks_passthrough() { - let rec = LayerRecord { blend_mode: BlendMode::Unknown(*b"wxyz"), blocks: vec![TaggedBlock::new(*b"Zzzz", vec![1, 2, 3, 4, 5])], ..Default::default() }; - rt(&LayerInfo { layers: vec![rec], ..Default::default() }, Version::Psd); -} diff --git a/crates/psd/src/lazy.rs b/crates/psd/src/lazy.rs deleted file mode 100644 index f78fa90068..0000000000 --- a/crates/psd/src/lazy.rs +++ /dev/null @@ -1,448 +0,0 @@ -//! Parsing from a [`Source`] without reading the whole file: [`PsdFile::from_source`]. -//! -//! The structure (header, resources, layer records, global blocks) is read into memory as -//! [`PsdFile::from_bytes`] would hold it. The bulk of a layered file is left in the source: every -//! channel's encoded bytes and the merged image become [`Stored`] ranges, read when they are -//! decoded. Apart from those ranges, the model is the one `from_bytes` builds from the same bytes -//! (the parse tests hold the two to that), with one difference: a merged image the caller does -//! not ask for is not read, so it is not checked either. - -use crate::compression::Compression; -use crate::error::{PsdError, Result}; -use crate::file::{GlobalLayerMask, LayerInfoPlacement, PsdFile}; -use crate::header::{Header, Version}; -use crate::image_data::ImageData; -use crate::io::Reader; -use crate::layer::{LayerInfo, LayerRecord}; -use crate::resources::ImageResource; -use crate::source::{Source, Stored}; -use crate::tagged::{TaggedBlock, is_sig, uses_long_length}; - -/// Bytes read per attempt at the layer records; doubled until they all fit. (Small in tests, so -/// they grow it.) -const RECORDS_CHUNK: u64 = if cfg!(test) { 64 } else { 1 << 20 }; - -/// A bounds-checked cursor over `start..end` of a source. -struct Cursor<'a> { - src: &'a Source, - pos: u64, - end: u64, -} - -impl<'a> Cursor<'a> { - fn new(src: &'a Source) -> Self { - Cursor { src, pos: 0, end: src.get().len() } - } - - fn remaining(&self) -> u64 { - self.end.saturating_sub(self.pos) - } - - fn is_empty(&self) -> bool { - self.remaining() == 0 - } - - fn eof(&self, needed: u64) -> PsdError { - PsdError::UnexpectedEof { - offset: usize::try_from(self.pos).unwrap_or(usize::MAX), - needed: usize::try_from(needed.saturating_sub(self.remaining())).unwrap_or(usize::MAX), - } - } - - fn need(&self, n: u64) -> Result<()> { - if n > self.remaining() { Err(self.eof(n)) } else { Ok(()) } - } - - /// Reads `n` bytes. - fn bytes(&mut self, n: u64) -> Result> { - self.need(n)?; - let v = self.src.read_vec(self.pos, n)?; - self.pos += n; - Ok(v) - } - - /// Up to `n` bytes from the current position, without consuming. - fn peek(&self, n: u64) -> Result> { - self.src.read_vec(self.pos, n.min(self.remaining())) - } - - fn array(&mut self) -> Result<[u8; N]> { - self.need(N as u64)?; - let mut b = [0u8; N]; - self.src.get().read_at(self.pos, &mut b)?; - self.pos += N as u64; - Ok(b) - } - - fn u16(&mut self) -> Result { - self.array().map(u16::from_be_bytes) - } - - fn i16(&mut self) -> Result { - self.array().map(i16::from_be_bytes) - } - - fn u32(&mut self) -> Result { - self.array().map(u32::from_be_bytes) - } - - fn len_field(&mut self, long: bool) -> Result { - if long { self.array().map(u64::from_be_bytes) } else { self.u32().map(u64::from) } - } - - /// The next `n` bytes as a cursor of their own; this one moves past them. - fn sub(&mut self, n: u64) -> Result> { - self.need(n)?; - let sub = Cursor { src: self.src, pos: self.pos, end: self.pos + n }; - self.pos += n; - Ok(sub) - } - - fn skip(&mut self, n: u64) -> Result<()> { - self.need(n)?; - self.pos += n; - Ok(()) - } - - fn rest(&mut self) -> Result> { - self.bytes(self.remaining()) - } - - /// The rest as a stored range; the cursor moves to the end. - fn stored_rest(&mut self) -> Stored { - let s = Stored { source: self.src.clone(), offset: self.pos, len: self.remaining() }; - self.pos = self.end; - s - } -} - -impl PsdFile { - /// Parses a file from `source`, leaving channel data and the merged image in it: see - /// [`crate::source`]. Decoding a channel then reads its bytes from the source, which must - /// stay unchanged while the model is in use. - pub fn from_source(source: Source) -> Result { - Self::from_source_and_merged_recovering(source, |_| false, |_| false).map(|(file, _)| file) - } - - /// [`Self::from_bytes_and_merged_recovering`] for a [`Source`]: the merged image is read only - /// when `want` asks for it (and is then decoded as there, with the same recovery); otherwise - /// it stays in the source, unread and unchecked. - pub fn from_source_and_merged_recovering( - source: Source, - want: impl FnOnce(&PsdFile) -> bool, - recover: impl FnOnce(&PsdFile) -> bool, - ) -> Result<(Self, Option>>)> { - Self::from_source_and_merged_preflight(source, |file| Ok(want(file)), recover) - } - - /// Parses with a fallible pixel preflight after layer records are available and before - /// the merged image is read, validated or decoded. A preflight error is always fatal; - /// `recover` only handles damage encountered after an accepted preflight. - pub fn from_source_and_merged_preflight( - source: Source, - want: impl FnOnce(&PsdFile) -> Result, - recover: impl FnOnce(&PsdFile) -> bool, - ) -> Result<(Self, Option>>)> { - let mut c = Cursor::new(&source); - let header = Header::read(&mut Reader::new(&c.bytes(26)?))?; - let version = header.version; - let psb = version.is_psb(); - let cmd_len = c.u32()?; - let color_mode_data = c.bytes(u64::from(cmd_len))?; - let res_len = c.u32()?; - let res = c.bytes(u64::from(res_len))?; - let mut rr = Reader::new(&res); - let mut resources = Vec::new(); - while !rr.is_empty() { - resources.push(ImageResource::read(&mut rr)?); - } - - let lm_len = c.len_field(psb)?; - let mut s = c.sub(lm_len)?; - let mut layer_info = None; - let mut global_layer_mask = None; - let mut global_blocks = Vec::new(); - let mut layer_mask_trailing = Vec::new(); - let mut layer_info_placement = LayerInfoPlacement::Section; - if !s.is_empty() { - let li_len = s.len_field(psb)?; - if li_len > 0 { - layer_info = Some(read_layer_info(s.sub(li_len)?, version)?); - } - if s.remaining() >= 4 { - let n = s.u32()?; - global_layer_mask = Some(GlobalLayerMask { data: s.bytes(u64::from(n))? }); - let target_key = match header.depth { - 16 => Some(b"Lr16"), - 32 => Some(b"Lr32"), - _ => None, - }; - let mut candidate_seen = match target_key { - Some(_) => false, - None => layer_info.as_ref().is_some_and(|i: &LayerInfo| !i.layers.is_empty()), - }; - let trailing = read_blocks( - &mut s, - version, - |index, signature, key, data, padding| { - let is_candidate = match target_key { - Some(k) => &key == k, - None => matches!(&key, b"Layr" | b"Lr16" | b"Lr32"), - }; - if candidate_seen || !is_candidate { - return Ok(None); - } - candidate_seen = true; - let info = match read_layer_info(data, version) { - Ok(info) => info, - // A failed read of the source is not damage: fail the parse, as - // reading the whole file would. - Err(e @ PsdError::Read(_)) => return Err(e), - Err(_) => { - if target_key.is_some() { - layer_info = None; - } - return Ok(None); - } - }; - layer_info = Some(info); - layer_info_placement = LayerInfoPlacement::GlobalBlock { index, signature, key, padding: padding.clone() }; - Ok(Some(())) - }, - &mut global_blocks, - )?; - layer_mask_trailing = trailing; - } else { - layer_mask_trailing = s.rest()?; - } - } - let mut file = PsdFile { - header, - color_mode_data, - resources, - layer_info, - layer_info_placement, - global_layer_mask, - global_blocks, - layer_mask_trailing, - image_data: ImageData::new(Compression::Raw, Vec::new()), - write_as_psb: false, - }; - if !want(&file)? { - let compression = Compression::from_u16(c.u16()?); - file.image_data = ImageData { compression, data: Vec::new(), stored: Some(c.stored_rest()) }; - return Ok((file, None)); - } - // The merged image is wanted: read it and decode it as `from_bytes` does. - let rest = c.rest()?; - let merged = file.read_merged_recovering(&rest, recover)?; - Ok((file, merged)) - } -} - -/// Reads tagged blocks as [`crate::tagged::read_blocks_with`] does, from a cursor: `consume` -/// sees each block's data as a cursor and returns `Some` when it took the block; the others are -/// read into memory and pushed to `blocks`. Returns the trailing bytes. -fn read_blocks<'a>( - r: &mut Cursor<'a>, - version: Version, - mut consume: impl FnMut(usize, [u8; 4], [u8; 4], Cursor<'a>, &Option>) -> Result>, - blocks: &mut Vec, -) -> Result> { - const ALIGNMENT: usize = 4; - loop { - let head = r.peek(12)?; - if head.len() < 12 || !is_sig(&head) { - return r.rest(); - } - let signature = r.array::<4>()?; - let key = r.array::<4>()?; - let len = r.len_field(uses_long_length(version, &key))?; - let data = r.sub(len)?; - // Padding as `read_blocks_with` detects it: the smallest k in 0..=3 such that the next - // signature (or the end of the region) follows k bytes later. - let rest = r.peek(7)?; - let mut pad = None; - for k in 0..=3usize { - if k == rest.len() || rest.get(k..).is_some_and(is_sig) { - pad = Some(k); - break; - } - } - let default = TaggedBlock::default_padding((len % ALIGNMENT as u64) as usize, ALIGNMENT); - let pad = pad.unwrap_or_else(|| if rest.get(..default).is_some_and(|p| p.iter().all(|&b| b == 0)) { default } else { 0 }); - let pad_bytes = rest.get(..pad).map(<[u8]>::to_vec).unwrap_or_default(); - r.skip(pad as u64)?; - let padding = if pad_bytes.len() == default && pad_bytes.iter().all(|&b| b == 0) { None } else { Some(pad_bytes) }; - let (start, end) = (data.pos, data.end); - if consume(blocks.len(), signature, key, data, &padding)?.is_none() { - let data = Cursor { src: r.src, pos: start, end }.rest()?; - blocks.push(TaggedBlock { signature, key, data, padding }); - } - } -} - -/// [`LayerInfo::read_body`] from a cursor over the body: the records are read into memory, and -/// each channel's encoded bytes become a [`Stored`] range after its compression field. -fn read_layer_info(mut r: Cursor<'_>, version: Version) -> Result { - let body_start = r.pos; - let count = r.i16()?; - let merged_alpha = count < 0; - let n = count.unsigned_abs(); - // Minimum record size, as `read_body` checks it. - if u64::from(n) * 34 > r.remaining() { - return Err(r.eof(u64::from(n) * 34)); - } - // The records' total size is only known once they are parsed: read a chunk and grow it until - // they all fit (or the body is exhausted, when their error is the body's). - let mut chunk = RECORDS_CHUNK.min(r.remaining()); - let (layers, all_lens, used) = loop { - let buf = r.peek(chunk)?; - let mut rr = Reader::new(&buf); - let mut layers = Vec::with_capacity(usize::from(n)); - let mut all_lens = Vec::with_capacity(usize::from(n)); - let parsed = (0..n).try_for_each(|_| { - let (rec, lens) = LayerRecord::read(&mut rr, version)?; - layers.push(rec); - all_lens.push(lens); - Ok::<_, PsdError>(()) - }); - match parsed { - Ok(()) => break (layers, all_lens, rr.pos() as u64), - Err(PsdError::UnexpectedEof { .. }) if chunk < r.remaining() => chunk = chunk.saturating_mul(2).min(r.remaining()), - Err(e) => return Err(e), - } - }; - r.skip(used)?; - let mut layers = layers; - for (rec, lens) in layers.iter_mut().zip(all_lens) { - for (ch, len) in rec.channels.iter_mut().zip(lens) { - if len == 0 { - continue; - } - if len == 1 { - return Err(PsdError::invalid("channel data length 1")); - } - ch.compression = Some(Compression::from_u16(r.u16()?)); - let data = r.sub(len - 2)?; - ch.stored = Some(Stored { source: data.src.clone(), offset: data.pos, len: data.remaining() }); - } - } - let body_len = r.pos - body_start; - let rest = r.rest()?; - let padding = if rest.len() as u64 == body_len % 2 && rest.iter().all(|&b| b == 0) { None } else { Some(rest) }; - Ok(LayerInfo { merged_alpha, layers, padding }) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::testgen; - - /// Every generated file, plus the layered 16/32-bit ones with their layers in a global block. - fn samples() -> Vec> { - let mut v = Vec::new(); - for case in testgen::all_cases() { - let mut file = case.file; - v.push(file.to_bytes().unwrap()); - let key = match file.header.depth { - 16 => *b"Lr16", - 32 => *b"Lr32", - _ => continue, - }; - if file.layer_info.as_ref().is_some_and(|i| !i.layers.is_empty()) { - file.global_layer_mask.get_or_insert_with(GlobalLayerMask::default); - file.layer_info_placement = LayerInfoPlacement::GlobalBlock { index: 0, signature: *b"8BIM", key, padding: None }; - v.push(file.to_bytes().unwrap()); - } - } - v - } - - fn same(bytes: Vec) { - let eager = PsdFile::from_bytes(&bytes).unwrap(); - let lazy = PsdFile::from_source(Source::new(bytes.clone())).unwrap(); - assert_eq!(lazy, eager); - assert_eq!(lazy.to_bytes().unwrap(), bytes); - assert!(lazy.layers().iter().flat_map(|l| &l.channels).all(|c| c.data.is_empty())); - assert!(lazy.image_data.data.is_empty() && lazy.image_data.stored.is_some()); - } - - #[test] - fn lazy_parse_matches_the_eager_one() { - for bytes in samples() { - same(bytes); - } - } - - /// A PSD read lazily and written as PSB widens the RLE count tables of channels still in the - /// source, byte for byte as an eager read does (main's PSB promotion, #5285, on #6583's reader). - #[test] - fn a_lazy_psd_promotes_to_the_same_psb_as_an_eager_one() { - let bytes = testgen::layered(Version::Psd, crate::ColorMode::Rgb, 8, Compression::Rle).to_bytes().unwrap(); - let mut eager = PsdFile::from_bytes(&bytes).unwrap(); - let mut lazy = PsdFile::from_source(Source::new(bytes)).unwrap(); - eager.promote_to_psb().unwrap(); - lazy.promote_to_psb().unwrap(); - let written = eager.to_bytes().unwrap(); - assert_eq!(lazy.encoded_len().unwrap(), written.len() as u64); - assert!(lazy.to_bytes().unwrap() == written, "the lazily read PSD writes another PSB"); - } - - #[test] - fn lazy_parse_fails_where_the_eager_one_does() { - for bytes in samples() { - for cut in [1, 2, 7, 26, 40, bytes.len() / 3, bytes.len() / 2, bytes.len() - 3] { - let Some(b) = bytes.get(..cut) else { continue }; - let eager = PsdFile::from_bytes(b); - let lazy = PsdFile::from_source(Source::new(b.to_vec())); - // A truncated merged image is only noticed when it is read. - match eager { - Ok(eager) => assert_eq!(lazy.unwrap(), eager, "cut at {cut}"), - Err(e) => { - let records_ok = PsdFile::from_bytes_and_merged_recovering(b, |_| false, |_| true).is_ok(); - assert!(lazy.is_err() || records_ok, "cut at {cut}: eager {e:?}"); - } - } - } - } - } - - /// A source whose `fail_at`-th read fails once. - struct FailOnce { - bytes: Vec, - fail_at: usize, - reads: std::sync::atomic::AtomicUsize, - } - - impl crate::source::ReadAt for FailOnce { - fn len(&self) -> u64 { - self.bytes.len() as u64 - } - - fn read_at(&self, offset: u64, buf: &mut [u8]) -> Result<()> { - if self.reads.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == self.fail_at { - return Err(PsdError::Read("injected".into())); - } - self.bytes.read_at(offset, buf) - } - } - - #[test] - fn a_failed_read_fails_the_parse_instead_of_dropping_the_layers() { - for bytes in samples() { - let eager = PsdFile::from_bytes(&bytes).unwrap(); - let clean = std::sync::Arc::new(FailOnce { bytes: bytes.clone(), fail_at: usize::MAX, reads: 0.into() }); - PsdFile::from_source(Source::from_arc(clean.clone())).unwrap(); - let reads = clean.reads.load(std::sync::atomic::Ordering::SeqCst); - for fail_at in 0..reads { - let src = FailOnce { bytes: bytes.clone(), fail_at, reads: 0.into() }; - match PsdFile::from_source(Source::new(src)) { - Err(PsdError::Read(_)) => {} - Ok(lazy) => assert_eq!(lazy, eager, "read {fail_at} failed silently"), - Err(e) => panic!("read {fail_at}: {e:?}"), - } - } - } - } -} diff --git a/crates/psd/src/lib.rs b/crates/psd/src/lib.rs deleted file mode 100644 index 5d0422f13a..0000000000 --- a/crates/psd/src/lib.rs +++ /dev/null @@ -1,101 +0,0 @@ -//! # photocraft-psd -//! -//! Faithful, lossless reader and writer for Adobe Photoshop PSD (version 1) -//! and PSB (version 2, "large document format") files. -//! -//! * [`PsdFile::from_bytes`] parses a file into a format-level model; -//! [`PsdFile::to_bytes`] writes it back. Unmodified files round-trip byte -//! for byte: channel data is kept in its original encoding, unknown tagged -//! blocks and resources are kept raw, and padding is preserved. -//! * Channel data is decoded lazily ([`ChannelData::decode`], -//! [`ImageData::decode`]) with all four compression methods supported for -//! both decoding and encoding. -//! * [`PsdBuilder`] constructs new files from RGBA/gray/CMYK buffers; the -//! caller supplies the merged composite (this crate never composites). -//! * [`descriptor`] parses and writes ActionDescriptors. -//! -//! Implemented clean-room from Adobe's public "Photoshop File Formats -//! Specification". Undocumented details that follow MIT-licensed psd-tools -//! or ag-psd behavior are noted in comments. -//! -//! The parser never panics on malformed input and enforces limits -//! (dimensions ≤ 300000, bounded allocations, bounded descriptor nesting). -//! -//! ``` -//! use photocraft_psd::{LayerSpec, PixelData, PsdBuilder, PsdFile}; -//! -//! let mut b = PsdBuilder::new(2, 1); -//! b.push_layer(LayerSpec::new("Red", 0, 0, 2, 1, PixelData::Rgba8(vec![255, 0, 0, 255, 255, 0, 0, 128]))); -//! b.composite(PixelData::Rgba8(vec![255, 0, 0, 255, 255, 0, 0, 128])); -//! let bytes = b.to_bytes()?; -//! -//! let file = PsdFile::from_bytes(&bytes)?; -//! assert_eq!(file.to_bytes()?, bytes); // byte-stable -//! let layer = file.layer(0).unwrap(); -//! assert_eq!(layer.name(), "Red"); -//! assert_eq!(layer.rgba8()?.data[7], 128); -//! # Ok::<(), photocraft_psd::PsdError>(()) -//! ``` -#![forbid(unsafe_code)] -#![warn(missing_docs)] -#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] - -pub mod abr; -pub mod aco; -pub mod act; -pub mod animation; -pub mod ase; -pub mod asl; -pub mod atn; -pub mod blend; -pub mod builder; -mod codepage; -pub mod compression; -pub mod csh; -pub mod descriptor; -pub mod error; -pub mod file; -pub mod filter_effects; -#[cfg(any(test, feature = "testgen"))] -pub mod fixtures; -pub mod grd; -pub mod hdr; -pub mod header; -pub mod image_data; -mod io; -pub mod layer; -mod lazy; -pub mod metadata; -pub mod path; -pub mod patterns; -pub mod pixels; -pub mod resources; -pub mod rows; -pub mod shc; -pub mod slices; -pub mod source; -pub mod tagged; -#[cfg(any(test, feature = "testgen"))] -// Test-data generator (tests and fuzz seeds only): failing loudly is the point. -#[allow(clippy::expect_used)] -pub mod testgen; -pub mod tiff; -pub mod tree; - -pub use blend::BlendMode; -pub use builder::{GroupSpec, LayerSpec, MaskSpec, PixelData, PsdBuilder}; -pub use compression::{Compression, PlaneLayout}; -pub use descriptor::{Descriptor, VersionedDescriptor}; -pub use error::{PsdError, Result}; -pub use file::{GlobalLayerMask, LayerInfoPlacement, PsdFile}; -pub use header::{ColorMode, Header, Version}; -pub use image_data::ImageData; -pub use layer::{BlendingRanges, ChannelData, LayerFlags, LayerInfo, LayerMask, LayerRecord, MaskData, MaskParameters, RealMask, Rect}; -pub use pixels::{GrayImage, Layer, RgbaImage}; -pub use resources::{ImageResource, ResolutionInfo, ResourceData}; -pub use rows::RowDecoder; -#[cfg(any(unix, windows))] -pub use source::FileSource; -pub use source::{ReadAt, Source, Stored}; -pub use tagged::{BlockData, SectionDivider, SectionType, TaggedBlock}; -pub use tree::LayerNode; diff --git a/crates/psd/src/metadata.rs b/crates/psd/src/metadata.rs deleted file mode 100644 index 8e4565fe3f..0000000000 --- a/crates/psd/src/metadata.rs +++ /dev/null @@ -1,266 +0,0 @@ -//! `shmd` (metadata setting) layer block: a list of keyed sub-blocks, such as `cmls` (layer comp -//! state, a versioned descriptor), `cust` (the layer's modify time and XMP packet) and `mlst`. -//! -//! Layout (Adobe PSD spec, "Metadata setting"): u32 count, then per item the signature `8BIM`, -//! a 4-byte key, a copy-on-sheet-duplication byte, 3 padding bytes, a u32 length and the data. -//! Photoshop pads item data to an even length (the padding is part of the stored length), so -//! parsing and writing are exact: `write(parse(b)) == b`. - -use crate::descriptor::{Descriptor, Id, UnicodeString, Value, VersionedDescriptor}; -use crate::error::{PsdError, Result}; -use crate::io::{Reader, WriteExt}; - -/// One `shmd` item. -#[derive(Debug, Clone, PartialEq, Eq)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct MetadataItem { - /// Usually `8BIM`. - pub signature: [u8; 4], - /// Item key, e.g. `cmls`. - pub key: [u8; 4], - /// Copy on sheet duplication. - pub copy: bool, - /// The 3 bytes after the copy flag (zero in Photoshop files), kept for exactness. - pub reserved: [u8; 3], - /// Data including any padding counted in the stored length. - pub data: Vec, -} - -impl MetadataItem { - /// Typed animation descriptor for `mlst` (sparse layer frame states) or `tmln` - /// (layer timeline tracks). Other metadata, including `cust` / XMP, is not interpreted. - /// The original item stays unchanged; inheritance and track evaluation belong to the importer. - pub fn animation_descriptor(&self) -> Option> { - matches!(&self.key, b"mlst" | b"tmln").then(|| { - if self.signature != *b"8BIM" { - return Err(PsdError::InvalidSignature { expected: "8BIM animation metadata", found: self.signature }); - } - crate::animation::parse_descriptor(&self.data) - }) - } - - /// A new item; `data` is zero-padded to an even length as Photoshop writes it. - pub fn new(key: [u8; 4], mut data: Vec) -> Self { - if data.len() % 2 == 1 { - data.push(0); - } - MetadataItem { signature: *b"8BIM", key, copy: false, reserved: [0; 3], data } - } -} - -/// Parses `shmd` block data. -pub fn parse_shmd(data: &[u8]) -> Result> { - let mut r = Reader::new(data); - let count = r.u32()?; - r.check_count(u64::from(count), 16)?; - let mut out = crate::descriptor::vec_for_count(count); - for _ in 0..count { - let signature = r.array()?; - let key = r.array()?; - let copy = r.u8()? != 0; - let reserved = r.array()?; - let len = r.u32()?; - let data = r.bytes_u64(u64::from(len))?.to_vec(); - out.push(MetadataItem { signature, key, copy, reserved, data }); - } - if !r.is_empty() { - return Err(PsdError::invalid("trailing bytes after metadata items")); - } - Ok(out) -} - -/// Serializes `shmd` block data. -pub fn write_shmd(items: &[MetadataItem]) -> Vec { - let mut out = Vec::new(); - out.put_u32(items.len() as u32); - for it in items { - out.put(&it.signature); - out.put(&it.key); - out.push(u8::from(it.copy)); - out.put(&it.reserved); - out.put_u32(it.data.len() as u32); - out.put(&it.data); - } - out -} - -/// The `shmd` item that holds a layer's own metadata (its modify time and XMP). -const CUST: [u8; 4] = *b"cust"; -/// Key of a layer's XMP packet in the `cust` descriptor. -const LAYER_XMP: &str = "layerXMP"; - -// A layer's XMP packet (Photoshop's `layer.xmpMetadata.rawData`), as observed in files Photoshop -// 2026 saves: a `TEXT` item `layerXMP` (UTF-16 with a terminating NUL) in the `metadata` -// descriptor (version 16, class name a lone NUL) of the `shmd` item `cust`, after the -// `layerTime` double that every Photoshop layer has. A layer without XMP has no `layerXMP` item. - -fn pad_even(mut v: Vec) -> Vec { - if v.len() % 2 == 1 { - v.push(0); - } - v -} - -/// The `cust` descriptor of `item`, or `None` when it isn't one. -fn cust_descriptor(item: &MetadataItem) -> Option { - (item.key == CUST).then(|| VersionedDescriptor::parse_prefix(&item.data).ok().map(|(d, _)| d)).flatten() -} - -/// The XMP packet stored on a layer whose `shmd` block data is `shmd`, or `None` when it has -/// none (or the block can't be read). -pub fn layer_xmp(shmd: &[u8]) -> Option { - let items = parse_shmd(shmd).ok()?; - items.iter().filter_map(cust_descriptor).find_map(|d| match d.descriptor.get(LAYER_XMP) { - Some(Value::Text(t)) => Some(t.to_string_lossy()), - _ => None, - }) -} - -/// Splits a layer's XMP packet out of its `shmd` block data: the packet, and the block without -/// it (`None` when nothing is left). `None` when the block holds no packet, so the caller keeps -/// the original bytes. A `cust` item left empty is dropped, the other items are kept as stored. -pub fn take_layer_xmp(shmd: &[u8]) -> Option<(String, Option>)> { - let mut items = parse_shmd(shmd).ok()?; - let (i, mut vd) = items.iter().enumerate().find_map(|(i, it)| cust_descriptor(it).filter(|d| d.descriptor.get(LAYER_XMP).is_some()).map(|d| (i, d)))?; - let at = vd.descriptor.items.iter().position(|(k, _)| k.is(LAYER_XMP))?; - let xmp = match vd.descriptor.items.remove(at).1 { - Value::Text(t) => t.to_string_lossy(), - _ => return None, - }; - if vd.descriptor.items.is_empty() { - items.remove(i); - } else { - items[i].data = pad_even(vd.to_bytes()); - } - Some((xmp, (!items.is_empty()).then(|| write_shmd(&items)))) -} - -/// `shmd` block data (`shmd`, or a new block when `None`) with `xmp` as the layer's XMP packet, -/// written as Photoshop writes it: it replaces an existing packet in place, or is appended to -/// the `cust` descriptor (a new `cust` item when there is none). An unreadable block is an -/// error, so the caller never replaces data it couldn't read. -pub fn put_layer_xmp(shmd: Option<&[u8]>, xmp: &str) -> Result> { - let mut items = match shmd { - Some(b) => parse_shmd(b)?, - None => Vec::new(), - }; - let value = Value::Text(UnicodeString::new_nul(xmp)); - match items.iter().position(|it| it.key == CUST) { - Some(i) => { - let (mut vd, _) = VersionedDescriptor::parse_prefix(&items[i].data)?; - match vd.descriptor.items.iter_mut().find(|(k, _)| k.is(LAYER_XMP)) { - Some(entry) => entry.1 = value, - None => vd.descriptor.items.push((Id::new(LAYER_XMP), value)), - } - items[i].data = pad_even(vd.to_bytes()); - } - None => { - let d = Descriptor { name: UnicodeString::new_nul(""), class_id: Id::new("metadata"), items: vec![(Id::new(LAYER_XMP), value)] }; - items.push(MetadataItem::new(CUST, VersionedDescriptor::new(d).to_bytes())); - } - } - Ok(write_shmd(&items)) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn unhex(lines: &[&str]) -> Vec { - let h: String = lines.concat(); - (0..h.len()).step_by(2).map(|i| u8::from_str_radix(&h[i..i + 2], 16).unwrap()).collect() - } - - /// `shmd` of a layer saved by Photoshop 2026 after a script set its XMP to [`PS_PACKET`]. - const PS_WITH_XMP: &[&str] = &[ - "000000013842494d63757374000000000000024800000010000000010000000000086d65746164617461000000020000", - "00096c6179657254696d65646f756241dab643ffd54363000000086c61796572584d505445585400000100003c007800", - "3a0078006d0070006d00650074006100200078006d006c006e0073003a0078003d002200610064006f00620065003a00", - "6e0073003a006d006500740061002f0022003e003c007200640066003a00520044004600200078006d006c006e007300", - "3a007200640066003d00220068007400740070003a002f002f007700770077002e00770033002e006f00720067002f00", - "31003900390039002f00300032002f00320032002d007200640066002d00730079006e007400610078002d006e007300", - "230022003e003c007200640066003a004400650073006300720069007000740069006f006e0020007200640066003a00", - "610062006f00750074003d0022002200200078006d006c006e0073003a00730061006d0070006c0065003d0022006800", - "7400740070003a002f002f006500780061006d0070006c0065002e0063006f006d002f00730061006d0070006c006500", - "2f0031002e0030002f0022003e003c00730061006d0070006c0065003a00760061006c00750065003e0061006c007000", - "680061002d003100320033003c002f00730061006d0070006c0065003a00760061006c00750065003e003c002f007200", - "640066003a004400650073006300720069007000740069006f006e003e003c002f007200640066003a00520044004600", - "3e003c002f0078003a0078006d0070006d006500740061003e000000", - ]; - /// `shmd` of a layer without XMP in the same file (its own `layerTime` only). - const PS_WITHOUT_XMP: &[&str] = - &["000000013842494d63757374000000000000003400000010000000010000000000086d6574616461746100000001000000096c6179657254696d65646f756241dab643ffd86bec00"]; - const PS_PACKET: &str = concat!( - r#""#, - r#"alpha-123"#, - r#""# - ); - - #[test] - fn reads_photoshop_layer_xmp() { - assert_eq!(layer_xmp(&unhex(PS_WITH_XMP)).as_deref(), Some(PS_PACKET)); - assert_eq!(layer_xmp(&unhex(PS_WITHOUT_XMP)), None); - assert!(take_layer_xmp(&unhex(PS_WITHOUT_XMP)).is_none()); - } - - #[test] - fn take_then_put_is_exact() { - let ps = unhex(PS_WITH_XMP); - let (xmp, rest) = take_layer_xmp(&ps).unwrap(); - assert_eq!(xmp, PS_PACKET); - let rest = rest.unwrap(); - // What's left is a layer without XMP, shaped as Photoshop writes one. - assert_eq!(rest.len(), unhex(PS_WITHOUT_XMP).len()); - assert_eq!(layer_xmp(&rest), None); - assert_eq!(put_layer_xmp(Some(&rest), &xmp).unwrap(), ps); - } - - #[test] - fn put_replaces_in_place_and_creates_a_block() { - let ps = unhex(PS_WITH_XMP); - let edited = put_layer_xmp(Some(&ps), "").unwrap(); - assert_eq!(layer_xmp(&edited).as_deref(), Some("")); - // `layerTime` is kept, still first. - let items = parse_shmd(&edited).unwrap(); - let d = cust_descriptor(&items[0]).unwrap().descriptor; - assert!(d.items[0].0.is("layerTime") && d.items[1].0.is(LAYER_XMP) && d.items.len() == 2); - - let fresh = put_layer_xmp(None, PS_PACKET).unwrap(); - assert_eq!(layer_xmp(&fresh).as_deref(), Some(PS_PACKET)); - assert_eq!(take_layer_xmp(&fresh), Some((PS_PACKET.to_string(), None))); - // Other items (here a layer comp state) stay as they were. - let comps = write_shmd(&[MetadataItem::new(*b"cmls", vec![1, 2])]); - let both = put_layer_xmp(Some(&comps), PS_PACKET).unwrap(); - assert_eq!(parse_shmd(&both).unwrap()[0], parse_shmd(&comps).unwrap()[0]); - assert_eq!(take_layer_xmp(&both).unwrap().1.unwrap(), comps); - } - - #[test] - fn put_refuses_an_unreadable_block() { - assert!(put_layer_xmp(Some(&[0, 0, 0, 9]), PS_PACKET).is_err()); - let bad_cust = write_shmd(&[MetadataItem::new(CUST, vec![0, 0, 0, 16, 1])]); - assert!(put_layer_xmp(Some(&bad_cust), PS_PACKET).is_err()); - assert_eq!(layer_xmp(&bad_cust), None); - assert!(take_layer_xmp(&bad_cust).is_none()); - } - - #[test] - fn roundtrip_exact() { - let items = vec![MetadataItem::new(*b"cmls", vec![1, 2, 3]), MetadataItem { copy: true, ..MetadataItem::new(*b"cust", vec![9; 4]) }]; - assert_eq!(items[0].data.len(), 4); - let bytes = write_shmd(&items); - assert_eq!(parse_shmd(&bytes).unwrap(), items); - assert_eq!(write_shmd(&parse_shmd(&bytes).unwrap()), bytes); - } - - #[test] - fn malformed_is_an_error() { - assert!(parse_shmd(&[]).is_err()); - assert!(parse_shmd(&[0, 0, 0, 9]).is_err()); - let mut b = write_shmd(&[MetadataItem::new(*b"cmls", vec![0; 2])]); - b.push(0); - assert!(parse_shmd(&b).is_err()); - b.truncate(b.len() - 3); - assert!(parse_shmd(&b).is_err()); - } -} diff --git a/crates/psd/src/path.rs b/crates/psd/src/path.rs deleted file mode 100644 index 79f055cb0b..0000000000 --- a/crates/psd/src/path.rs +++ /dev/null @@ -1,312 +0,0 @@ -//! Path records: saved paths (image resources 2000–2997), the work path -//! (resource 1025) and vector masks (`vmsk` / `vsms` layer blocks). -//! -//! Implemented from the public Adobe "Photoshop File Formats Specification", -//! section "Path resource format": a path is a list of 26-byte records, each a -//! 2-byte selector followed by 24 bytes of data. Knot coordinates are signed -//! 8.24 fixed-point fractions of the document height (vertical) and width -//! (horizontal), stored vertical first. -//! -//! Undocumented detail (as documented by the MIT-licensed psd-tools): bytes -//! 2–3 of a subpath length record's data (after the knot count) hold the -//! *path operation* of the subpath (`-1` or `1` combine/or, `2` subtract, -//! `3` intersect, `0` exclude/xor). Everything else is kept raw, so parsing -//! and writing a record list is byte-exact. - -use crate::error::{PsdError, Result}; - -/// Length of one path record in bytes (selector + data). -pub const RECORD_LEN: usize = 26; - -/// Record selectors (spec table "Path data record types"). -pub mod selector { - /// Closed subpath length record. - pub const CLOSED_LENGTH: u16 = 0; - /// Closed subpath Bézier knot, linked. - pub const CLOSED_KNOT_LINKED: u16 = 1; - /// Closed subpath Bézier knot, unlinked. - pub const CLOSED_KNOT_UNLINKED: u16 = 2; - /// Open subpath length record. - pub const OPEN_LENGTH: u16 = 3; - /// Open subpath Bézier knot, linked. - pub const OPEN_KNOT_LINKED: u16 = 4; - /// Open subpath Bézier knot, unlinked. - pub const OPEN_KNOT_UNLINKED: u16 = 5; - /// Path fill rule record. - pub const FILL_RULE: u16 = 6; - /// Clipboard record. - pub const CLIPBOARD: u16 = 7; - /// Initial fill rule record. - pub const INITIAL_FILL: u16 = 8; -} - -/// One 26-byte path record, kept raw. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct PathRecord { - /// Record type (see [`selector`]). - pub selector: u16, - /// The 24 data bytes. - pub data: [u8; 24], -} - -impl PathRecord { - fn u16_at(&self, at: usize) -> u16 { - u16::from_be_bytes([self.data[at], self.data[at + 1]]) - } - fn i32_at(&self, at: usize) -> i32 { - i32::from_be_bytes([self.data[at], self.data[at + 1], self.data[at + 2], self.data[at + 3]]) - } - /// Whether this is a (closed or open) subpath length record. - pub fn is_length(&self) -> bool { - matches!(self.selector, selector::CLOSED_LENGTH | selector::OPEN_LENGTH) - } - /// Whether this is a Bézier knot record. - pub fn is_knot(&self) -> bool { - matches!(self.selector, 1 | 2 | 4 | 5) - } -} - -/// A Bézier knot as fractions of the document size: `(x, y)` pairs. -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct PsdKnot { - /// Linked (smooth) knot: the control points move together. - pub linked: bool, - /// Control point preceding the anchor (`(x, y)` fractions). - pub pre: (f64, f64), - /// Anchor point. - pub anchor: (f64, f64), - /// Control point leaving the anchor. - pub post: (f64, f64), -} - -/// A subpath: its knots, closed flag and path operation. -#[derive(Clone, Debug, PartialEq)] -pub struct PsdSubpath { - /// Closed subpath. - pub closed: bool, - /// Path operation (bytes 2–3 of the length record data): `-1`/`1` - /// combine, `2` subtract, `3` intersect, `0` exclude. - pub operation: i16, - /// Knots in order. - pub knots: Vec, -} - -/// Fixed 8.24 → fraction. -pub fn fixed_to_f64(v: i32) -> f64 { - f64::from(v) / 16_777_216.0 -} - -/// Fraction → fixed 8.24 (rounded, saturating). -pub fn f64_to_fixed(v: f64) -> i32 { - (v * 16_777_216.0).round().clamp(f64::from(i32::MIN), f64::from(i32::MAX)) as i32 -} - -/// A parsed path record list. -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct PathData { - /// Records in file order. - pub records: Vec, -} - -impl PathData { - /// Parses path records. Trailing bytes shorter than a record are ignored. - pub fn from_bytes(data: &[u8]) -> Result { - if data.len() / RECORD_LEN > 4_000_000 { - return Err(PsdError::LimitExceeded("path records")); - } - let records = data - .as_chunks::() - .0 - .iter() - .map(|c| { - let mut d = [0u8; 24]; - d.copy_from_slice(&c[2..]); - PathRecord { selector: u16::from_be_bytes([c[0], c[1]]), data: d } - }) - .collect(); - Ok(PathData { records }) - } - - /// Serializes the records. - pub fn to_bytes(&self) -> Vec { - let mut out = Vec::with_capacity(self.records.len() * RECORD_LEN); - for r in &self.records { - out.extend_from_slice(&r.selector.to_be_bytes()); - out.extend_from_slice(&r.data); - } - out - } - - /// Initial fill rule: `true` when the path starts with all pixels filled - /// (inverted path). - pub fn initial_fill(&self) -> bool { - self.records.iter().find(|r| r.selector == selector::INITIAL_FILL).is_some_and(|r| r.u16_at(0) != 0) - } - - /// Subpaths with their knots. Knot records not preceded by a length - /// record, or beyond a length record's count, start/extend a subpath - /// leniently instead of failing. - pub fn subpaths(&self) -> Vec { - let mut out: Vec = Vec::new(); - let mut remaining = 0usize; - for r in &self.records { - if r.is_length() { - remaining = usize::from(r.u16_at(0)); - out.push(PsdSubpath { - closed: r.selector == selector::CLOSED_LENGTH, - operation: r.u16_at(2) as i16, - // No preallocation: the count is untrusted (u16) and PsdKnot is 56 bytes. - knots: Vec::new(), - }); - } else if r.is_knot() { - let closed = r.selector <= 2; - if remaining == 0 && out.last().is_none_or(|s| s.closed != closed) { - out.push(PsdSubpath { closed, operation: -1, knots: Vec::new() }); - } - remaining = remaining.saturating_sub(1); - let pt = |i: usize| (fixed_to_f64(r.i32_at(i * 8 + 4)), fixed_to_f64(r.i32_at(i * 8))); - if let Some(s) = out.last_mut() { - s.knots.push(PsdKnot { linked: matches!(r.selector, 1 | 4), pre: pt(0), anchor: pt(1), post: pt(2) }); - } - } - } - out - } - - /// Builds a record list: a fill rule record, an initial fill record, - /// then each subpath's length record and knots. - pub fn from_subpaths(subpaths: &[PsdSubpath], initial_fill: bool) -> Self { - let mut records = vec![PathRecord { selector: selector::FILL_RULE, data: [0; 24] }, PathRecord { selector: selector::INITIAL_FILL, data: [0; 24] }]; - records[1].data[..2].copy_from_slice(&u16::from(initial_fill).to_be_bytes()); - for s in subpaths { - let mut data = [0u8; 24]; - data[..2].copy_from_slice(&(s.knots.len().min(u16::MAX as usize) as u16).to_be_bytes()); - data[2..4].copy_from_slice(&s.operation.to_be_bytes()); - // Photoshop writes 1 here (psd-tools default); meaning unknown. - data[4..6].copy_from_slice(&1u16.to_be_bytes()); - records.push(PathRecord { selector: if s.closed { selector::CLOSED_LENGTH } else { selector::OPEN_LENGTH }, data }); - for k in s.knots.iter().take(u16::MAX as usize) { - let sel = match (s.closed, k.linked) { - (true, true) => selector::CLOSED_KNOT_LINKED, - (true, false) => selector::CLOSED_KNOT_UNLINKED, - (false, true) => selector::OPEN_KNOT_LINKED, - (false, false) => selector::OPEN_KNOT_UNLINKED, - }; - let mut d = [0u8; 24]; - for (i, p) in [k.pre, k.anchor, k.post].iter().enumerate() { - d[i * 8..i * 8 + 4].copy_from_slice(&f64_to_fixed(p.1).to_be_bytes()); - d[i * 8 + 4..i * 8 + 8].copy_from_slice(&f64_to_fixed(p.0).to_be_bytes()); - } - records.push(PathRecord { selector: sel, data: d }); - } - } - PathData { records } - } -} - -/// A vector mask block (`vmsk` / `vsms`): version, flags and path records. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct VectorMaskBlock { - /// Version (3). - pub version: u32, - /// Flags: bit 0 invert, bit 1 not linked, bit 2 disabled. - pub flags: u32, - /// The path. - pub path: PathData, -} - -impl VectorMaskBlock { - /// Invert flag. - pub const FLAG_INVERT: u32 = 1; - /// Not-linked flag. - pub const FLAG_NOT_LINKED: u32 = 2; - /// Disabled flag. - pub const FLAG_DISABLED: u32 = 4; - - /// Parses the block data. - pub fn from_bytes(data: &[u8]) -> Result { - if data.len() < 8 { - return Err(PsdError::UnexpectedEof { offset: 0, needed: 8 - data.len() }); - } - let version = u32::from_be_bytes([data[0], data[1], data[2], data[3]]); - let flags = u32::from_be_bytes([data[4], data[5], data[6], data[7]]); - Ok(VectorMaskBlock { version, flags, path: PathData::from_bytes(&data[8..])? }) - } - - /// Serializes the block data. - pub fn to_bytes(&self) -> Vec { - let mut out = Vec::with_capacity(8 + self.path.records.len() * RECORD_LEN); - out.extend_from_slice(&self.version.to_be_bytes()); - out.extend_from_slice(&self.flags.to_be_bytes()); - out.extend_from_slice(&self.path.to_bytes()); - out - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn sample() -> Vec { - vec![ - PsdSubpath { - closed: true, - operation: 1, - knots: vec![ - PsdKnot { linked: false, pre: (0.25, 0.25), anchor: (0.25, 0.25), post: (0.25, 0.25) }, - PsdKnot { linked: true, pre: (0.625, 0.125), anchor: (0.75, 0.25), post: (0.875, 0.375) }, - PsdKnot { linked: false, pre: (0.5, 0.75), anchor: (0.5, 0.75), post: (0.5, 0.75) }, - ], - }, - PsdSubpath { closed: false, operation: 2, knots: vec![PsdKnot { linked: false, pre: (-0.5, 1.5), anchor: (0.0, 1.0), post: (0.0625, 0.9375) }] }, - ] - } - - #[test] - fn subpaths_does_not_preallocate_from_untrusted_counts() { - let mut bytes = Vec::new(); - for _ in 0..100_000 { - bytes.extend_from_slice(&selector::CLOSED_LENGTH.to_be_bytes()); - bytes.extend_from_slice(&0xFFFFu16.to_be_bytes()); - bytes.extend_from_slice(&[0u8; 22]); - } - let subs = PathData::from_bytes(&bytes).unwrap().subpaths(); - assert_eq!(subs.len(), 100_000); - assert!(subs.iter().all(|s| s.knots.capacity() == 0)); - } - - #[test] - fn subpaths_roundtrip_and_bytes_are_stable() { - let p = PathData::from_subpaths(&sample(), true); - let bytes = p.to_bytes(); - assert_eq!(bytes.len(), (2 + 2 + 4) * RECORD_LEN); - let q = PathData::from_bytes(&bytes).unwrap(); - assert_eq!(q.to_bytes(), bytes); - assert_eq!(q.subpaths(), sample()); - assert!(q.initial_fill()); - } - - #[test] - fn vector_mask_block_roundtrip() { - let b = VectorMaskBlock { version: 3, flags: VectorMaskBlock::FLAG_INVERT, path: PathData::from_subpaths(&sample(), false) }; - let bytes = b.to_bytes(); - assert_eq!(VectorMaskBlock::from_bytes(&bytes).unwrap(), b); - assert!(VectorMaskBlock::from_bytes(&[0; 4]).is_err()); - } - - #[test] - fn fixed_point_conversion() { - assert_eq!(f64_to_fixed(1.0), 1 << 24); - assert_eq!(fixed_to_f64(-(1 << 23)), -0.5); - assert_eq!(f64_to_fixed(1e12), i32::MAX); - } - - #[test] - fn lenient_knots_without_length_record() { - let mut p = PathData::from_subpaths(&sample(), false); - p.records.retain(|r| !r.is_length()); - let s = p.subpaths(); - assert_eq!(s.len(), 2); - assert_eq!(s[0].knots.len(), 3); - } -} diff --git a/crates/psd/src/patterns.rs b/crates/psd/src/patterns.rs deleted file mode 100644 index fb604f0c02..0000000000 --- a/crates/psd/src/patterns.rs +++ /dev/null @@ -1,631 +0,0 @@ -//! Patterns: the global `Patt` / `Pat2` / `Pat3` tagged blocks (8 / 16 / 32-bit documents) and -//! standalone `.pat` files. -//! -//! Layout per the public Photoshop File Formats Specification ("Patterns" and "Virtual Memory -//! Array List"): -//! -//! ```text -//! pattern := version u32 (1), image mode u32, height i16, width i16, -//! name (Unicode string), unique id (Pascal string, unpadded), -//! [indexed: 256 × RGB palette], virtual memory array list -//! VMA list := version u32 (3), length u32, rect (top, left, bottom, right i32), -//! channel count u32, then count + 2 arrays (user mask, sheet mask) -//! array := written u32 (0 = absent), length u32 (0 = absent), depth u32, rect, -//! depth u16, compression u8 (0 raw, 1 PackBits RLE), data -//! ``` -//! -//! In the tagged blocks each pattern is prefixed by its u32 length and padded to 4 bytes; a -//! `.pat` file is `8BPT`, version u16 (1), count u32, then the patterns without length prefixes. -//! Photoshop writes the channel count as 24 and only marks the real channels as written; like -//! ag-psd (MIT) we take the first written arrays as the colour channels and the next one as -//! transparency. Photoshop stores a pattern's transparency in the last (sheet mask) array and -//! leaves the user mask unwritten (psd-tools multichannel-pattern-fill.psd, Photoshop CC 2019); a -//! file with transparency in the user mask array is refused by Photoshop (#8054). - -use crate::compression::{Compression, MAX_DECODED_BYTES, PlaneLayout, decode_planes, encode_planes}; -use crate::error::{PsdError, Result}; -use crate::header::Version; - -/// Largest pattern edge accepted (Photoshop's own limit is far lower). -const MAX_EDGE: u32 = 30_000; -/// Budget for zero-padded channel planes summed over every pattern of one parse (a block, a `.pat` -/// file, an `.abr`). Photoshop writes every channel at the pattern rectangle, so a smaller channel -/// rectangle is only padding; without this cap a few hundred bytes (1x1 channels, a huge pattern -/// rectangle) would allocate up to `MAX_DECODED_BYTES` of zeros. -const MAX_PADDED_PATTERN_BYTES: u64 = 512 << 20; -// A PackBits run packet emits at most 128 bytes from a 2-byte packet. -const MAX_RLE_EXPANSION: u64 = 64; - -/// Decoded bytes left for the patterns of one parse. Shared by every pattern read in one call, so -/// many patterns can't each claim the whole per-pattern allowance. -#[derive(Debug, Clone)] -pub struct PatternBudget { - decoded_left: u64, - padded_left: u64, -} - -impl Default for PatternBudget { - fn default() -> Self { - PatternBudget { decoded_left: MAX_DECODED_BYTES, padded_left: MAX_PADDED_PATTERN_BYTES } - } -} - -impl PatternBudget { - /// A budget of at most `decoded` decoded bytes (zero padding included), and never more than - /// the default limits. - pub fn with_decoded_limit(decoded: u64) -> Self { - let d = Self::default(); - PatternBudget { decoded_left: d.decoded_left.min(decoded), padded_left: d.padded_left.min(decoded) } - } - - /// Cap both decoded and padded bytes to the amount still available to a containing file. - pub fn cap_decoded_to(&mut self, decoded: u64) { - self.decoded_left = self.decoded_left.min(decoded); - self.padded_left = self.padded_left.min(decoded); - } - - /// Decoded bytes still available. - pub fn decoded_left(&self) -> u64 { - self.decoded_left - } -} - -/// One pattern tile with planar, big-endian samples. -#[derive(Debug, Clone, PartialEq)] -pub struct PsdPattern { - /// PSD colour mode number (1 gray, 2 indexed, 3 RGB, 4 CMYK, 7 multichannel, 8 duotone, 9 Lab). - pub mode: u32, - /// Tile width in pixels. - pub width: u32, - /// Tile height in pixels. - pub height: u32, - /// Name without the trailing NUL (built-ins look like `$$$/Patterns/…=Water`). - pub name: String, - /// Unique id (usually a UUID). - pub id: String, - /// Indexed-colour palette (768 bytes, RGB triplets). - pub palette: Option>, - /// Bits per sample: 1, 8, 16 or 32. - pub depth: u16, - /// Decoded colour planes (`width × height` samples each). - pub channels: Vec>, - /// Decoded transparency plane, if any. - pub alpha: Option>, -} - -/// Colour channels implied by a PSD colour mode. -pub fn mode_channels(mode: u32) -> usize { - match mode { - 3 | 9 => 3, - 4 => 4, - _ => 1, - } -} - -struct Rd<'a> { - b: &'a [u8], - p: usize, -} - -struct PatternChannel<'a> { - data: &'a [u8], - compression: Compression, - source: PlaneLayout, - destination: PlaneLayout, - x: usize, - y: usize, -} - -impl<'a> Rd<'a> { - fn take(&mut self, n: usize) -> Result<&'a [u8]> { - let end = self.p.checked_add(n).ok_or(PsdError::LimitExceeded("pattern length overflow"))?; - if end > self.b.len() { - return Err(PsdError::UnexpectedEof { offset: self.p, needed: end - self.b.len() }); - } - let s = &self.b[self.p..end]; - self.p = end; - Ok(s) - } - fn u8(&mut self) -> Result { - Ok(self.take(1)?[0]) - } - fn u16(&mut self) -> Result { - let s = self.take(2)?; - Ok(u16::from_be_bytes([s[0], s[1]])) - } - fn u32(&mut self) -> Result { - let s = self.take(4)?; - Ok(u32::from_be_bytes([s[0], s[1], s[2], s[3]])) - } - fn i32(&mut self) -> Result { - Ok(self.u32()? as i32) - } - fn rect(&mut self) -> Result<[i32; 4]> { - Ok([self.i32()?, self.i32()?, self.i32()?, self.i32()?]) - } -} - -/// A pattern's mode, name, id and palette, up to its virtual memory array list. -struct PatternHeader { - mode: u32, - name: String, - id: String, - palette: Option>, -} - -fn read_header(r: &mut Rd) -> Result { - let version = r.u32()?; - if version != 1 { - return Err(PsdError::invalid(format!("pattern version {version}"))); - } - let mode = r.u32()?; - let _h = r.u16()?; - let _w = r.u16()?; - let n = r.u32()? as usize; - if n > 65_536 { - return Err(PsdError::LimitExceeded("pattern name length")); - } - let units: Vec = r.take(n * 2)?.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]])).collect(); - let name = String::from_utf16_lossy(&units).trim_end_matches('\0').to_string(); - let idl = r.u8()? as usize; - let id = String::from_utf8_lossy(r.take(idl)?).to_string(); - let palette = if mode == 2 { - let p = r.take(768)?.to_vec(); - // Some writers follow the palette with 4 extra bytes (ag-psd skips them). - if r.b.get(r.p..r.p + 4).is_some_and(|v| v != [0, 0, 0, 3]) { - r.take(4)?; - } - Some(p) - } else { - None - }; - Ok(PatternHeader { mode, name, id, palette }) -} - -/// The body of a pattern's virtual memory array list (after its version and length). -fn read_vma_body<'a>(r: &mut Rd<'a>) -> Result<&'a [u8]> { - let vver = r.u32()?; - if vver != 3 { - return Err(PsdError::invalid(format!("virtual memory array list version {vver}"))); - } - let len = r.u32()? as usize; - r.take(len) -} - -/// Pixel storage needed by a pattern, inspected without decoding channel data. -#[derive(Debug, Clone)] -pub struct PatternMemory { - /// Pattern width in pixels. - pub width: u32, - /// Pattern height in pixels. - pub height: u32, - /// Bits per sample, including packed bitmap samples. - pub depth: u16, - /// PSD colour mode number. - pub mode: u32, - /// All written planes, including any ignored extra arrays. - pub planes: usize, - /// Sum of full padded channel plane sizes. - pub decoded_bytes: u64, -} - -fn read_pattern(r: &mut Rd, budget: &mut PatternBudget) -> Result { - read_pattern_with(r, budget, true, &mut |_| {}) -} - -fn read_pattern_with(r: &mut Rd, budget: &mut PatternBudget, decode: bool, visit: &mut dyn FnMut(PatternMemory)) -> Result { - let PatternHeader { mode, name, id, palette } = read_header(r)?; - let body = read_vma_body(r)?; - let mut v = Rd { b: body, p: 0 }; - let [top, left, bottom, right] = v.rect()?; - let w = right.checked_sub(left).ok_or(PsdError::LimitExceeded("pattern width overflow"))?; - let h = bottom.checked_sub(top).ok_or(PsdError::LimitExceeded("pattern height overflow"))?; - if w <= 0 || h <= 0 || w as u32 > MAX_EDGE || h as u32 > MAX_EDGE { - return Err(PsdError::LimitExceeded("pattern size")); - } - let (w, h) = (w as u32, h as u32); - let count = v.u32()?; - if count > 64 { - return Err(PsdError::LimitExceeded("pattern channel count")); - } - // Validate every channel and the total padded size before decoding any plane. - // Otherwise a later channel can exceed the existing pattern budget only after - // earlier channels have already allocated several gigabytes of zero padding. - // `budget` is shared by every pattern of the parse, so many patterns can't each pad up to the - // per-parse cap. - let mut channels = Vec::new(); - let mut first_depth: Option = None; - for _ in 0..count + 2 { - if v.p >= body.len() { - break; - } - if v.u32()? == 0 { - continue; - } - let alen = v.u32()? as usize; - if alen == 0 { - continue; - } - if alen < 23 { - return Err(PsdError::invalid("short virtual memory array")); - } - let d32 = v.u32()?; - let [ct, cl, cb, cr] = v.rect()?; - let d16 = v.u16()?; - let comp = v.u8()?; - let data = v.take(alen - 23)?; - let d = if matches!(d16, 1 | 8 | 16 | 32) { d16 } else { d32 as u16 }; - if !matches!(d, 1 | 8 | 16 | 32) { - return Err(PsdError::invalid(format!("pattern depth {d}"))); - } - // Every plane is sized by one depth; a channel at another depth can't be written back. - if *first_depth.get_or_insert(d) != d { - return Err(PsdError::invalid("pattern channels have different depths")); - } - if ct < top || cl < left || cb > bottom || cr > right || cb < ct || cr < cl { - return Err(PsdError::invalid("pattern channel rectangle falls outside pattern rectangle")); - } - let cw = usize::try_from(cr.checked_sub(cl).ok_or(PsdError::LimitExceeded("pattern channel width overflow"))?) - .map_err(|_| PsdError::invalid("negative pattern channel width"))?; - let ch = usize::try_from(cb.checked_sub(ct).ok_or(PsdError::LimitExceeded("pattern channel height overflow"))?) - .map_err(|_| PsdError::invalid("negative pattern channel height"))?; - let channel_layout = PlaneLayout { planes: 1, width: cw, height: ch, depth: d, version: Version::Psd }; - let channel_bytes = channel_layout.total_bytes()?; - let layout = PlaneLayout { planes: 1, width: w as usize, height: h as usize, depth: d, version: Version::Psd }; - let plane_bytes = layout.total_bytes()?; - budget.decoded_left = budget.decoded_left.checked_sub(plane_bytes).ok_or(PsdError::LimitExceeded("pattern data exceeds MAX_DECODED_BYTES"))?; - if cw != w as usize || ch != h as usize { - budget.padded_left = - budget.padded_left.checked_sub(plane_bytes).ok_or(PsdError::LimitExceeded("padded pattern channels exceed MAX_PADDED_PATTERN_BYTES"))?; - } - let _ = layout.decoded_len()?; - let compression = if comp == 1 { - Compression::Rle - } else if comp == 0 { - Compression::Raw - } else { - Compression::Unknown(u16::from(comp)) - }; - let max_expansion = match compression { - Compression::Raw => Some(1), - Compression::Rle => Some(MAX_RLE_EXPANSION), - Compression::Zip | Compression::ZipPrediction | Compression::Unknown(_) => None, - }; - if let Some(max_expansion) = max_expansion { - let expansion_limit = u64::try_from(data.len()) - .map_err(|_| PsdError::LimitExceeded("pattern channel input exceeds address space"))? - .checked_mul(max_expansion) - .ok_or(PsdError::LimitExceeded("pattern channel input size overflow"))?; - if channel_bytes > expansion_limit { - return Err(PsdError::LimitExceeded("pattern channel exceeds its input-derived size limit")); - } - } - let x = usize::try_from(cl.checked_sub(left).ok_or(PsdError::LimitExceeded("pattern channel x offset overflow"))?) - .map_err(|_| PsdError::invalid("negative pattern channel x offset"))?; - let y = usize::try_from(ct.checked_sub(top).ok_or(PsdError::LimitExceeded("pattern channel y offset overflow"))?) - .map_err(|_| PsdError::invalid("negative pattern channel y offset"))?; - channels.push(PatternChannel { data, compression, source: channel_layout, destination: layout, x, y }); - } - let nc = mode_channels(mode); - if channels.len() < nc { - return Err(PsdError::invalid("pattern has fewer channels than its colour mode")); - } - visit(PatternMemory { - width: w, - height: h, - depth: first_depth.unwrap_or(8), - mode, - planes: channels.len(), - decoded_bytes: channels - .iter() - .try_fold(0u64, |sum, c| sum.checked_add(c.destination.total_bytes()?).ok_or(PsdError::LimitExceeded("pattern memory size overflow")))?, - }); - let mut planes = Vec::new(); - for channel in channels.into_iter().filter(|_| decode) { - let plane = decode_planes(channel.compression, channel.data, &channel.source)?; - planes.push(place_channel_plane(plane, &channel.source, channel.x, channel.y, &channel.destination)?); - } - let alpha = (planes.len() > nc).then(|| planes.remove(nc)); - planes.truncate(nc); - let depth = first_depth.unwrap_or(8); - Ok(PsdPattern { mode, width: w, height: h, name, id, palette, depth, channels: planes, alpha }) -} - -fn place_channel_plane(mut plane: Vec, source: &PlaneLayout, x: usize, y: usize, destination: &PlaneLayout) -> Result> { - if source.planes != 1 || destination.planes != 1 || source.depth != destination.depth { - return Err(PsdError::invalid("incompatible pattern channel layouts")); - } - let x_end = x.checked_add(source.width).ok_or(PsdError::LimitExceeded("pattern channel x offset overflow"))?; - let y_end = y.checked_add(source.height).ok_or(PsdError::LimitExceeded("pattern channel y offset overflow"))?; - if x_end > destination.width || y_end > destination.height { - return Err(PsdError::invalid("pattern channel rectangle falls outside pattern rectangle")); - } - let source_len = source.decoded_len()?; - if plane.len() != source_len { - return Err(PsdError::invalid("pattern channel payload does not match its rectangle")); - } - let destination_len = destination.decoded_len()?; - if x == 0 && y == 0 && source.width == destination.width && source.height == destination.height { - // Preserve the old placement's zeroed unused bits without copying a full - // bitmap plane. Only the last byte of each non-byte-aligned row changes. - if source.depth == 1 && !source.width.is_multiple_of(8) { - let mask = u8::MAX << (8 - source.width % 8); - for row in plane.chunks_exact_mut(source.row_bytes()) { - if let Some(last) = row.last_mut() { - *last &= mask; - } - } - } - return Ok(plane); - } - let mut out = zeroed_pattern_plane(destination_len)?; - if source.depth == 1 { - let source_row_bytes = source.row_bytes(); - let destination_row_bytes = destination.row_bytes(); - for row in 0..source.height { - for col in 0..source.width { - let source_pos = row - .checked_mul(source_row_bytes) - .and_then(|v| v.checked_add(col / 8)) - .ok_or(PsdError::LimitExceeded("pattern channel source offset overflow"))?; - let destination_col = x.checked_add(col).ok_or(PsdError::LimitExceeded("pattern channel destination offset overflow"))?; - let destination_pos = y - .checked_add(row) - .and_then(|v| v.checked_mul(destination_row_bytes)) - .and_then(|v| v.checked_add(destination_col / 8)) - .ok_or(PsdError::LimitExceeded("pattern channel destination offset overflow"))?; - let source_bit = 7 - col % 8; - let destination_bit = 7 - destination_col % 8; - let source_byte = plane.get(source_pos).ok_or(PsdError::invalid("pattern channel payload does not match its rectangle"))?; - let value = (*source_byte >> source_bit) & 1; - let dst = out.get_mut(destination_pos).ok_or(PsdError::invalid("pattern channel placement exceeds its output plane"))?; - *dst |= value << destination_bit; - } - } - } else { - let bytes_per_sample = usize::from(source.depth / 8); - let source_row_bytes = source.row_bytes(); - let destination_row_bytes = destination.row_bytes(); - let row_bytes = source.width.checked_mul(bytes_per_sample).ok_or(PsdError::LimitExceeded("pattern channel row size overflow"))?; - let x_bytes = x.checked_mul(bytes_per_sample).ok_or(PsdError::LimitExceeded("pattern channel x offset overflow"))?; - for row in 0..source.height { - let source_start = row.checked_mul(source_row_bytes).ok_or(PsdError::LimitExceeded("pattern channel source offset overflow"))?; - let destination_start = y - .checked_add(row) - .and_then(|v| v.checked_mul(destination_row_bytes)) - .and_then(|v| v.checked_add(x_bytes)) - .ok_or(PsdError::LimitExceeded("pattern channel destination offset overflow"))?; - let source_end = source_start.checked_add(row_bytes).ok_or(PsdError::LimitExceeded("pattern channel source offset overflow"))?; - let destination_end = destination_start.checked_add(row_bytes).ok_or(PsdError::LimitExceeded("pattern channel destination offset overflow"))?; - let source_row = plane.get(source_start..source_end).ok_or(PsdError::invalid("pattern channel payload does not match its rectangle"))?; - let destination_row = - out.get_mut(destination_start..destination_end).ok_or(PsdError::invalid("pattern channel placement exceeds its output plane"))?; - destination_row.copy_from_slice(source_row); - } - } - Ok(out) -} - -fn zeroed_pattern_plane(len: usize) -> Result> { - let mut out = Vec::new(); - out.try_reserve_exact(len).map_err(|_| PsdError::OutOfMemory("not enough memory for the padded pattern channel"))?; - out.resize(len, 0); - Ok(out) -} - -fn write_pattern(p: &PsdPattern, out: &mut Vec) -> Result<()> { - if p.width > MAX_EDGE || p.height > MAX_EDGE || p.width > i16::MAX as u32 || p.height > i16::MAX as u32 { - return Err(PsdError::LimitExceeded("pattern size")); - } - out.extend_from_slice(&1u32.to_be_bytes()); - out.extend_from_slice(&p.mode.to_be_bytes()); - out.extend_from_slice(&(p.height as u16).to_be_bytes()); - out.extend_from_slice(&(p.width as u16).to_be_bytes()); - let units: Vec = p.name.encode_utf16().chain(std::iter::once(0)).collect(); - // The reader refuses longer names (see `read_pattern`). - if units.len() > 65_536 { - return Err(PsdError::LimitExceeded("pattern name length")); - } - out.extend_from_slice(&(units.len() as u32).to_be_bytes()); - for u in units { - out.extend_from_slice(&u.to_be_bytes()); - } - let id = p.id.as_bytes(); - let idl = id.len().min(255); - out.push(idl as u8); - out.extend_from_slice(&id[..idl]); - if p.mode == 2 { - let mut pal = p.palette.clone().unwrap_or_default(); - pal.resize(768, 0); - out.extend_from_slice(&pal); - } - let rect = [0i32, 0, p.height as i32, p.width as i32]; - let mut body = Vec::new(); - for v in rect { - body.extend_from_slice(&v.to_be_bytes()); - } - const SLOTS: u32 = 24; - body.extend_from_slice(&SLOTS.to_be_bytes()); - let layout = PlaneLayout { planes: 1, width: p.width as usize, height: p.height as usize, depth: p.depth, version: Version::Psd }; - let array = |plane: &[u8], body: &mut Vec| -> Result<()> { - let (comp, data) = - if p.depth == 8 { (1u8, encode_planes(Compression::Rle, plane, &layout)?) } else { (0u8, encode_planes(Compression::Raw, plane, &layout)?) }; - body.extend_from_slice(&1u32.to_be_bytes()); - body.extend_from_slice(&((23 + data.len()) as u32).to_be_bytes()); - body.extend_from_slice(&u32::from(p.depth).to_be_bytes()); - for v in rect { - body.extend_from_slice(&v.to_be_bytes()); - } - body.extend_from_slice(&p.depth.to_be_bytes()); - body.push(comp); - body.extend_from_slice(&data); - Ok(()) - }; - let nc = p.channels.len().min(SLOTS as usize); - for c in &p.channels[..nc] { - array(c, &mut body)?; - } - for _ in nc..SLOTS as usize { - body.extend_from_slice(&0u32.to_be_bytes()); - } - // User mask slot: unused; sheet mask slot: transparency, where Photoshop keeps it (#8054). - body.extend_from_slice(&0u32.to_be_bytes()); - match &p.alpha { - Some(a) => array(a, &mut body)?, - None => body.extend_from_slice(&0u32.to_be_bytes()), - } - out.extend_from_slice(&3u32.to_be_bytes()); - out.extend_from_slice(&(body.len() as u32).to_be_bytes()); - out.extend_from_slice(&body); - Ok(()) -} - -/// Parses the data of a `Patt` / `Pat2` / `Pat3` global block. -pub fn parse_pattern_block(data: &[u8]) -> Result> { - parse_pattern_block_with(data, &mut PatternBudget::default()) -} - -/// [`parse_pattern_block`] charging `budget`, so several blocks of one document share one limit. -/// Charges are monotonic even when a later record in the block fails. -pub fn parse_pattern_block_with(data: &[u8], budget: &mut PatternBudget) -> Result> { - let mut r = Rd { b: data, p: 0 }; - let mut out = Vec::new(); - while r.p + 4 <= data.len() { - let len = r.u32()? as usize; - if len == 0 { - break; - } - let body = r.take(len)?; - out.push(read_pattern(&mut Rd { b: body, p: 0 }, budget)?); - let pad = (4 - len % 4) % 4; - r.p = (r.p + pad).min(data.len()); - } - Ok(out) -} - -/// Inspect a pattern block's dimensions and decoded sizes without allocating pixel planes. -/// Uses the pixel parser’s structural validation and shared decode limits. Returns -/// earlier records even if a later record is malformed, since those can decode first. -pub fn pattern_block_memory(data: &[u8], budget: &mut PatternBudget) -> Vec { - let mut r = Rd { b: data, p: 0 }; - let mut out = Vec::new(); - while r.p + 4 <= data.len() { - let Ok(len) = r.u32() else { break }; - let len = len as usize; - if len == 0 { - break; - } - let Ok(body) = r.take(len) else { break }; - // Earlier patterns may already have decoded when a later record is malformed. - if read_pattern_with(&mut Rd { b: body, p: 0 }, budget, false, &mut |m| out.push(m)).is_err() { - break; - } - r.p = (r.p + (4 - len % 4) % 4).min(data.len()); - } - out -} - -/// Whether `Patt` / `Pat2` / `Pat3` block data keeps a pattern's transparency in the user mask -/// array, as PhotoCraft wrote it before #8054. Photoshop refuses such a file, so a save must -/// rewrite the block rather than keep it. Unreadable data counts as `false`. -pub fn block_has_user_mask(data: &[u8]) -> bool { - let mut r = Rd { b: data, p: 0 }; - while r.p + 4 <= data.len() { - let Ok(len) = r.u32() else { return false }; - if len == 0 { - break; - } - let Ok(body) = r.take(len as usize) else { return false }; - if pattern_has_user_mask(&mut Rd { b: body, p: 0 }).unwrap_or(false) { - return true; - } - let pad = (4 - len as usize % 4) % 4; - r.p = (r.p + pad).min(data.len()); - } - false -} - -fn pattern_has_user_mask(r: &mut Rd) -> Result { - read_header(r)?; - let mut v = Rd { b: read_vma_body(r)?, p: 0 }; - v.rect()?; - let count = v.u32()?; - // Every array takes at least 4 bytes, so the body bounds this loop whatever `count` says. - for i in 0..count.saturating_add(2) { - if v.p >= v.b.len() { - break; - } - if v.u32()? == 0 { - continue; - } - let alen = v.u32()? as usize; - if i == count && alen != 0 { - return Ok(true); - } - v.take(alen)?; - } - Ok(false) -} - -/// Serializes patterns as `Patt` / `Pat2` / `Pat3` block data. -pub fn write_pattern_block(patterns: &[PsdPattern]) -> Result> { - let mut out = Vec::new(); - for p in patterns { - let mut one = Vec::new(); - write_pattern(p, &mut one)?; - out.extend_from_slice(&(one.len() as u32).to_be_bytes()); - let pad = (4 - one.len() % 4) % 4; - out.extend_from_slice(&one); - out.extend(std::iter::repeat_n(0u8, pad)); - } - Ok(out) -} - -/// Tagged-block key for patterns of a document at `depth` bits. -pub fn block_key(depth: u16) -> [u8; 4] { - match depth { - 16 => *b"Pat2", - 32 => *b"Pat3", - _ => *b"Patt", - } -} - -/// The most patterns a `.pat` file may hold: more are refused on read and on write. -pub const MAX_PAT_PATTERNS: usize = 100_000; - -/// Parses a `.pat` pattern file. -pub fn parse_pat_file(data: &[u8]) -> Result> { - let mut r = Rd { b: data, p: 0 }; - let sig = r.take(4)?; - if sig != b"8BPT" { - return Err(PsdError::InvalidSignature { expected: "8BPT", found: [sig[0], sig[1], sig[2], sig[3]] }); - } - let _version = r.u16()?; - let count = r.u32()? as usize; - if count > MAX_PAT_PATTERNS { - return Err(PsdError::LimitExceeded("pattern count")); - } - let mut out = Vec::new(); - let mut budget = PatternBudget::default(); - for _ in 0..count { - out.push(read_pattern(&mut r, &mut budget)?); - } - Ok(out) -} - -/// Writes a `.pat` pattern file. -pub fn write_pat_file(patterns: &[PsdPattern]) -> Result> { - if patterns.len() > MAX_PAT_PATTERNS { - return Err(PsdError::LimitExceeded("pattern count")); - } - let mut out = b"8BPT".to_vec(); - out.extend_from_slice(&1u16.to_be_bytes()); - out.extend_from_slice(&(patterns.len() as u32).to_be_bytes()); - for p in patterns { - write_pattern(p, &mut out)?; - } - Ok(out) -} - -#[cfg(test)] -mod tests; diff --git a/crates/psd/src/patterns/tests.rs b/crates/psd/src/patterns/tests.rs deleted file mode 100644 index e66d583738..0000000000 --- a/crates/psd/src/patterns/tests.rs +++ /dev/null @@ -1,352 +0,0 @@ -use super::*; - -fn pat(depth: u16, alpha: bool) -> PsdPattern { - let (w, h) = (5u32, 3u32); - let bpp = usize::from(depth / 8); - let plane = |s: u8| (0..w as usize * h as usize * bpp).map(|i| (i as u8).wrapping_mul(7).wrapping_add(s)).collect::>(); - PsdPattern { - mode: 3, - width: w, - height: h, - name: "Test ✓".into(), - id: "0bd2d3ba-1234-11d4-8f8f-aabbccddeeff".into(), - palette: None, - depth, - channels: vec![plane(1), plane(2), plane(3)], - alpha: alpha.then(|| plane(9)), - } -} - -/// #9318 review: a name the reader refuses (more than 65,536 UTF-16 units with its NUL) is -/// refused on write, so a saved library can't become a file that loads nothing. -#[test] -fn a_name_the_reader_refuses_is_refused_on_write() { - let long = PsdPattern { name: "a".repeat(65_536), ..pat(8, false) }; - assert!(matches!(write_pat_file(&[long]), Err(PsdError::LimitExceeded(_)))); - let longest = PsdPattern { name: "a".repeat(65_535), ..pat(8, false) }; - assert_eq!(parse_pat_file(&write_pat_file(std::slice::from_ref(&longest)).unwrap()).unwrap(), [longest]); -} - -/// #9318 review: more patterns than a `.pat` holds are refused on write, and a file claiming -/// more is refused on read rather than silently cut to the first 100,000. -#[test] -fn more_patterns_than_a_pat_file_holds_are_refused() { - let many = vec![pat(8, false); MAX_PAT_PATTERNS + 1]; - assert!(matches!(write_pat_file(&many), Err(PsdError::LimitExceeded(_)))); - let mut header = b"8BPT".to_vec(); - header.extend_from_slice(&1u16.to_be_bytes()); - header.extend_from_slice(&(MAX_PAT_PATTERNS as u32 + 1).to_be_bytes()); - assert!(matches!(parse_pat_file(&header), Err(PsdError::LimitExceeded(_)))); -} - -#[test] -fn block_and_pat_file_round_trip_at_every_depth() { - for depth in [8, 16, 32] { - for alpha in [false, true] { - let ps = vec![pat(depth, alpha), PsdPattern { name: "Second".into(), ..pat(depth, false) }]; - let blk = write_pattern_block(&ps).unwrap(); - assert_eq!(blk.len() % 4, 0); - assert_eq!(parse_pattern_block(&blk).unwrap(), ps); - let f = write_pat_file(&ps).unwrap(); - assert_eq!(parse_pat_file(&f).unwrap(), ps); - // Re-serialising is byte-stable. - assert_eq!(write_pattern_block(&parse_pattern_block(&blk).unwrap()).unwrap(), blk); - } - } -} - -/// Offsets of the virtual memory arrays of a one-pattern block (each at its "written" flag), -/// with whether each is written, and the end of the array list. -fn vma_slots(block: &[u8], p: &PsdPattern) -> (Vec<(usize, bool)>, usize) { - let rect = pattern_vma_rect_offset(4, p); - let end = rect + u32::from_be_bytes(block[rect - 4..rect].try_into().unwrap()) as usize; - let (mut at, mut slots) = (rect + 20, Vec::new()); - while at < end { - let written = u32::from_be_bytes(block[at..at + 4].try_into().unwrap()) != 0; - slots.push((at, written)); - at += 4; - if written { - at += 4 + u32::from_be_bytes(block[at..at + 4].try_into().unwrap()) as usize; - } - } - (slots, end) -} - -#[test] -fn transparency_goes_in_the_sheet_mask_array_like_photoshop() { - // #8054: transparency in the user mask array (slot 24) made Photoshop refuse the file; - // Photoshop keeps it in the sheet mask array (slot 25). - for depth in [8, 16, 32] { - let p = pat(depth, true); - let mut block = write_pattern_block(std::slice::from_ref(&p)).unwrap(); - let (slots, end) = vma_slots(&block, &p); - let written: Vec = slots.iter().enumerate().filter(|(_, s)| s.1).map(|(i, _)| i).collect(); - assert_eq!((slots.len(), written), (26, vec![0, 1, 2, 25]), "{depth}-bit"); - assert!(!block_has_user_mask(&block)); - // The layout older PhotoCraft builds wrote still opens, and is detected. - block[slots[24].0..end].rotate_left(4); - let (slots, _) = vma_slots(&block, &p); - assert!(slots[24].1 && !slots[25].1); - assert!(block_has_user_mask(&block)); - assert_eq!(parse_pattern_block(&block).unwrap(), vec![p]); - } - assert!(!block_has_user_mask(&write_pattern_block(&[pat(8, false)]).unwrap())); - for junk in [&[][..], &[0, 0, 0, 9, 1], &[0xff; 64]] { - assert!(!block_has_user_mask(junk)); - } -} - -#[test] -fn malformed_input_errors_without_panicking() { - let blk = write_pattern_block(&[pat(8, true)]).unwrap(); - for cut in [3, 10, 40, blk.len() - 5] { - let _ = parse_pattern_block(&blk[..cut]); - } - assert!(parse_pat_file(b"8BPS\0\x01").is_err()); - assert!(parse_pattern_block(&[]).unwrap().is_empty()); -} - -fn pattern_vma_rect_offset(pattern_start: usize, p: &PsdPattern) -> usize { - let name_units = p.name.encode_utf16().count() + 1; - pattern_start + 16 + name_units * 2 + 1 + p.id.len() + if p.mode == 2 { 768 } else { 0 } + 8 -} - -fn set_i32(bytes: &mut [u8], offset: usize, value: i32) { - bytes[offset..offset + 4].copy_from_slice(&value.to_be_bytes()); -} - -#[test] -fn hostile_pattern_rectangles_are_rejected_through_all_import_routes() { - let p = pat(8, false); - - // A subtraction of the hostile endpoints overflowed before rectangle validation. - let mut global = write_pattern_block(std::slice::from_ref(&p)).unwrap(); - let vma = pattern_vma_rect_offset(4, &p); - set_i32(&mut global, vma + 4, i32::MIN); - set_i32(&mut global, vma + 12, i32::MAX); - assert!(parse_pattern_block(&global).is_err()); - - let mut standalone = write_pat_file(std::slice::from_ref(&p)).unwrap(); - let vma = pattern_vma_rect_offset(10, &p); - set_i32(&mut standalone, vma + 4, i32::MIN); - set_i32(&mut standalone, vma + 12, i32::MAX); - assert!(parse_pat_file(&standalone).is_err()); - - let mut abr = crate::abr::write_v6( - 1, - &[crate::abr::AbrSample { id: String::new(), width: 1, height: 1, depth: 8, data: vec![0] }], - std::slice::from_ref(&p), - &[], - true, - ) - .unwrap(); - let section = abr.windows(4).position(|w| w == b"patt").unwrap(); - let vma = pattern_vma_rect_offset(section + 8, &p) + 4; - set_i32(&mut abr, vma + 4, i32::MIN); - set_i32(&mut abr, vma + 12, i32::MAX); - let parsed = crate::abr::parse(&abr).unwrap(); - assert!(parsed.patterns.is_empty()); - assert!(parsed.warnings.iter().any(|warning| warning.contains("embedded patterns unreadable"))); -} - -#[test] -fn huge_pattern_rect_with_tiny_channel_payload_is_rejected() { - // RGB32 needs 10.8 GB; CMYK16 plus alpha needs 9 GB. Both exceed the - // existing pattern budget, but their first plane fits on 64-bit targets. - // The complete metadata must be checked before allocating that first plane. - huge_pattern_rect_is_rejected(pat(32, false)); - let mut cmyk = pat(16, true); - cmyk.mode = 4; - cmyk.channels.push(vec![0; 5 * 3 * 2]); - huge_pattern_rect_is_rejected(cmyk); -} - -fn huge_pattern_rect_is_rejected(p: PsdPattern) { - let mut global = write_pattern_block(std::slice::from_ref(&p)).unwrap(); - let vma = pattern_vma_rect_offset(4, &p); - set_i32(&mut global, vma + 8, 30_000); - set_i32(&mut global, vma + 12, 30_000); - assert!(matches!(parse_pattern_block(&global), Err(PsdError::LimitExceeded(_)))); - - let mut standalone = write_pat_file(std::slice::from_ref(&p)).unwrap(); - let vma = pattern_vma_rect_offset(10, &p); - set_i32(&mut standalone, vma + 8, 30_000); - set_i32(&mut standalone, vma + 12, 30_000); - assert!(matches!(parse_pat_file(&standalone), Err(PsdError::LimitExceeded(_)))); - - let mut abr = crate::abr::write_v6( - 1, - &[crate::abr::AbrSample { id: String::new(), width: 1, height: 1, depth: 8, data: vec![0] }], - std::slice::from_ref(&p), - &[], - true, - ) - .unwrap(); - let section = abr.windows(4).position(|w| w == b"patt").unwrap(); - let vma = pattern_vma_rect_offset(section + 8, &p) + 4; - set_i32(&mut abr, vma + 8, 30_000); - set_i32(&mut abr, vma + 12, 30_000); - let parsed = crate::abr::parse(&abr).unwrap(); - assert!(parsed.patterns.is_empty()); - assert!(parsed.warnings.iter().any(|warning| warning.contains("embedded patterns unreadable"))); -} - -#[test] -fn offset_channel_rectangles_are_placed_inside_the_pattern() { - for depth in [8, 16, 32] { - let original = pat(depth, true); - let mut block = write_pattern_block(std::slice::from_ref(&original)).unwrap(); - let vma = pattern_vma_rect_offset(4, &original); - set_i32(&mut block, vma, -2); - set_i32(&mut block, vma + 4, -3); - set_i32(&mut block, vma + 8, 4); - set_i32(&mut block, vma + 12, 5); - - let parsed = parse_pattern_block(&block).unwrap(); - let parsed = parsed.first().unwrap(); - assert_eq!((parsed.width, parsed.height), (8, 6)); - let bytes_per_sample = usize::from(depth / 8); - for (actual, expected) in parsed.channels.iter().zip(&original.channels).chain(parsed.alpha.iter().zip(original.alpha.iter())) { - for y in 0..6usize { - for x in 0..8usize { - let at = (y * 8 + x) * bytes_per_sample; - let actual_sample = actual.get(at..at + bytes_per_sample).unwrap(); - if (2..5).contains(&y) && (3..8).contains(&x) { - let source_at = ((y - 2) * 5 + (x - 3)) * bytes_per_sample; - assert_eq!(actual_sample, expected.get(source_at..source_at + bytes_per_sample).unwrap()); - } else { - assert!(actual_sample.iter().all(|&sample| sample == 0)); - } - } - } - } - } -} - -#[test] -fn one_bit_offset_channel_placement_preserves_msb_first_pixels() { - let source = PlaneLayout { planes: 1, width: 5, height: 2, depth: 1, version: Version::Psd }; - let destination = PlaneLayout { planes: 1, width: 8, height: 4, depth: 1, version: Version::Psd }; - let placed = place_channel_plane(vec![0b1010_1000, 0b0101_0000], &source, 2, 1, &destination).unwrap(); - assert_eq!(placed, [0, 0b0010_1010, 0b0001_0100, 0]); -} - -#[test] -fn full_size_channels_reuse_the_decoded_allocation() { - for depth in [1, 8, 16, 32] { - let layout = PlaneLayout { planes: 1, width: 5, height: 3, depth, version: Version::Psd }; - let plane = vec![0x55; layout.decoded_len().unwrap()]; - let original = plane.as_ptr(); - let placed = place_channel_plane(plane, &layout, 0, 0, &layout).unwrap(); - assert_eq!(placed.as_ptr(), original); - let expected = if depth == 1 { 0x50 } else { 0x55 }; - assert!(placed.iter().all(|&byte| byte == expected)); - } -} - -#[test] -fn large_channel_padding_within_the_existing_budget_is_allowed() { - let mut p = pat(8, false); - p.mode = 1; - let mut block = write_pattern_block(std::slice::from_ref(&p)).unwrap(); - let vma = pattern_vma_rect_offset(4, &p); - // More than 64 MiB of implicit padding is valid: the fix must not introduce - // a smaller, padding-specific format limit to avoid the oversized RGB case. - set_i32(&mut block, vma + 8, 8193); - set_i32(&mut block, vma + 12, 8193); - let parsed = parse_pattern_block(&block).unwrap(); - let plane = &parsed[0].channels[0]; - assert_eq!(plane.len(), 8193 * 8193); - for y in 0..3 { - assert_eq!(&plane[y * 8193..y * 8193 + 5], &p.channels[0][y * 5..y * 5 + 5]); - } - assert_eq!(plane[5], 0); - assert_eq!(plane[plane.len() - 1], 0); -} - -#[test] -fn padded_plane_allocation_failure_is_an_error() { - // Capacity overflow is deterministic and never asks the OS for huge memory. - assert!(matches!(zeroed_pattern_plane(usize::MAX), Err(PsdError::OutOfMemory("not enough memory for the padded pattern channel")))); -} - -#[test] -fn tiny_channels_in_a_huge_pattern_rect_are_not_zero_padded_without_bound() { - // 3 channels of 5x3 padded to 30000x30000 would allocate 2.7 GB of zeros. - let p = pat(8, false); - let mut global = write_pattern_block(std::slice::from_ref(&p)).unwrap(); - let vma = pattern_vma_rect_offset(4, &p); - set_i32(&mut global, vma + 8, 30_000); - set_i32(&mut global, vma + 12, 30_000); - assert!(matches!(parse_pattern_block(&global), Err(PsdError::LimitExceeded("padded pattern channels exceed MAX_PADDED_PATTERN_BYTES")))); -} - -/// A one-channel gray pattern whose 5x3 channel is padded into a `size`x`size` rectangle. -fn padded_gray_block(size: i32) -> Vec { - let mut p = pat(8, false); - p.mode = 1; - p.channels.truncate(1); - let mut block = write_pattern_block(std::slice::from_ref(&p)).unwrap(); - let vma = pattern_vma_rect_offset(4, &p); - set_i32(&mut block, vma + 8, size); - set_i32(&mut block, vma + 12, size); - block -} - -#[test] -fn failed_pattern_block_keeps_partial_charge_for_the_next_block() { - let one = padded_gray_block(100); - let mut malformed = one.clone(); - malformed.extend_from_slice(&100u32.to_be_bytes()); - let mut budget = PatternBudget::with_decoded_limit(15_000); - assert!(parse_pattern_block_with(&malformed, &mut budget).is_err()); - assert_eq!(budget.decoded_left(), 5_000); - assert!(parse_pattern_block_with(&one, &mut budget).is_err()); - assert_eq!(budget.decoded_left(), 5_000); -} - -#[test] -fn padding_budget_is_shared_by_every_pattern_of_a_parse() { - // Each pattern pads 100x100 = 10000 bytes; a 15000-byte budget fits one, not two. Before - // the fix each pattern got a fresh allowance, so N tiny patterns committed N times the cap. - let one = padded_gray_block(100); - let mut budget = PatternBudget::with_decoded_limit(15_000); - assert_eq!(parse_pattern_block_with(&one, &mut budget).unwrap().len(), 1); - assert_eq!(budget.decoded_left(), 5_000); - - let two = [one.clone(), one.clone()].concat(); - let mut budget = PatternBudget::with_decoded_limit(15_000); - assert!(matches!(parse_pattern_block_with(&two, &mut budget), Err(PsdError::LimitExceeded(_)))); - // Charges are monotonic: the first pattern's 10000 bytes stay charged even when the second - // record fails. - assert_eq!(budget.decoded_left(), 5_000); - - // Two blocks of one document share a budget too. - let mut budget = PatternBudget::with_decoded_limit(15_000); - assert!(parse_pattern_block_with(&one, &mut budget).is_ok()); - assert!(parse_pattern_block_with(&one, &mut budget).is_err()); -} - -#[test] -fn channels_at_different_depths_are_rejected() { - // The second colour channel claims 8 bits in a 16-bit pattern. It used to be accepted with - // the pattern's depth taken from the last channel, so the result couldn't be written back. - let p = pat(16, false); - let mut block = write_pattern_block(std::slice::from_ref(&p)).unwrap(); - let first = pattern_vma_rect_offset(4, &p) + 20; - let first_len = u32::from_be_bytes(block[first + 4..first + 8].try_into().unwrap()) as usize; - let second = first + 8 + first_len; - block[second + 8..second + 12].copy_from_slice(&8u32.to_be_bytes()); - block[second + 28..second + 30].copy_from_slice(&8u16.to_be_bytes()); - let err = parse_pattern_block(&block).unwrap_err().to_string(); - assert!(err.contains("different depths"), "{err}"); -} - -#[test] -fn zero_size_pattern_rectangles_are_rejected() { - let p = pat(8, false); - let mut block = write_pattern_block(std::slice::from_ref(&p)).unwrap(); - let vma = pattern_vma_rect_offset(4, &p); - set_i32(&mut block, vma + 8, 0); - assert!(parse_pattern_block(&block).is_err()); -} diff --git a/crates/psd/src/pixels.rs b/crates/psd/src/pixels.rs deleted file mode 100644 index 04f7c28ae8..0000000000 --- a/crates/psd/src/pixels.rs +++ /dev/null @@ -1,465 +0,0 @@ -//! Sample helpers and RGBA8 convenience extraction. -//! -//! These are conveniences for previews and tests; they perform no color -//! management. 16-bit samples are scaled with rounding, 32-bit float samples -//! are clamped to 0..=1 (no tone mapping or gamma), CMYK is converted -//! naively (`r = c' * k' / 255` using the stored, inverted values). - -use crate::error::{PsdError, Result}; -use crate::file::PsdFile; -use crate::header::{ColorMode, Header, Version}; -use crate::layer::{CHANNEL_REAL_USER_MASK, CHANNEL_TRANSPARENCY, CHANNEL_USER_MASK, LayerRecord, Rect}; -use std::borrow::Cow; - -/// Interprets planar big-endian bytes as u16 samples. -pub fn samples_u16(bytes: &[u8]) -> Vec { - bytes.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]])).collect() -} - -/// Interprets planar big-endian bytes as f32 samples. -pub fn samples_f32(bytes: &[u8]) -> Vec { - bytes.as_chunks::<4>().0.iter().map(|c| f32::from_be_bytes([c[0], c[1], c[2], c[3]])).collect() -} - -/// Encodes u16 samples as big-endian bytes. -pub fn u16_to_bytes(samples: &[u16]) -> Vec { - samples.iter().flat_map(|s| s.to_be_bytes()).collect() -} - -/// Encodes f32 samples as big-endian bytes. -pub fn f32_to_bytes(samples: &[f32]) -> Vec { - samples.iter().flat_map(|s| s.to_be_bytes()).collect() -} - -/// Allocates `len` bytes, reporting a machine without that much memory instead of aborting. -fn try_buffer(len: usize) -> Result> { - let mut out = Vec::new(); - out.try_reserve_exact(len).map_err(|_| PsdError::OutOfMemory("not enough memory for the converted plane"))?; - Ok(out) -} - -/// Expands a 1-bit plane (MSB first, rows padded to bytes) to one byte per -/// pixel (0 or 1). Errors with [`PsdError::LimitExceeded`] when the plane -/// size overflows or cannot be allocated. -pub fn unpack_bits(bytes: &[u8], width: usize, height: usize) -> Result> { - unpack_bits_with(bytes, width, height, |bit| bit) -} - -/// Shared 1-bit expansion; `map` turns each bit (0 or 1) into the output byte. -fn unpack_bits_with(bytes: &[u8], width: usize, height: usize, map: impl Fn(u8) -> u8) -> Result> { - let len = width.checked_mul(height).ok_or(PsdError::LimitExceeded("plane size overflow"))?; - let rb = width.div_ceil(8); - let mut out = try_buffer(len)?; - for y in 0..height { - for x in 0..width { - let b = y.checked_mul(rb).and_then(|row| bytes.get(row + x / 8)).copied().unwrap_or(0); - out.push(map((b >> (7 - (x % 8))) & 1)); - } - } - Ok(out) -} - -/// Converts one plane of samples at `depth` to 8-bit. -pub fn plane_to_u8(bytes: &[u8], depth: u16, width: usize, height: usize) -> Result> { - Ok(match depth { - 1 => unpack_bits_with(bytes, width, height, |b| if b == 1 { 0 } else { 255 })?, - 8 => { - let mut out = try_buffer(bytes.len())?; - out.extend_from_slice(bytes); - out - } - 16 => { - let chunks = bytes.as_chunks::<2>().0; - let mut out = try_buffer(chunks.len())?; - out.extend(chunks.iter().map(|b| ((u32::from(u16::from_be_bytes(*b)) * 255 + 32767) / 65535) as u8)); - out - } - 32 => { - let chunks = bytes.as_chunks::<4>().0; - let mut out = try_buffer(chunks.len())?; - out.extend(chunks.iter().map(|b| { - let v = f32::from_be_bytes(*b); - if v.is_nan() { 0 } else { (v.clamp(0.0, 1.0) * 255.0).round() as u8 } - })); - out - } - d => return Err(PsdError::Unsupported(format!("depth {d}"))), - }) -} - -/// An 8-bit RGBA image positioned in document coordinates. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct RgbaImage { - /// Left edge in document coordinates. - pub left: i32, - /// Top edge in document coordinates. - pub top: i32, - /// Width. - pub width: u32, - /// Height. - pub height: u32, - /// Interleaved RGBA, row-major. - pub data: Vec, -} - -/// An 8-bit single-channel image (e.g. a layer mask). -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct GrayImage { - /// Left edge. - pub left: i32, - /// Top edge. - pub top: i32, - /// Width. - pub width: u32, - /// Height. - pub height: u32, - /// Samples. - pub data: Vec, - /// Value outside the rectangle (masks only). - pub default_value: u8, -} - -fn interleave(mode: ColorMode, color: &[Cow<'_, [u8]>], alpha: Option<&[u8]>, palette: &[u8], n: usize) -> Result> { - if color.len() != color_channel_count(mode)? || color.iter().any(|p| p.len() != n) || alpha.is_some_and(|p| p.len() != n) { - return Err(PsdError::invalid("channel size mismatch")); - } - let len = n - .checked_mul(4) - .filter(|&len| len as u64 <= crate::compression::MAX_DECODED_BYTES) - .ok_or(PsdError::LimitExceeded("RGBA output exceeds MAX_DECODED_BYTES"))?; - let mut out = Vec::new(); - out.try_reserve_exact(len).map_err(|_| PsdError::OutOfMemory("not enough memory for RGBA output"))?; - out.resize(len, 255); - if mode == ColorMode::Rgb { - let [r, g, b] = color else { return Err(PsdError::invalid("RGB requires three channels")) }; - let rgb = r.iter().zip(g.iter()).zip(b.iter()); - match alpha { - Some(alpha) => { - for (rgba, (((r, g), b), a)) in out.as_chunks_mut::<4>().0.iter_mut().zip(rgb.zip(alpha)) { - *rgba = [*r, *g, *b, *a]; - } - } - None => { - for (rgba, ((r, g), b)) in out.as_chunks_mut::<4>().0.iter_mut().zip(rgb) { - *rgba = [*r, *g, *b, 255]; - } - } - } - return Ok(out); - } - for i in 0..n { - let (r, g, b) = match mode { - ColorMode::Rgb => (color[0][i], color[1][i], color[2][i]), - ColorMode::Grayscale | ColorMode::Bitmap | ColorMode::Duotone => (color[0][i], color[0][i], color[0][i]), - ColorMode::Indexed => { - let idx = usize::from(color[0][i]); - let p = |k: usize| palette.get(k * 256 + idx).copied().unwrap_or(0); - (p(0), p(1), p(2)) - } - ColorMode::Cmyk => { - let k = u32::from(color[3][i]); - let f = |c: u8| ((u32::from(c) * k + 127) / 255) as u8; - (f(color[0][i]), f(color[1][i]), f(color[2][i])) - } - m => return Err(PsdError::Unsupported(format!("rgba8 for color mode {m:?}"))), - }; - out[i * 4] = r; - out[i * 4 + 1] = g; - out[i * 4 + 2] = b; - out[i * 4 + 3] = alpha.map_or(255, |a| a[i]); - } - Ok(out) -} - -fn color_channel_count(mode: ColorMode) -> Result { - match mode { - ColorMode::Rgb => Ok(3), - ColorMode::Cmyk => Ok(4), - ColorMode::Grayscale | ColorMode::Indexed | ColorMode::Bitmap | ColorMode::Duotone => Ok(1), - m => Err(PsdError::Unsupported(format!("rgba8 for color mode {m:?}"))), - } -} - -/// One 8-bit sample matted against white at alpha `a`, back to straight colour (rounded, at most -/// 255). At `a == 0` there is no colour to recover and the sample is kept. -fn unmatte_u8(c: u8, a: u8) -> u8 { - let a = u32::from(a); - ((u32::from(c) + a).saturating_sub(255) * 255 + a / 2).checked_div(a).map_or(c, |straight| straight.min(255) as u8) -} - -/// [`unmatte_u8`] of every sample, one row per alpha: `table[a][c]`. Built per call (64 KiB, a -/// division per entry) rather than per pixel, and not as a static, which would add 64 KiB to -/// every binary that links this crate. -fn unmatte_u8_table() -> Vec<[u8; 256]> { - (0..=255).map(|a| std::array::from_fn(|c| unmatte_u8(c as u8, a))).collect() -} - -fn unmatte_to_u8(color: &[u8], alpha: &[u8], depth: u16) -> Result> { - let convert = |c: f32, a: f32| { - let straight = if a > 0.0 { (c + a - 1.0) / a } else { c }; - (straight.clamp(0.0, 1.0) * 255.0).round() as u8 - }; - Ok(match depth { - 16 => color - .as_chunks::<2>() - .0 - .iter() - .zip(alpha.as_chunks::<2>().0) - .map(|(c, a)| convert(f32::from(u16::from_be_bytes(*c)) / 65535.0, f32::from(u16::from_be_bytes(*a)) / 65535.0)) - .collect(), - 32 => color.as_chunks::<4>().0.iter().zip(alpha.as_chunks::<4>().0).map(|(c, a)| convert(f32::from_be_bytes(*c), f32::from_be_bytes(*a))).collect(), - d => return Err(PsdError::Unsupported(format!("RGB composite depth {d}"))), - }) -} - -/// A borrowed view of a layer with the file context needed to decode it. -#[derive(Debug, Clone, Copy)] -pub struct Layer<'a> { - /// Index in [`PsdFile::layers`]. - pub index: usize, - /// The record. - pub record: &'a LayerRecord, - header: &'a Header, -} - -impl<'a> Layer<'a> { - /// Name, preferring `luni`. - pub fn name(&self) -> String { - self.record.name() - } - - /// PSD/PSB version of the containing file. - pub fn version(&self) -> Version { - self.header.version - } - - /// Decodes a channel (planar, big-endian samples). - pub fn channel_bytes(&self, id: i16) -> Result> { - self.record.decode_channel(id, self.header.depth, self.header.version) - } - - /// Layer pixels as RGBA8 (transparency channel as alpha; the user mask is - /// *not* applied, see [`Self::user_mask`]). Supports RGB, grayscale and - /// CMYK at 8/16/32 bits. - pub fn rgba8(&self) -> Result { - let rect = self.record.rect; - let (w, h) = rect.size()?; - let n = w.checked_mul(h).ok_or(PsdError::LimitExceeded("layer size overflow"))?; - let depth = self.header.depth; - let mode = self.header.color_mode; - let cc = color_channel_count(mode)?; - // The planes (one per colour channel, filled in for channels the layer lacks, and alpha) - // coexist with the RGBA output, so the cap covers all of them, not the output alone. - if n.checked_mul(cc + 2 + 4).is_none_or(|len| len as u64 > crate::compression::MAX_DECODED_BYTES) { - return Err(PsdError::LimitExceeded("RGBA output exceeds MAX_DECODED_BYTES")); - } - let mut color = Vec::with_capacity(cc); - for id in 0..cc as i16 { - let plane = if self.record.channel(id).is_some() { - let bytes = self.channel_bytes(id)?; - if depth == 8 { bytes } else { plane_to_u8(&bytes, depth, w, h)? } - } else { - vec![if mode == ColorMode::Cmyk { 255 } else { 0 }; n] - }; - if plane.len() != n { - return Err(PsdError::invalid("channel size mismatch")); - } - color.push(Cow::Owned(plane)); - } - let alpha = match self.record.channel(CHANNEL_TRANSPARENCY) { - Some(_) => { - let bytes = self.channel_bytes(CHANNEL_TRANSPARENCY)?; - Some(if depth == 8 { bytes } else { plane_to_u8(&bytes, depth, w, h)? }) - } - None => None, - }; - let data = interleave(mode, &color, alpha.as_deref(), &[], n)?; - Ok(RgbaImage { left: rect.left, top: rect.top, width: w as u32, height: h as u32, data }) - } - - /// The user mask (channel -3 if present, else -2) as 8-bit gray, with its - /// rectangle and default color. `None` if the layer has no mask channel. - pub fn user_mask(&self) -> Option> { - let id = if self.record.channel(CHANNEL_REAL_USER_MASK).is_some() { - CHANNEL_REAL_USER_MASK - } else if self.record.channel(CHANNEL_USER_MASK).is_some() { - CHANNEL_USER_MASK - } else { - return None; - }; - Some((|| { - let rect: Rect = self.record.channel_rect(id); - let (w, h) = rect.size()?; - let data = plane_to_u8(&self.channel_bytes(id)?, self.header.depth, w, h)?; - let default_value = match (self.record.layer_mask(), id) { - (Some(m), CHANNEL_REAL_USER_MASK) => m.real.map_or(m.default_color, |r| r.background), - (Some(m), _) => m.default_color, - (None, _) => 0, - }; - Ok(GrayImage { left: rect.left, top: rect.top, width: w as u32, height: h as u32, data, default_value }) - })()) - } -} - -impl PsdFile { - /// Borrowed view of layer `index`. - pub fn layer(&self, index: usize) -> Option> { - self.layers().get(index).map(|record| Layer { index, record, header: &self.header }) - } - - /// Iterates over layers in file order (bottom-most first). - pub fn iter_layers(&self) -> impl Iterator> { - self.layers().iter().enumerate().map(|(index, record)| Layer { index, record, header: &self.header }) - } - - /// The merged composite as RGBA8. Supports RGB, grayscale, CMYK, indexed, - /// duotone (as gray) and bitmap at all depths. Alpha comes from the first - /// extra channel when [`PsdFile::merged_has_alpha`] is true. Removes the - /// white matte from RGB composites before reducing precision. - pub fn composite_rgba8(&self) -> Result { - let h = &self.header; - h.validate()?; - let (w, hh) = (h.width as usize, h.height as usize); - let n = w.checked_mul(hh).ok_or(PsdError::LimitExceeded("composite size overflow"))?; - // Same budget as `Layer::rgba8`: refuse before decoding or converting any plane. - if n.checked_mul(4).is_none_or(|len| len as u64 > crate::compression::MAX_DECODED_BYTES) { - return Err(PsdError::LimitExceeded("RGBA output exceeds MAX_DECODED_BYTES")); - } - let cc = color_channel_count(h.color_mode)?; - if usize::from(h.channels) < cc { - return Err(PsdError::invalid("fewer channels than the color mode requires")); - } - let all = self.decode_merged()?; - let plane = h.row_bytes().checked_mul(hh).ok_or(PsdError::LimitExceeded("plane size overflow"))?; - let alpha_bytes = if self.merged_has_alpha() { - Some(all.get(cc * plane..(cc + 1) * plane).ok_or_else(|| PsdError::invalid("alpha channel size mismatch"))?) - } else { - None - }; - let get = |i: usize| -> Result> { - let bytes = all.get(i * plane..(i + 1) * plane).ok_or_else(|| PsdError::invalid("channel size mismatch"))?; - if h.depth == 8 { Ok(Cow::Borrowed(bytes)) } else { plane_to_u8(bytes, h.depth, w, hh).map(Cow::Owned) } - }; - let mut color = Vec::with_capacity(cc); - for i in 0..cc { - color.push( - if h.color_mode == ColorMode::Rgb - && h.depth != 8 - && let Some(alpha) = alpha_bytes - { - let bytes = all.get(i * plane..(i + 1) * plane).ok_or_else(|| PsdError::invalid("channel size mismatch"))?; - Cow::Owned(unmatte_to_u8(bytes, alpha, h.depth)?) - } else { - get(i)? - }, - ); - } - let alpha = if self.merged_has_alpha() { Some(get(cc)?) } else { None }; - let mut data = interleave(h.color_mode, &color, alpha.as_deref(), &self.color_mode_data, n)?; - if h.color_mode == ColorMode::Rgb && h.depth == 8 && alpha.is_some() { - let table = unmatte_u8_table(); - for pixel in data.as_chunks_mut::<4>().0 { - let row = &table[usize::from(pixel[3])]; - for c in pixel.iter_mut().take(3) { - *c = row[usize::from(*c)]; - } - } - } - Ok(RgbaImage { left: 0, top: 0, width: h.width, height: h.height, data }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn float_composite_removes_white_matte_without_changing_native_data() { - let mut file = crate::testgen::merged_only(Version::Psd, ColorMode::Rgb, 32, crate::Compression::Raw, 1, 1); - file.header.channels = 4; - let native = f32_to_bytes(&[0.75, 0.875, 1.0, 0.25]); - file.image_data = crate::ImageData::encode(crate::Compression::Raw, &native, &file.header).unwrap(); - assert_eq!(file.composite_rgba8().unwrap().data, [0, 128, 255, 64]); - assert_eq!(file.decode_merged().unwrap(), native); - } - - /// #5694: the 8-bit RGB composite unmattes through a table; every (sample, alpha) pair comes - /// out exactly as the per-pixel division it replaced (kept here as the reference). - #[test] - fn rgba8_composite_unmattes_every_sample_and_alpha_as_the_division_did() { - let mut file = crate::testgen::merged_only(Version::Psd, ColorMode::Rgb, 8, crate::Compression::Raw, 256, 256); - file.header.channels = 4; - // Pixel (x, y): red x, green 255 - x, blue x ^ y, alpha y. - let (x, y) = (|i: usize| (i % 256) as u8, |i: usize| (i / 256) as u8); - let plane = |f: &dyn Fn(usize) -> u8| (0..256 * 256).map(f).collect::>(); - let planes = [plane(&|i| x(i)), plane(&|i| 255 - x(i)), plane(&|i| x(i) ^ y(i)), plane(&|i| y(i))]; - file.image_data = crate::ImageData::encode(crate::Compression::Raw, &planes.concat(), &file.header).unwrap(); - let mut expected: Vec = (0..256 * 256).flat_map(|i| planes.each_ref().map(|p| p[i])).collect(); - for pixel in expected.as_chunks_mut::<4>().0 { - let a = u32::from(pixel[3]); - for c in pixel.iter_mut().take(3) { - if let Some(straight) = ((u32::from(*c) + a).saturating_sub(255) * 255 + a / 2).checked_div(a) { - *c = straight.min(255) as u8; - } - } - } - assert_eq!(file.composite_rgba8().unwrap().data, expected); - } - - #[test] - fn oversized_layer_preview_rejects_missing_channels_before_allocating() { - let mut file = crate::testgen::small(Version::Psb, crate::Compression::Raw); - file.layers_mut().push(LayerRecord { rect: Rect::from_xywh(0, 0, 300000, 300000), ..Default::default() }); - assert!(matches!(file.iter_layers().last().unwrap().rgba8(), Err(PsdError::LimitExceeded(_)))); - } - - #[test] - fn sample_conversions() { - assert_eq!(samples_u16(&[0x12, 0x34, 0xff, 0xff]), vec![0x1234, 0xffff]); - assert_eq!(u16_to_bytes(&[0x1234]), vec![0x12, 0x34]); - let f = f32_to_bytes(&[0.5, -1.0]); - assert_eq!(samples_f32(&f), vec![0.5, -1.0]); - } - - #[test] - fn unpack_bits_rows() { - assert_eq!(unpack_bits(&[0b1010_0000, 0b1000_0000], 3, 2).unwrap(), vec![1, 0, 1, 1, 0, 0]); - } - - #[test] - fn plane_to_u8_depths() { - assert_eq!(plane_to_u8(&[0xff, 0xff, 0, 0, 0x80, 0x00], 16, 3, 1).unwrap(), vec![255, 0, 128]); - assert_eq!(plane_to_u8(&f32_to_bytes(&[0.0, 1.0, 2.0, -1.0, f32::NAN]), 32, 5, 1).unwrap(), vec![0, 255, 255, 0, 0]); - assert_eq!(plane_to_u8(&[0b1000_0000], 1, 2, 1).unwrap(), vec![0, 255]); - assert!(plane_to_u8(&[], 12, 0, 0).is_err()); - } - - /// A plane size that overflows or cannot be allocated is an error, not a panic or abort. - #[test] - fn plane_conversion_reports_absurd_sizes() { - assert!(matches!(unpack_bits(&[0], usize::MAX / 2, 4), Err(PsdError::LimitExceeded(_)))); - assert!(matches!(plane_to_u8(&[0], 1, usize::MAX / 2, 4), Err(PsdError::LimitExceeded(_)))); - assert!(matches!(unpack_bits(&[0], 1 << 40, 1 << 20), Err(PsdError::LimitExceeded(_) | PsdError::OutOfMemory(_)))); - } - - /// A bitmap composite whose RGBA output is over the decode budget is refused up front. - #[test] - fn composite_rgba8_checks_the_output_budget_first() { - let mut file = crate::testgen::small(Version::Psb, crate::Compression::Zip); - file.header.color_mode = ColorMode::Bitmap; - file.header.depth = 1; - file.header.channels = 1; - file.header.width = 60_000; - file.header.height = 60_000; - assert!(matches!(file.composite_rgba8(), Err(PsdError::LimitExceeded("RGBA output exceeds MAX_DECODED_BYTES")))); - } - - #[test] - fn rgb_interleave_checks_planes_and_preserves_alpha() { - let color = [Cow::Borrowed(&[1, 2][..]), Cow::Borrowed(&[3, 4][..]), Cow::Borrowed(&[5, 6][..])]; - assert_eq!(interleave(ColorMode::Rgb, &color, Some(&[7, 8]), &[], 2).unwrap(), [1, 3, 5, 7, 2, 4, 6, 8]); - assert_eq!(interleave(ColorMode::Rgb, &color, None, &[], 2).unwrap(), [1, 3, 5, 255, 2, 4, 6, 255]); - assert!(interleave(ColorMode::Rgb, &color[..2], None, &[], 2).is_err()); - assert!(interleave(ColorMode::Rgb, &color, Some(&[7]), &[], 2).is_err()); - } -} diff --git a/crates/psd/src/resources.rs b/crates/psd/src/resources.rs deleted file mode 100644 index 1ac69ed25d..0000000000 --- a/crates/psd/src/resources.rs +++ /dev/null @@ -1,404 +0,0 @@ -//! Image resources section (`8BIM` resource blocks). - -use crate::descriptor::UnicodeString; -use crate::error::{PsdError, Result}; -use crate::io::{Reader, WriteExt, read_pascal, write_pascal}; - -/// Well-known resource ids. -pub mod ids { - /// Resolution info (`ResolutionInfo` structure). - pub const RESOLUTION_INFO: u16 = 1005; - /// Target layer index. - pub const LAYER_STATE: u16 = 1024; - /// Layer group ids (one u16 per layer). - pub const LAYER_GROUP_INFO: u16 = 1026; - /// Legacy (Photoshop 4) thumbnail, BGR. - pub const THUMBNAIL_PS4: u16 = 1033; - /// Thumbnail (JFIF). - pub const THUMBNAIL: u16 = 1036; - /// Global lighting angle. - pub const GLOBAL_ANGLE: u16 = 1037; - /// ICC profile. - pub const ICC_PROFILE: u16 = 1039; - /// Global altitude. - pub const GLOBAL_ALTITUDE: u16 = 1049; - /// Version info (has real merged data flag). - pub const VERSION_INFO: u16 = 1057; - /// EXIF data 1. - pub const EXIF: u16 = 1058; - /// XMP metadata. - pub const XMP: u16 = 1060; - /// Document timeline settings (versioned descriptor). - pub const TIMELINE_INFO: u16 = 1075; -} - -/// One image resource block. -#[derive(Debug, Clone, PartialEq, Eq)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct ImageResource { - /// Signature, normally `8BIM` (also `MeSa`, `AgHg`, `PHUT`, `DCSR` seen in the wild). - pub signature: [u8; 4], - /// Resource id. - pub id: u16, - /// Pascal name bytes (usually empty). - pub name: Vec, - /// Resource data (without the even padding). - pub data: Vec, -} - -/// Fixed-point resolution info (resource 1005). -/// -/// `h_res_fixed` and `v_res_fixed` are always pixels per inch (16.16 fixed). -/// `h_res_unit` and `v_res_unit` only say how to display that number -/// (1 = pixels/inch, 2 = pixels/cm) and do not rescale the stored value. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct ResolutionInfo { - /// Horizontal resolution, 16.16 fixed point, always pixels per inch. - pub h_res_fixed: u32, - /// Display unit for the horizontal resolution (1 = px/in, 2 = px/cm). - pub h_res_unit: u16, - /// Ruler unit for width (1 in, 2 cm, 3 pt, 4 pica, 5 column). - pub width_unit: u16, - /// Vertical resolution, 16.16 fixed point, always pixels per inch. - pub v_res_fixed: u32, - /// Display unit for the vertical resolution (1 = px/in, 2 = px/cm). - pub v_res_unit: u16, - /// Ruler unit for height (1 in, 2 cm, 3 pt, 4 pica, 5 column). - pub height_unit: u16, -} - -impl ResolutionInfo { - /// Resolution info for `dpi` pixels per inch in both directions. - pub fn from_dpi(dpi: f64) -> Self { - let f = (dpi * 65536.0).round().clamp(0.0, u32::MAX as f64) as u32; - ResolutionInfo { h_res_fixed: f, h_res_unit: 1, width_unit: 1, v_res_fixed: f, v_res_unit: 1, height_unit: 1 } - } - /// Horizontal resolution in pixels per inch. - pub fn h_res(&self) -> f64 { - f64::from(self.h_res_fixed) / 65536.0 - } - /// Vertical resolution in pixels per inch. - pub fn v_res(&self) -> f64 { - f64::from(self.v_res_fixed) / 65536.0 - } - /// Parses 16 bytes. - pub fn from_bytes(b: &[u8]) -> Result { - let mut r = Reader::new(b); - Ok(ResolutionInfo { - h_res_fixed: r.u32()?, - h_res_unit: r.u16()?, - width_unit: r.u16()?, - v_res_fixed: r.u32()?, - v_res_unit: r.u16()?, - height_unit: r.u16()?, - }) - } - /// Serializes to 16 bytes. - pub fn to_bytes(&self) -> Vec { - let mut v = Vec::with_capacity(16); - v.put_u32(self.h_res_fixed); - v.put_u16(self.h_res_unit); - v.put_u16(self.width_unit); - v.put_u32(self.v_res_fixed); - v.put_u16(self.v_res_unit); - v.put_u16(self.height_unit); - v - } -} - -/// Version info (resource 1057). -#[derive(Debug, Clone, PartialEq, Eq)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct VersionInfo { - /// Version (1). - pub version: u32, - /// `false` when the merged image is a placeholder. - pub has_real_merged_data: bool, - /// Writer application name. - pub writer: UnicodeString, - /// Reader application name. - pub reader: UnicodeString, - /// File version. - pub file_version: u32, -} - -impl VersionInfo { - /// Parses resource data. - pub fn from_bytes(b: &[u8]) -> Result { - let mut r = Reader::new(b); - Ok(VersionInfo { - version: r.u32()?, - has_real_merged_data: r.u8()? != 0, - writer: UnicodeString::read(&mut r)?, - reader: UnicodeString::read(&mut r)?, - file_version: r.u32()?, - }) - } - /// Serializes. - pub fn to_bytes(&self) -> Vec { - let mut v = Vec::new(); - v.put_u32(self.version); - v.put_u8(u8::from(self.has_real_merged_data)); - self.writer.write(&mut v); - self.reader.write(&mut v); - v.put_u32(self.file_version); - v - } -} - -/// Typed view of a known resource. -#[derive(Debug, Clone, PartialEq)] -#[non_exhaustive] -pub enum ResourceData<'a> { - /// 1005 - ResolutionInfo(ResolutionInfo), - /// 1024: index of the target layer. - LayerState(u16), - /// 1026: group id per layer. - LayerGroupInfo(Vec), - /// 1036 / 1033: raw thumbnail resource (28-byte header + JFIF/raw data). - Thumbnail(&'a [u8]), - /// 1037: global lighting angle in degrees. - GlobalAngle(i32), - /// 1039: ICC profile bytes. - IccProfile(&'a [u8]), - /// 1049: global altitude. - GlobalAltitude(i32), - /// 1057 - VersionInfo(VersionInfo), - /// 1058: EXIF bytes. - Exif(&'a [u8]), - /// 1060: XMP packet (UTF-8). - Xmp(&'a [u8]), - /// 1075: document timeline settings; tracks and rational times remain descriptor data. - TimelineInfo(crate::VersionedDescriptor), - /// A `mani` legacy animation resource in the plug-in resource id range (4000..=4999). - Animation(crate::animation::AnimationResource<'a>), -} - -impl ImageResource { - /// A new `8BIM` resource with an empty name. - pub fn new(id: u16, data: Vec) -> Self { - ImageResource { signature: *b"8BIM", id, name: Vec::new(), data } - } - - /// Typed parse of well-known resources. Returns `None` for unknown ids, - /// `Some(Err)` if the data is malformed. - pub fn parsed(&self) -> Option>> { - let d = &self.data[..]; - let be_i32 = |d: &[u8]| -> Result { Reader::new(d).i32() }; - Some(match self.id { - ids::RESOLUTION_INFO => ResolutionInfo::from_bytes(d).map(ResourceData::ResolutionInfo), - ids::LAYER_STATE => Reader::new(d).u16().map(ResourceData::LayerState), - ids::LAYER_GROUP_INFO => { - if !d.len().is_multiple_of(2) { - Err(PsdError::invalid("layer group info has odd length")) - } else { - Ok(ResourceData::LayerGroupInfo(d.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]])).collect())) - } - } - ids::THUMBNAIL | ids::THUMBNAIL_PS4 => Ok(ResourceData::Thumbnail(d)), - ids::GLOBAL_ANGLE => be_i32(d).map(ResourceData::GlobalAngle), - ids::ICC_PROFILE => Ok(ResourceData::IccProfile(d)), - ids::GLOBAL_ALTITUDE => be_i32(d).map(ResourceData::GlobalAltitude), - ids::VERSION_INFO => VersionInfo::from_bytes(d).map(ResourceData::VersionInfo), - ids::EXIF => Ok(ResourceData::Exif(d)), - ids::XMP => Ok(ResourceData::Xmp(d)), - ids::TIMELINE_INFO => crate::animation::parse_descriptor(d).map(ResourceData::TimelineInfo), - 4000..=4999 if d.starts_with(b"mani") => crate::animation::AnimationResource::from_bytes(d).map(ResourceData::Animation), - _ => return None, - }) - } - - /// XMP packet as a string (resource 1060). - pub fn xmp_str(&self) -> Option<&str> { - (self.id == ids::XMP).then(|| std::str::from_utf8(&self.data).ok()).flatten() - } - - pub(crate) fn read(r: &mut Reader<'_>) -> Result { - let signature = r.array::<4>()?; - if !matches!(&signature, b"8BIM" | b"MeSa" | b"AgHg" | b"PHUT" | b"DCSR") { - return Err(PsdError::InvalidSignature { expected: "8BIM resource", found: signature }); - } - let id = r.u16()?; - let name = read_pascal(r, 2)?; - let len = r.u32()?; - let data = r.bytes_u64(u64::from(len))?.to_vec(); - if len % 2 == 1 { - r.skip(1)?; - } - Ok(ImageResource { signature, id, name, data }) - } - - pub(crate) fn write(&self, out: &mut Vec) -> Result<()> { - out.put(&self.signature); - out.put_u16(self.id); - write_pascal(out, &self.name, 2); - out.put_len(self.data.len() as u64, false)?; - out.put(&self.data); - if self.data.len() % 2 == 1 { - out.put_u8(0); - } - Ok(()) - } -} - -pub(crate) fn read_section(r: &mut Reader<'_>) -> Result> { - let len = r.u32()?; - let mut s = r.sub(u64::from(len))?; - let mut v = Vec::new(); - while !s.is_empty() { - v.push(ImageResource::read(&mut s)?); - } - Ok(v) -} - -/// Serialized size of the resource section [`write_section`] writes, its length field included. -pub(crate) fn section_len(res: &[ImageResource]) -> u64 { - res.iter().fold(4u64, |n, r| { - let name = 1 + r.name.len().min(255); - let name = name + name % 2; - let data = r.data.len().saturating_add(r.data.len() % 2); - n.saturating_add((4 + 2 + name + 4) as u64).saturating_add(data as u64) - }) -} - -pub(crate) fn write_section(out: &mut Vec, res: &[ImageResource]) -> Result<()> { - let at = out.begin_len(false); - for r in res { - r.write(out)?; - } - out.end_len(at, false) -} - -/// Builds the data for a 1057 version info resource, with the writer and reader names Photoshop -/// 2026 writes ("Adobe Photoshop", "Adobe Photoshop 2026"), without a NUL terminator. -pub fn version_info_resource(has_real_merged_data: bool) -> ImageResource { - ImageResource::new( - ids::VERSION_INFO, - VersionInfo { - version: 1, - has_real_merged_data, - writer: UnicodeString::new("Adobe Photoshop"), - reader: UnicodeString::new("Adobe Photoshop 2026"), - file_version: 1, - } - .to_bytes(), - ) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn roundtrip(res: &[ImageResource]) -> Vec { - let mut out = Vec::new(); - write_section(&mut out, res).unwrap(); - let back = read_section(&mut Reader::new(&out)).unwrap(); - assert_eq!(back, res); - out - } - - #[test] - fn empty_section() { - assert_eq!(roundtrip(&[]), vec![0, 0, 0, 0]); - } - - #[test] - fn odd_data_is_padded() { - let out = roundtrip(&[ImageResource::new(1000, vec![1, 2, 3])]); - // len(4) + sig(4) + id(2) + name(2) + size(4) + data(3) + pad(1) - assert_eq!(out.len(), 4 + 4 + 2 + 2 + 4 + 4); - assert_eq!(*out.last().unwrap(), 0); - } - - #[test] - fn named_resources() { - let mut r = ImageResource::new(2000, vec![9; 4]); - r.name = b"path".to_vec(); - let mut r2 = ImageResource::new(2001, vec![]); - r2.name = b"abc".to_vec(); - r2.signature = *b"MeSa"; - roundtrip(&[r, r2]); - } - - #[test] - fn bad_signature() { - let mut out = Vec::new(); - write_section(&mut out, &[ImageResource::new(1, vec![])]).unwrap(); - out[4] = b'X'; - assert!(read_section(&mut Reader::new(&out)).is_err()); - } - - #[test] - fn truncation_errors() { - let mut out = Vec::new(); - write_section(&mut out, &[ImageResource::new(1005, ResolutionInfo::from_dpi(72.0).to_bytes())]).unwrap(); - for cut in 0..out.len() { - assert!(read_section(&mut Reader::new(&out[..cut])).is_err()); - } - } - - #[test] - fn typed_resolution() { - let ri = ResolutionInfo::from_dpi(300.0); - let r = ImageResource::new(ids::RESOLUTION_INFO, ri.to_bytes()); - match r.parsed() { - Some(Ok(ResourceData::ResolutionInfo(p))) => { - assert_eq!(p, ri); - assert!((p.h_res() - 300.0).abs() < 1e-9); - assert!((p.v_res() - 300.0).abs() < 1e-9); - } - other => panic!("{other:?}"), - } - } - - #[test] - fn typed_misc() { - let r = ImageResource::new(ids::GLOBAL_ANGLE, 120i32.to_be_bytes().to_vec()); - assert_eq!(r.parsed(), Some(Ok(ResourceData::GlobalAngle(120)))); - let r = ImageResource::new(ids::GLOBAL_ALTITUDE, 30i32.to_be_bytes().to_vec()); - assert_eq!(r.parsed(), Some(Ok(ResourceData::GlobalAltitude(30)))); - let r = ImageResource::new(ids::LAYER_STATE, vec![0, 2]); - assert_eq!(r.parsed(), Some(Ok(ResourceData::LayerState(2)))); - let r = ImageResource::new(ids::LAYER_GROUP_INFO, vec![0, 1, 0, 2]); - assert_eq!(r.parsed(), Some(Ok(ResourceData::LayerGroupInfo(vec![1, 2])))); - let r = ImageResource::new(ids::LAYER_GROUP_INFO, vec![0]); - assert!(matches!(r.parsed(), Some(Err(_)))); - let r = ImageResource::new(ids::ICC_PROFILE, vec![1]); - assert_eq!(r.parsed(), Some(Ok(ResourceData::IccProfile(&[1])))); - let r = ImageResource::new(ids::EXIF, vec![2]); - assert_eq!(r.parsed(), Some(Ok(ResourceData::Exif(&[2])))); - let r = ImageResource::new(ids::THUMBNAIL, vec![3]); - assert_eq!(r.parsed(), Some(Ok(ResourceData::Thumbnail(&[3])))); - let r = ImageResource::new(ids::XMP, b"".to_vec()); - assert_eq!(r.xmp_str(), Some("")); - assert_eq!(ImageResource::new(4242, vec![]).parsed(), None); - } - - #[test] - fn version_info_roundtrip() { - let r = version_info_resource(false); - match r.parsed() { - Some(Ok(ResourceData::VersionInfo(v))) => { - assert!(!v.has_real_merged_data); - assert_eq!(v.to_bytes(), r.data); - } - other => panic!("{other:?}"), - } - } - - /// Byte for byte what Photoshop 2026 writes (every Photoshop-authored oracle in the corpus): - /// u32 count + UTF-16BE, no NUL terminator. - #[test] - fn version_info_matches_photoshop_bytes() { - let mut want = vec![0, 0, 0, 1, 1]; - for s in ["Adobe Photoshop", "Adobe Photoshop 2026"] { - want.extend_from_slice(&(s.len() as u32).to_be_bytes()); - want.extend(s.encode_utf16().flat_map(u16::to_be_bytes)); - } - want.extend_from_slice(&[0, 0, 0, 1]); - assert_eq!(version_info_resource(true).data, want); - } -} diff --git a/crates/psd/src/rows.rs b/crates/psd/src/rows.rs deleted file mode 100644 index f5f95f74d4..0000000000 --- a/crates/psd/src/rows.rs +++ /dev/null @@ -1,234 +0,0 @@ -//! Decoding a channel a band of rows at a time: [`ChannelData::row_decoder`]. -//! -//! [`ChannelData::decode`] returns the whole plane, so turning a layer into tiles holds every -//! channel's plane plus an interleaved copy at once: for a 20000×15000 RGBA layer that is 2.4 GB -//! on top of the tiles. A [`RowDecoder`] hands out any band of rows instead. RLE and raw channels -//! are read band by band (a channel still in its [`Stored`](crate::Stored) source reads only the -//! band's bytes: RLE's per-row byte counts give each row's offset); ZIP streams cannot be entered -//! part way, so a ZIP channel is inflated once, when the decoder is made. - -use std::borrow::Cow; - -use crate::compression::{Compression, MAX_DECODED_BYTES, PlaneLayout, decode_planes, packbits}; -use crate::error::{PsdError, Result}; -use crate::header::Version; -use crate::layer::ChannelData; -use crate::source::Stored; - -/// Where a channel's encoded bytes are. -enum Bytes<'a> { - Memory(&'a [u8]), - Source(&'a Stored), -} - -impl Bytes<'_> { - fn len(&self) -> u64 { - match self { - Bytes::Memory(b) => b.len() as u64, - Bytes::Source(s) => s.len, - } - } - - fn get(&self, at: u64, len: u64) -> Result> { - match self { - Bytes::Memory(b) => { - let range = usize::try_from(at).ok().zip(usize::try_from(len).ok()).and_then(|(a, n)| b.get(a..a.checked_add(n)?)); - range - .map(Cow::Borrowed) - .ok_or(PsdError::UnexpectedEof { offset: usize::try_from(at).unwrap_or(usize::MAX), needed: usize::try_from(len).unwrap_or(usize::MAX) }) - } - Bytes::Source(s) => { - if at.checked_add(len).is_none_or(|end| end > s.len) { - return Err(PsdError::UnexpectedEof { - offset: usize::try_from(at).unwrap_or(usize::MAX), - needed: usize::try_from(len).unwrap_or(usize::MAX), - }); - } - s.read_part(at, len).map(Cow::Owned) - } - } - } -} - -enum Kind<'a> { - /// Rows at `row * row_bytes`. - Raw(Bytes<'a>), - /// `offsets[r]..offsets[r + 1]` are row `r`'s PackBits bytes (offsets from the start of the - /// data, after the count table). - Rle { bytes: Bytes<'a>, offsets: Vec }, - /// The whole decoded plane. - Decoded(Vec), - /// No data and no rows. - Empty, -} - -/// Decodes bands of rows of one channel: see the [module docs](self). -pub struct RowDecoder<'a> { - kind: Kind<'a>, - row_bytes: usize, - height: usize, -} - -impl ChannelData { - /// A decoder of this channel's rows, for a `width × height` plane. Fails where - /// [`Self::decode`] would on everything it can check without decoding the rows (sizes, the - /// RLE count table, the whole of a ZIP stream); a damaged row fails when its band is decoded. - pub fn row_decoder(&self, width: usize, height: usize, depth: u16, version: Version) -> Result> { - let layout = PlaneLayout { planes: 1, width, height, depth, version }; - let total = layout.decoded_len()?; - let row_bytes = layout.row_bytes(); - let bytes = match &self.stored { - Some(s) => Bytes::Source(s), - None => Bytes::Memory(&self.data), - }; - let kind = match self.compression { - None if total == 0 => Kind::Empty, - None => return Err(PsdError::invalid("channel has no data")), - Some(Compression::Raw) => { - if bytes.len() < total as u64 { - return Err(PsdError::UnexpectedEof { offset: usize::try_from(bytes.len()).unwrap_or(usize::MAX), needed: total - bytes.len() as usize }); - } - Kind::Raw(bytes) - } - Some(Compression::Rle) => { - let size: u64 = if version.is_psb() { 4 } else { 2 }; - let table_len = (height as u64).saturating_mul(size); - if table_len > bytes.len() { - return Err(PsdError::UnexpectedEof { offset: 0, needed: usize::try_from(table_len - bytes.len()).unwrap_or(usize::MAX) }); - } - let table = bytes.get(0, table_len)?; - let mut offsets = Vec::new(); - offsets.try_reserve_exact(height + 1).map_err(|_| PsdError::OutOfMemory("not enough memory for the RLE row table"))?; - let mut at = table_len; - offsets.push(at); - for c in table.chunks_exact(size as usize) { - let n = match *c { - [a, b] => u64::from(u16::from_be_bytes([a, b])), - [a, b, c, d] => u64::from(u32::from_be_bytes([a, b, c, d])), - _ => 0, - }; - at = at.saturating_add(n); - offsets.push(at); - } - let data_len = bytes.len() - table_len; - // As `decode_planes` checks: PackBits expands at most 64x. - if (total as u64) > data_len.saturating_mul(64) { - return Err(PsdError::Decompress("RLE data too short for declared size".into())); - } - if at > bytes.len() { - return Err(PsdError::UnexpectedEof { - offset: usize::try_from(bytes.len()).unwrap_or(usize::MAX), - needed: usize::try_from(at - bytes.len()).unwrap_or(usize::MAX), - }); - } - Kind::Rle { bytes, offsets } - } - Some(c) => Kind::Decoded(decode_planes(c, &self.encoded()?, &layout)?), - }; - Ok(RowDecoder { kind, row_bytes, height }) - } -} - -impl RowDecoder<'_> { - /// Bytes per decoded row. - pub fn row_bytes(&self) -> usize { - self.row_bytes - } - - /// Number of rows. - pub fn height(&self) -> usize { - self.height - } - - /// Rows `r0..r1` as planar big-endian samples (`(r1 - r0) × row_bytes` bytes). - pub fn rows(&self, r0: usize, r1: usize) -> Result> { - if r0 > r1 || r1 > self.height { - return Err(PsdError::invalid(format!("rows {r0}..{r1} outside 0..{}", self.height))); - } - let n = (r1 - r0).checked_mul(self.row_bytes).ok_or(PsdError::LimitExceeded("band size overflow"))?; - if n as u64 > MAX_DECODED_BYTES { - return Err(PsdError::LimitExceeded("decoded channel data exceeds MAX_DECODED_BYTES")); - } - let mut out = Vec::new(); - out.try_reserve_exact(n).map_err(|_| PsdError::OutOfMemory("not enough memory for the decoded channel data"))?; - match &self.kind { - Kind::Empty => {} - Kind::Raw(bytes) => out.extend_from_slice(&bytes.get((r0 * self.row_bytes) as u64, n as u64)?), - Kind::Decoded(plane) => { - out.extend_from_slice(plane.get(r0 * self.row_bytes..r1 * self.row_bytes).ok_or_else(|| PsdError::invalid("decoded plane too short"))?) - } - Kind::Rle { bytes, offsets } => { - let (Some(&start), Some(&end)) = (offsets.get(r0), offsets.get(r1)) else { - return Err(PsdError::invalid("RLE row table too short")); - }; - let band = bytes.get(start, end - start)?; - for r in r0..r1 { - let (Some(&a), Some(&b)) = (offsets.get(r), offsets.get(r + 1)) else { - return Err(PsdError::invalid("RLE row table too short")); - }; - let row = band.get((a - start) as usize..(b - start) as usize).ok_or_else(|| PsdError::invalid("RLE row outside its band"))?; - packbits::decode_into(row, self.row_bytes, &mut out)?; - } - } - } - Ok(out) - } -} - -#[cfg(test)] -mod tests { - use crate::source::Source; - use crate::{PsdFile, testgen}; - - /// Every channel of every generated file, decoded in uneven bands from memory and from a - /// source, is its whole-plane decode. - #[test] - fn bands_join_up_to_the_whole_plane() { - let mut checked = 0; - for case in testgen::all_cases() { - let bytes = case.file.to_bytes().unwrap(); - let eager = PsdFile::from_bytes(&bytes).unwrap(); - let lazy = PsdFile::from_source(Source::new(bytes)).unwrap(); - let (d, v) = (eager.header.depth, eager.header.version); - for file in [&eager, &lazy] { - for rec in file.layers() { - for ch in &rec.channels { - let (w, h) = rec.channel_rect(ch.id).size().unwrap(); - let whole = ch.decode(w, h, d, v); - let dec = ch.row_decoder(w, h, d, v); - let (Ok(whole), Ok(dec)) = (whole, dec) else { continue }; - let mut joined = Vec::new(); - let mut r = 0; - for step in [1, 3, 2, 7].iter().cycle() { - if r >= h { - break; - } - let r1 = (r + step).min(h); - joined.extend(dec.rows(r, r1).unwrap()); - r = r1; - } - assert_eq!(joined, whole, "{}", case.name); - assert!(dec.rows(0, h + 1).is_err()); - checked += 1; - } - } - } - } - assert!(checked > 100, "{checked}"); - } - - #[test] - fn a_damaged_rle_row_fails_its_band() { - use crate::Compression; - use crate::layer::ChannelData; - // Two rows of 4 bytes: a valid row, then one that ends early. - let mut data = vec![0, 2, 0, 2]; - data.extend([0xFD, 7]); // header -3: 1 - (-3) = 4 copies of 7 - data.extend([0x01, 9]); // a literal of 2 bytes, only 1 present - let ch = ChannelData::new(0, Some(Compression::Rle), data); - let dec = ch.row_decoder(4, 2, 8, crate::Version::Psd).unwrap(); - assert_eq!(dec.rows(0, 1).unwrap(), vec![7; 4]); - assert!(dec.rows(1, 2).is_err()); - assert!(ch.decode(4, 2, 8, crate::Version::Psd).is_err()); - } -} diff --git a/crates/psd/src/shc.rs b/crates/psd/src/shc.rs deleted file mode 100644 index 135fd33e62..0000000000 --- a/crates/psd/src/shc.rs +++ /dev/null @@ -1,251 +0,0 @@ -//! Photoshop contour preset files (`.shc`), the Layer Style dialog's Contour Editor Load/Save. -//! -//! Layout from Adobe's public "Photoshop File Formats Specification" (Additional File Formats › -//! Contours): a 4-byte type `8BFS`, a `u16` version (1) and a `u32` count of contours. Each -//! contour is a `u32` version (1 or 2), a Unicode name, a `u16` point count and the points as two -//! `u16`s each (vertical first, then horizontal). Version 2 then has one boolean byte per point -//! ("whether the point is continuous"); both versions end with a 4-byte minimum and maximum -//! input range. All values are big-endian. -//! -//! The spec leaves two details open, handled defensively: -//! - The name is read like the spec's other Unicode strings (a `u32` UTF-16 code-unit count that -//! includes a trailing null, as in `.aco`); a name whose count leaves out the null and is -//! followed by one is read too. Names are written with the null counted. -//! - Coordinates are read as 0..=255 levels, the scale of the layer-style contour descriptors -//! (`ShpC` points); the input range is written as 0..=255. - -use crate::error::{PsdError, Result}; -use crate::io::{Reader, WriteExt}; - -/// Most contours read from one file. -pub const MAX_CONTOURS: usize = 4096; -/// Most points in one contour (the counts are 16-bit; Photoshop allows far fewer). -pub const MAX_POINTS: usize = 256; -/// Longest name read or written, in UTF-16 code units. -pub const MAX_NAME_UNITS: usize = 1024; - -/// One contour point: input (horizontal) and output (vertical), `0..=255`, and whether the curve -/// passes smoothly through it (`false` = a corner). -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct ShcPoint { - /// Input level, `0..=255`. - pub input: f32, - /// Output level, `0..=255`. - pub output: f32, - /// Smooth (`true`) or corner (`false`). - pub continuous: bool, -} - -/// One named contour. -#[derive(Debug, Clone, PartialEq)] -pub struct ShcContour { - /// Preset name. - pub name: String, - /// Points, in file order. - pub points: Vec, -} - -fn read_name(r: &mut Reader<'_>) -> Result { - let n = u64::from(r.u32()?); - r.check_count(n, 2)?; - let n = usize::try_from(n).map_err(|_| PsdError::LimitExceeded("contour name length"))?; - let mut units = Vec::with_capacity(n.min(MAX_NAME_UNITS)); - // The last unit read, not the last one kept: past the cap the counted null is read but not kept. - let mut last = None; - for _ in 0..n { - let u = r.u16()?; - last = Some(u); - if units.len() < MAX_NAME_UNITS { - units.push(u); - } - } - // A count that leaves out the terminator: the null follows. - if last != Some(0) && r.peek_rest().get(..2) == Some(&[0, 0]) { - r.u16()?; - } - while units.last() == Some(&0) { - units.pop(); - } - Ok(String::from_utf16_lossy(&units)) -} - -/// Parse an `.shc` file. Bad signatures, unknown versions, truncation and impossible counts are -/// errors; never panics. -pub fn parse(data: &[u8]) -> Result> { - let mut r = Reader::new(data); - let sig = r.array::<4>()?; - if &sig != b"8BFS" { - return Err(PsdError::InvalidSignature { expected: "8BFS", found: sig }); - } - let version = r.u16()?; - if version != 1 { - return Err(PsdError::UnsupportedVersion(version)); - } - let count = u64::from(r.u32()?); - // A contour takes at least 4 (version) + 4 (name) + 2 (points) + 8 (range) bytes. - r.check_count(count, 18)?; - let count = usize::try_from(count).map_err(|_| PsdError::LimitExceeded("contour count"))?; - if count > MAX_CONTOURS { - return Err(PsdError::LimitExceeded("too many contours in an .shc file")); - } - let mut out = Vec::with_capacity(count); - for _ in 0..count { - let v = r.u32()?; - if v != 1 && v != 2 { - return Err(PsdError::invalid(format!("unknown contour version {v}"))); - } - let name = read_name(&mut r)?; - let n = usize::from(r.u16()?); - r.check_count(n as u64, if v == 2 { 5 } else { 4 })?; - if n > MAX_POINTS { - return Err(PsdError::LimitExceeded("too many points in a contour")); - } - let mut points = Vec::with_capacity(n); - for _ in 0..n { - let output = f32::from(r.u16()?).min(255.0); - let input = f32::from(r.u16()?).min(255.0); - points.push(ShcPoint { input, output, continuous: true }); - } - if v == 2 { - for p in &mut points { - p.continuous = r.u8()? != 0; - } - } - // Minimum and maximum input range: not needed to draw the curve. - r.skip(8)?; - out.push(ShcContour { name, points }); - } - Ok(out) -} - -/// Write an `.shc` file (every contour as version 2, with its corner flags). Fails when a contour -/// has more than [`MAX_POINTS`] points or there are more than [`MAX_CONTOURS`]. -pub fn write(contours: &[ShcContour]) -> Result> { - if contours.len() > MAX_CONTOURS { - return Err(PsdError::LimitExceeded("too many contours for an .shc file")); - } - let mut out = Vec::with_capacity(10 + contours.len() * 64); - out.put(b"8BFS"); - out.put_u16(1); - out.put_u32(contours.len() as u32); - for c in contours { - let n = u16::try_from(c.points.len()).ok().filter(|n| usize::from(*n) <= MAX_POINTS).ok_or(PsdError::LimitExceeded("too many points in a contour"))?; - out.put_u32(2); - // Capped on a character boundary, so a surrogate pair is never split. - let units = crate::io::utf16_units(&c.name, MAX_NAME_UNITS); - out.put_u32(units.len() as u32 + 1); - for u in units { - out.put_u16(u); - } - out.put_u16(0); - out.put_u16(n); - let level = |v: f32| if v.is_finite() { v.round().clamp(0.0, 255.0) as u16 } else { 0 }; - for p in &c.points { - out.put_u16(level(p.output)); - out.put_u16(level(p.input)); - } - for p in &c.points { - out.put_u8(u8::from(p.continuous)); - } - out.put_u32(0); - out.put_u32(255); - } - Ok(out) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn shc_round_trips_and_rejects_hostile_input() { - let cone = ShcContour { - name: "Cone".into(), - points: vec![ - ShcPoint { input: 0.0, output: 0.0, continuous: true }, - ShcPoint { input: 128.0, output: 255.0, continuous: false }, - ShcPoint { input: 255.0, output: 0.0, continuous: true }, - ], - }; - let bytes = write(std::slice::from_ref(&cone)).unwrap(); - assert_eq!(&bytes[..4], b"8BFS"); - assert_eq!(parse(&bytes).unwrap(), vec![cone.clone()]); - - // Version 1 without corner flags, with a name count that leaves out the null. - let mut v1 = b"8BFS".to_vec(); - v1.put_u16(1); - v1.put_u32(1); - v1.put_u32(1); - v1.put_u32(2); - v1.put_u16(u16::from(b'O')); - v1.put_u16(u16::from(b'K')); - v1.put_u16(0); - v1.put_u16(2); - for v in [0u16, 0, 255, 255] { - v1.put_u16(v); - } - v1.put_u32(0); - v1.put_u32(255); - let c = parse(&v1).unwrap(); - assert_eq!(c[0].name, "OK"); - assert!(c[0].points.iter().all(|p| p.continuous) && c[0].points[1].input == 255.0); - - // Truncations, a wrong signature and absurd counts are errors, never panics. - for cut in 0..bytes.len() { - assert!(parse(&bytes[..cut]).is_err(), "cut at {cut}"); - } - assert!(parse(b"8BFX\0\x01\0\0\0\0").is_err()); - assert!(parse(b"8BFS\0\x01\xff\xff\xff\xff").is_err()); - let many = ShcContour { name: String::new(), points: vec![ShcPoint { input: 0.0, output: 0.0, continuous: true }; MAX_POINTS + 1] }; - assert!(write(&[many]).is_err()); - } - - fn pts(n: usize) -> Vec { - (0..n).map(|i| ShcPoint { input: (i * 10) as f32, output: (i * 20) as f32, continuous: i % 2 == 0 }).collect() - } - - #[test] - fn shc_name_cap_never_splits_a_surrogate_pair() { - let name = format!("{}\u{1F600}", "a".repeat(MAX_NAME_UNITS - 1)); - let c = ShcContour { name, points: pts(2) }; - let back = parse(&write(&[c]).unwrap()).unwrap(); - assert_eq!(back[0].name, "a".repeat(MAX_NAME_UNITS - 1)); - } - - #[test] - fn shc_names_at_and_over_the_cap_keep_the_stream_in_sync() { - for len in [MAX_NAME_UNITS, MAX_NAME_UNITS + 1, MAX_NAME_UNITS * 3] { - for first_points in [0, 3] { - let long = ShcContour { name: "b".repeat(len), points: pts(first_points) }; - let next = ShcContour { name: "Next".into(), points: pts(4) }; - let back = parse(&write(&[long, next.clone()]).unwrap()).unwrap_or_else(|e| panic!("len {len}, points {first_points}: {e}")); - assert_eq!(back.len(), 2); - assert_eq!(back[0].name, "b".repeat(len.min(MAX_NAME_UNITS))); - assert_eq!(back[0].points.len(), first_points); - assert_eq!(back[1], next, "len {len}, points {first_points}"); - } - } - } - - #[test] - fn shc_name_count_without_null_at_the_cap_still_reads() { - // A hostile/foreign file: 1024 units counted, no null counted, null follows. - let mut v = b"8BFS".to_vec(); - v.put_u16(1); - v.put_u32(1); - v.put_u32(1); - v.put_u32(MAX_NAME_UNITS as u32); - for _ in 0..MAX_NAME_UNITS { - v.put_u16(u16::from(b'c')); - } - v.put_u16(0); - v.put_u16(1); - v.put_u16(7); - v.put_u16(9); - v.put_u32(0); - v.put_u32(255); - let c = parse(&v).unwrap(); - assert_eq!(c[0].name, "c".repeat(MAX_NAME_UNITS)); - assert_eq!((c[0].points[0].input, c[0].points[0].output), (9.0, 7.0)); - } -} diff --git a/crates/psd/src/slices.rs b/crates/psd/src/slices.rs deleted file mode 100644 index 5d9cd50b39..0000000000 --- a/crates/psd/src/slices.rs +++ /dev/null @@ -1,531 +0,0 @@ -//! Slices image resource (id 1050), from the Adobe Photoshop File Format Specification. -//! -//! Version 6 is a fixed binary layout (bounding rect, group name, then per slice: ids, origin, -//! optional layer id, name, type, rect, URL / target / message / alt strings, cell text and -//! alignment, background colour), optionally followed by a versioned descriptor carrying extra -//! per-slice data (layer-slice outsets). Versions 7 and 8 store everything as one versioned -//! descriptor (`baseName`, `bounds`, `slices` list). -//! -//! [`SlicesResource::to_bytes`] writes version 6, the documented binary layout every Photoshop -//! version reads. Unknown fields are not kept here; callers that need a byte-exact round trip -//! keep the raw resource while the slices are unchanged. - -use std::collections::HashMap; - -use crate::descriptor::{Descriptor, Id, UnicodeString, Value, VersionedDescriptor}; -use crate::error::{PsdError, Result}; -use crate::io::{Reader, WriteExt}; - -/// Resource id of the slices resource. -pub const SLICES: u16 = 1050; - -/// One slice record. -#[derive(Debug, Clone, PartialEq, Eq, Default)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct SliceRecord { - /// Slice id. - pub id: u32, - /// Group id. - pub group_id: u32, - /// Origin: 0 auto-generated, 1 layer-based, 2 user-drawn. - pub origin: u32, - /// Layer id of a layer-based slice (the layer record's `lyid`). - pub layer_id: Option, - /// Slice name. - pub name: String, - /// Type: 0 no image, 1 image, 2 table. - pub kind: u32, - /// Left, top, right, bottom in document pixels. - pub rect: [i32; 4], - /// URL. - pub url: String, - /// Target frame. - pub target: String, - /// Status-bar message. - pub message: String, - /// Alt tag. - pub alt: String, - /// Cell text is HTML. - pub cell_text_is_html: bool, - /// Cell text. - pub cell_text: String, - /// Horizontal alignment. - pub horizontal_align: u32, - /// Vertical alignment. - pub vertical_align: u32, - /// Background colour as alpha, red, green, blue. - pub color: [u8; 4], - /// Layer-based slice outsets: top, left, bottom, right. - pub outsets: [i32; 4], -} - -/// The whole slices resource. -#[derive(Debug, Clone, PartialEq, Eq, Default)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct SlicesResource { - /// Version read (6, 7 or 8); writing always produces 6. - pub version: u32, - /// Bounding rectangle of all slices: top, left, bottom, right. - pub bounds: [i32; 4], - /// Name of the slice group (the base name). - pub group_name: String, - /// Slices, including auto-generated ones as Photoshop saves them. - pub slices: Vec, -} - -fn ustr(r: &mut Reader<'_>) -> Result { - Ok(UnicodeString::read(r)?.to_string_lossy()) -} - -fn put_ustr(out: &mut Vec, s: &str) { - UnicodeString::new(s).write(out); -} - -// ---------- descriptor helpers (versions 7/8 and the v6 tail) ---------- - -/// A number as `i64`; float casts saturate (NaN reads as 0). -fn d_int(d: &Descriptor, key: &str) -> Option { - match d.get(key)? { - Value::Integer(v) => Some(i64::from(*v)), - Value::LargeInteger(v) => Some(*v), - Value::Double(v) => Some(*v as i64), - Value::UnitFloat { value, .. } => Some(*value as i64), - _ => None, - } -} - -/// A coordinate, clamped to the `i32` range instead of wrapping. -fn d_i32(d: &Descriptor, key: &str) -> Option { - d_int(d, key).map(|v| v.clamp(i64::from(i32::MIN), i64::from(i32::MAX)) as i32) -} - -/// An id. A 32-bit `long` holds the id's bits (ids past `i32::MAX` are written negative); any -/// other number outside the `u32` range is no id at all rather than a wrapped one. -fn d_u32(d: &Descriptor, key: &str) -> Option { - match d.get(key)? { - Value::Integer(v) => Some(*v as u32), - _ => u32::try_from(d_int(d, key)?).ok(), - } -} - -fn d_text(d: &Descriptor, key: &str) -> String { - match d.get(key) { - Some(Value::Text(t)) => t.to_string_lossy(), - _ => String::new(), - } -} - -fn d_enum(d: &Descriptor, key: &str) -> Option { - match d.get(key)? { - Value::Enumerated { value, .. } => Some(String::from_utf8_lossy(value.as_bytes()).into_owned()), - _ => None, - } -} - -fn d_bool(d: &Descriptor, key: &str) -> bool { - matches!(d.get(key), Some(Value::Boolean(true))) -} - -fn d_bounds(d: &Descriptor, key: &str) -> Option<[i32; 4]> { - let Value::Descriptor(b) = d.get(key)? else { return None }; - let g = |k: &str| d_i32(b, k).unwrap_or(0); - // top, left, bottom, right - Some([g("Top "), g("Left"), g("Btom"), g("Rght")]) -} - -fn origin_from(s: &str) -> u32 { - match s { - "autoGenerated" => 0, - "layerGenerated" | "layer" => 1, - _ => 2, - } -} - -fn kind_from(s: &str) -> u32 { - match s { - "noImage" => 0, - "Tbl " | "table" => 2, - _ => 1, - } -} - -fn slice_from_descriptor(d: &Descriptor) -> SliceRecord { - let b = d_bounds(d, "bounds").unwrap_or_default(); - let color = match (d_enum(d, "bgColorType").as_deref(), d.get("bgColor")) { - (Some("Clr "), Some(Value::Descriptor(c))) => { - let g = |k: &str| d_int(c, k).unwrap_or(0).clamp(0, 255) as u8; - let a = if c.get("alpha").is_some() { g("alpha") } else { 255 }; - [a, g("Rd "), g("Grn "), g("Bl ")] - } - _ => [0; 4], - }; - SliceRecord { - id: d_u32(d, "sliceID").unwrap_or(0), - group_id: d_u32(d, "groupID").unwrap_or(0), - origin: d_enum(d, "origin").map_or(2, |s| origin_from(&s)), - layer_id: d_u32(d, "layerID"), - name: d_text(d, "Nm "), - kind: d_enum(d, "Type").map_or(1, |s| kind_from(&s)), - // Descriptor bounds are top/left/bottom/right; records use left/top/right/bottom. - rect: [b[1], b[0], b[3], b[2]], - url: d_text(d, "url"), - target: d_text(d, "null"), - message: d_text(d, "Msge"), - alt: d_text(d, "altTag"), - cell_text_is_html: d_bool(d, "cellTextIsHTML"), - cell_text: d_text(d, "cellText"), - horizontal_align: 0, - vertical_align: 0, - color, - outsets: [ - d_i32(d, "topOutset").unwrap_or(0), - d_i32(d, "leftOutset").unwrap_or(0), - d_i32(d, "bottomOutset").unwrap_or(0), - d_i32(d, "rightOutset").unwrap_or(0), - ], - } -} - -fn bounds_descriptor(tlbr: [i32; 4]) -> Value { - Value::Descriptor( - Descriptor::new("Rct1") - .with("Top ", Value::Integer(tlbr[0])) - .with("Left", Value::Integer(tlbr[1])) - .with("Btom", Value::Integer(tlbr[2])) - .with("Rght", Value::Integer(tlbr[3])), - ) -} - -fn enumerated(ty: &str, v: &str) -> Value { - Value::Enumerated { type_id: Id::new(ty), value: Id::new(v) } -} - -impl SlicesResource { - /// Parses resource data (versions 6, 7 and 8). - pub fn from_bytes(data: &[u8]) -> Result { - let mut r = Reader::new(data); - let version = r.u32()?; - match version { - 6 => Self::read_v6(&mut r), - 7 | 8 => { - let vd = VersionedDescriptor::read(&mut r)?; - let d = &vd.descriptor; - let slices = match d.get("slices") { - Some(Value::List(items)) => items - .iter() - .filter_map(|v| match v { - Value::Descriptor(s) => Some(slice_from_descriptor(s)), - _ => None, - }) - .collect(), - _ => Vec::new(), - }; - Ok(SlicesResource { version, bounds: d_bounds(d, "bounds").unwrap_or_default(), group_name: d_text(d, "baseName"), slices }) - } - v => Err(PsdError::invalid(format!("unsupported slices resource version {v}"))), - } - } - - fn read_v6(r: &mut Reader<'_>) -> Result { - let bounds = [r.i32()?, r.i32()?, r.i32()?, r.i32()?]; - let group_name = ustr(r)?; - let n = r.u32()? as usize; - if n > r.remaining() / 16 { - return Err(PsdError::invalid("slice count exceeds the resource size")); - } - let mut slices = Vec::with_capacity(n); - for _ in 0..n { - let id = r.u32()?; - let group_id = r.u32()?; - let origin = r.u32()?; - let layer_id = if origin == 1 { Some(r.u32()?) } else { None }; - let name = ustr(r)?; - let kind = r.u32()?; - let rect = [r.i32()?, r.i32()?, r.i32()?, r.i32()?]; - let url = ustr(r)?; - let target = ustr(r)?; - let message = ustr(r)?; - let alt = ustr(r)?; - let cell_text_is_html = r.u8()? != 0; - let cell_text = ustr(r)?; - let horizontal_align = r.u32()?; - let vertical_align = r.u32()?; - let color = [r.u8()?, r.u8()?, r.u8()?, r.u8()?]; - slices.push(SliceRecord { - id, - group_id, - origin, - layer_id, - name, - kind, - rect, - url, - target, - message, - alt, - cell_text_is_html, - cell_text, - horizontal_align, - vertical_align, - color, - outsets: [0; 4], - }); - } - // Optional trailing descriptor (Photoshop 7+): per-slice extras such as outsets. - if r.remaining() >= 4 - && let Ok(vd) = VersionedDescriptor::read(r) - && let Some(Value::List(items)) = vd.descriptor.get("slices") - { - // Index each slice ID to its first occurrence once, so matching trailing - // descriptors against slices is linear rather than quadratic. Both the slice - // count and the descriptor-ID list are file-controlled, so a plain - // `find()` per descriptor is O(n*m) and lets a crafted resource burn CPU. - // `or_insert` preserves the original first-match behavior for duplicate IDs. - let mut id_to_index: HashMap = HashMap::with_capacity(slices.len()); - for (i, s) in slices.iter().enumerate() { - id_to_index.entry(s.id).or_insert(i); - } - for v in items { - let Value::Descriptor(d) = v else { continue }; - // An entry without a usable id belongs to no slice (not to slice 0). - let Some(id) = d_u32(d, "sliceID") else { continue }; - let extra = slice_from_descriptor(d); - if let Some(&i) = id_to_index.get(&id) { - slices[i].outsets = extra.outsets; - } - } - } - Ok(SlicesResource { version: 6, bounds, group_name, slices }) - } - - /// Serializes as version 6. A trailing descriptor with the outsets is added when any slice - /// has them, the way Photoshop extends the version 6 layout. - pub fn to_bytes(&self) -> Vec { - let mut out = Vec::new(); - out.put_u32(6); - for v in self.bounds { - out.put_i32(v); - } - put_ustr(&mut out, &self.group_name); - out.put_u32(self.slices.len() as u32); - for s in &self.slices { - out.put_u32(s.id); - out.put_u32(s.group_id); - out.put_u32(s.origin); - if s.origin == 1 { - out.put_u32(s.layer_id.unwrap_or(0)); - } - put_ustr(&mut out, &s.name); - out.put_u32(s.kind); - for v in s.rect { - out.put_i32(v); - } - put_ustr(&mut out, &s.url); - put_ustr(&mut out, &s.target); - put_ustr(&mut out, &s.message); - put_ustr(&mut out, &s.alt); - out.put_u8(u8::from(s.cell_text_is_html)); - put_ustr(&mut out, &s.cell_text); - out.put_u32(s.horizontal_align); - out.put_u32(s.vertical_align); - out.put(&s.color); - } - if self.slices.iter().any(|s| s.outsets != [0; 4]) { - let list = self - .slices - .iter() - .map(|s| { - Value::Descriptor( - Descriptor::new("slice") - .with("sliceID", Value::Integer(s.id as i32)) - .with("topOutset", Value::Integer(s.outsets[0])) - .with("leftOutset", Value::Integer(s.outsets[1])) - .with("bottomOutset", Value::Integer(s.outsets[2])) - .with("rightOutset", Value::Integer(s.outsets[3])), - ) - }) - .collect(); - VersionedDescriptor::new(Descriptor::new("null").with("slices", Value::List(list))).write(&mut out); - } - out - } - - /// Serializes in the version 7 descriptor form (what newer Photoshop versions write). - pub fn to_descriptor_bytes(&self) -> Vec { - let list = self - .slices - .iter() - .map(|s| { - let origin = match s.origin { - 0 => "autoGenerated", - 1 => "layerGenerated", - _ => "userGenerated", - }; - let kind = match s.kind { - 0 => "noImage", - 2 => "Tbl ", - _ => "Img ", - }; - let mut d = Descriptor::new("slice") - .with("sliceID", Value::Integer(s.id as i32)) - .with("groupID", Value::Integer(s.group_id as i32)) - .with("origin", enumerated("ESliceOrigin", origin)); - if let Some(l) = s.layer_id.filter(|_| s.origin == 1) { - d = d.with("layerID", Value::Integer(l as i32)); - } - d.with("Nm ", Value::Text(UnicodeString::new(&s.name))) - .with("Type", enumerated("ESliceType", kind)) - .with("bounds", bounds_descriptor([s.rect[1], s.rect[0], s.rect[3], s.rect[2]])) - .with("url", Value::Text(UnicodeString::new(&s.url))) - .with("null", Value::Text(UnicodeString::new(&s.target))) - .with("Msge", Value::Text(UnicodeString::new(&s.message))) - .with("altTag", Value::Text(UnicodeString::new(&s.alt))) - .with("cellTextIsHTML", Value::Boolean(s.cell_text_is_html)) - .with("cellText", Value::Text(UnicodeString::new(&s.cell_text))) - .with("horzAlign", enumerated("ESliceHorzAlign", "default")) - .with("vertAlign", enumerated("ESliceVertAlign", "default")) - .with("bgColorType", enumerated("ESliceBGColorType", "None")) - .with("topOutset", Value::Integer(s.outsets[0])) - .with("leftOutset", Value::Integer(s.outsets[1])) - .with("bottomOutset", Value::Integer(s.outsets[2])) - .with("rightOutset", Value::Integer(s.outsets[3])) - }) - .map(Value::Descriptor) - .collect(); - let d = Descriptor::new("null") - .with("bounds", bounds_descriptor(self.bounds)) - .with("slices", Value::List(list)) - .with("baseName", Value::Text(UnicodeString::new(&self.group_name))); - let mut out = Vec::new(); - out.put_u32(7); - VersionedDescriptor::new(d).write(&mut out); - out - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn sample() -> SlicesResource { - SlicesResource { - version: 6, - bounds: [0, 0, 80, 100], - group_name: "site".into(), - slices: vec![ - SliceRecord { id: 0, origin: 0, kind: 1, rect: [0, 0, 100, 10], ..Default::default() }, - SliceRecord { - id: 1, - group_id: 0, - origin: 2, - name: "logo".into(), - kind: 1, - rect: [20, 10, 60, 30], - url: "https://example.org/".into(), - target: "_blank".into(), - message: "hi".into(), - alt: "Logo".into(), - cell_text_is_html: true, - cell_text: "x".into(), - horizontal_align: 1, - vertical_align: 2, - color: [255, 10, 20, 30], - ..Default::default() - }, - SliceRecord { id: 2, origin: 1, layer_id: Some(7), kind: 0, rect: [5, 40, 25, 70], outsets: [1, 2, 3, 4], ..Default::default() }, - ], - } - } - - #[test] - fn v6_roundtrips() { - let s = sample(); - let bytes = s.to_bytes(); - let back = SlicesResource::from_bytes(&bytes).unwrap(); - assert_eq!(back, s); - assert_eq!(back.to_bytes(), bytes, "byte-stable"); - } - - #[test] - fn v7_descriptor_parses() { - let s = sample(); - let back = SlicesResource::from_bytes(&s.to_descriptor_bytes()).unwrap(); - assert_eq!(back.version, 7); - assert_eq!(back.group_name, "site"); - assert_eq!(back.bounds, s.bounds); - assert_eq!(back.slices.len(), 3); - assert_eq!(back.slices[1].rect, [20, 10, 60, 30]); - assert_eq!(back.slices[1].alt, "Logo"); - assert_eq!(back.slices[1].target, "_blank"); - assert_eq!(back.slices[2].origin, 1); - assert_eq!(back.slices[2].layer_id, Some(7)); - assert_eq!(back.slices[2].kind, 0); - assert_eq!(back.slices[2].outsets, [1, 2, 3, 4]); - } - - #[test] - fn out_of_range_descriptor_numbers_do_not_wrap() { - // sliceID 2^32+1 used to read as slice 1 and take that slice's outsets. - let wide = |key: &str| Descriptor::new("slice").with("sliceID", Value::LargeInteger((1 << 32) + 1)).with(key, Value::Integer(9)); - let mut plain = sample(); - for s in &mut plain.slices { - s.outsets = [0; 4]; - } - let mut bytes = plain.to_bytes(); - VersionedDescriptor::new(Descriptor::new("null").with("slices", Value::List(vec![Value::Descriptor(wide("topOutset"))]))).write(&mut bytes); - let back = SlicesResource::from_bytes(&bytes).unwrap(); - assert!(back.slices.iter().all(|s| s.outsets == [0; 4]), "{:?}", back.slices); - - // Descriptor form: ids out of the u32 range fall back to 0, coordinates clamp. - let d = wide("leftOutset") - .with("layerID", Value::LargeInteger(-1)) - .with("bottomOutset", Value::LargeInteger(1 << 40)) - .with("rightOutset", Value::Double(-1e12)) - .with("bounds", Value::Descriptor(Descriptor::new("Rct1").with("Top ", Value::LargeInteger((1 << 32) + 5)))); - let s = slice_from_descriptor(&d); - assert_eq!((s.id, s.layer_id), (0, None)); - assert_eq!(s.outsets, [0, 9, i32::MAX, i32::MIN]); - assert_eq!(s.rect[1], i32::MAX); - // A 32-bit long keeps the id's bits, as `to_descriptor_bytes` writes ids past i32::MAX. - assert_eq!(slice_from_descriptor(&Descriptor::new("slice").with("sliceID", Value::Integer(-1))).id, u32::MAX); - } - - #[test] - fn malformed_input_is_an_error() { - let bytes = sample().to_bytes(); - // Cuts inside the records; a damaged trailing descriptor is ignored on purpose. - for cut in [0, 3, 10, 30, 120] { - assert!(SlicesResource::from_bytes(&bytes[..cut]).is_err(), "cut at {cut}"); - } - let mut huge = 6u32.to_be_bytes().to_vec(); - huge.extend([0u8; 16]); - huge.extend(0u32.to_be_bytes()); - huge.extend(u32::MAX.to_be_bytes()); - assert!(SlicesResource::from_bytes(&huge).is_err()); - assert!(SlicesResource::from_bytes(&9u32.to_be_bytes()).is_err()); - } - - #[test] - fn trailing_descriptor_applies_outsets_to_first_matching_slice() { - // Two slices share id 1; the trailing descriptor must update the first one only, - // matching the pre-index behavior of `iter_mut().find()`. - let mut plain = SlicesResource { - version: 6, - bounds: [0, 0, 80, 100], - group_name: "site".into(), - slices: vec![ - SliceRecord { id: 1, origin: 0, kind: 1, rect: [0, 0, 10, 10], ..Default::default() }, - SliceRecord { id: 1, origin: 0, kind: 1, rect: [10, 10, 20, 20], ..Default::default() }, - ], - }; - for s in &mut plain.slices { - s.outsets = [0; 4]; - } - let mut bytes = plain.to_bytes(); - let extra = Descriptor::new("slice").with("sliceID", Value::Integer(1)).with("topOutset", Value::Integer(7)); - VersionedDescriptor::new(Descriptor::new("null").with("slices", Value::List(vec![Value::Descriptor(extra)]))).write(&mut bytes); - let back = SlicesResource::from_bytes(&bytes).unwrap(); - assert_eq!(back.slices[0].outsets, [7, 0, 0, 0], "first slice receives the outset"); - assert_eq!(back.slices[1].outsets, [0, 0, 0, 0], "later duplicate is untouched"); - } -} diff --git a/crates/psd/src/source.rs b/crates/psd/src/source.rs deleted file mode 100644 index e04e861a1f..0000000000 --- a/crates/psd/src/source.rs +++ /dev/null @@ -1,270 +0,0 @@ -//! Random-access byte sources, for parsing a file without reading all of it. -//! -//! [`PsdFile::from_source`](crate::PsdFile::from_source) reads the structure of a file (header, -//! resources, layer records, tagged blocks) and leaves the bulk of it, the encoded channel data -//! and the merged image, where it is: each [`ChannelData`](crate::ChannelData) records a -//! [`Stored`] range instead of a copy, and the bytes are read when the channel is decoded. A -//! 2 GB layered file then never needs 2 GB of memory just to be opened. - -use std::borrow::Cow; -use std::fmt; -use std::sync::Arc; - -use crate::error::{PsdError, Result}; - -/// Bytes that can be read at any offset, from any thread. -/// -/// Implemented for byte buffers and, on native targets, for [`std::fs::File`] (as -/// [`FileSource`]). The source must not change while a file parsed from it is in use: a -/// [`Stored`] range is an offset into it. -pub trait ReadAt: Send + Sync { - /// Total length in bytes. - fn len(&self) -> u64; - - /// `true` when the source holds no bytes. - fn is_empty(&self) -> bool { - self.len() == 0 - } - - /// Fills `buf` with the bytes at `offset..offset + buf.len()`. Fails, without a partial - /// result, when the range is not all inside the source or the read fails. - fn read_at(&self, offset: u64, buf: &mut [u8]) -> Result<()>; -} - -fn out_of_range(offset: u64, needed: usize, len: u64) -> PsdError { - PsdError::UnexpectedEof { - offset: usize::try_from(offset).unwrap_or(usize::MAX), - needed: needed.saturating_sub(usize::try_from(len.saturating_sub(offset)).unwrap_or(usize::MAX)), - } -} - -impl ReadAt for [u8] { - fn len(&self) -> u64 { - <[u8]>::len(self) as u64 - } - - fn read_at(&self, offset: u64, buf: &mut [u8]) -> Result<()> { - let src = usize::try_from(offset) - .ok() - .and_then(|start| self.get(start..start.checked_add(buf.len())?)) - .ok_or_else(|| out_of_range(offset, buf.len(), ReadAt::len(self)))?; - buf.copy_from_slice(src); - Ok(()) - } -} - -impl ReadAt for Vec { - fn len(&self) -> u64 { - self.as_slice().len() as u64 - } - - fn read_at(&self, offset: u64, buf: &mut [u8]) -> Result<()> { - self.as_slice().read_at(offset, buf) - } -} - -impl ReadAt for Arc { - fn len(&self) -> u64 { - (**self).len() - } - - fn read_at(&self, offset: u64, buf: &mut [u8]) -> Result<()> { - (**self).read_at(offset, buf) - } -} - -/// Largest single read from a [`FileSource`]. -#[cfg(any(unix, windows))] -const MAX_READ: usize = 64 << 20; - -/// An open file read at offsets (`pread` on Unix, `seek_read` on Windows), with its length taken -/// when it was opened. Reads from several threads at once need no lock. -#[cfg(any(unix, windows))] -#[derive(Debug)] -pub struct FileSource { - file: std::fs::File, - len: u64, -} - -#[cfg(any(unix, windows))] -impl FileSource { - /// Opens `path` for reading. - pub fn open(path: &std::path::Path) -> std::io::Result { - Self::new(std::fs::File::open(path)?) - } - - /// Wraps an open file. - pub fn new(file: std::fs::File) -> std::io::Result { - let len = file.metadata()?.len(); - Ok(FileSource { file, len }) - } - - /// The file. - pub fn file(&self) -> &std::fs::File { - &self.file - } -} - -#[cfg(any(unix, windows))] -impl ReadAt for FileSource { - fn len(&self) -> u64 { - self.len - } - - fn read_at(&self, offset: u64, buf: &mut [u8]) -> Result<()> { - if offset.checked_add(buf.len() as u64).is_none_or(|end| end > self.len) { - return Err(out_of_range(offset, buf.len(), self.len)); - } - let n = buf.len(); - let failed = |e: std::io::Error| PsdError::Read(format!("reading {n} bytes at offset {offset}: {e}")); - // Bounded reads, as `photocraft-format`'s `read_file` makes them: some Windows file - // systems reject a single read of gigabytes (#375). - let mut done = 0usize; - while done < n { - let Some(rest) = buf.get_mut(done..n.min(done + MAX_READ)) else { break }; - let at = offset + done as u64; - #[cfg(unix)] - let read = { - use std::os::unix::fs::FileExt; - self.file.read_at(rest, at) - }; - #[cfg(windows)] - let read = { - use std::os::windows::fs::FileExt; - self.file.seek_read(rest, at) - }; - match read { - Ok(0) => return Err(failed(std::io::ErrorKind::UnexpectedEof.into())), - Ok(k) => done += k, - Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {} - Err(e) => return Err(failed(e)), - } - } - Ok(()) - } -} - -/// A shared [`ReadAt`]: what [`Stored`] ranges point into. Two handles are equal when they are -/// the same source. -#[derive(Clone)] -pub struct Source(Arc); - -impl Source { - /// Shares `source`. - pub fn new(source: impl ReadAt + 'static) -> Self { - Source(Arc::new(source)) - } - - /// Shares an already shared source. - pub fn from_arc(source: Arc) -> Self { - Source(source) - } - - /// The source. - pub fn get(&self) -> &dyn ReadAt { - &*self.0 - } - - /// Reads `len` bytes at `offset` into a new buffer. The buffer is reserved fallibly: a - /// range larger than memory is an error, not an abort. - pub fn read_vec(&self, offset: u64, len: u64) -> Result> { - let n = usize::try_from(len).map_err(|_| PsdError::LimitExceeded("length exceeds address space"))?; - if offset.checked_add(len).is_none_or(|end| end > self.0.len()) { - return Err(out_of_range(offset, n, self.0.len())); - } - let mut out = Vec::new(); - out.try_reserve_exact(n).map_err(|_| PsdError::OutOfMemory("not enough memory to read the data"))?; - out.resize(n, 0); - self.0.read_at(offset, &mut out)?; - Ok(out) - } -} - -impl fmt::Debug for Source { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "Source({} bytes)", self.0.len()) - } -} - -impl PartialEq for Source { - fn eq(&self, other: &Self) -> bool { - Arc::ptr_eq(&self.0, &other.0) - } -} - -impl Eq for Source {} - -/// Encoded bytes left in their [`Source`] by a lazy parse: `len` bytes at `offset`. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Stored { - /// Where the bytes are. - pub source: Source, - /// Offset of the first byte. - pub offset: u64, - /// Number of bytes. - pub len: u64, -} - -impl Stored { - /// Reads the bytes. - pub fn read(&self) -> Result> { - self.source.read_vec(self.offset, self.len) - } - - /// Reads `len` bytes starting `at` bytes into the range. - pub fn read_part(&self, at: u64, len: u64) -> Result> { - if at.checked_add(len).is_none_or(|end| end > self.len) { - return Err(PsdError::invalid("read past the end of stored data")); - } - self.source.read_vec(self.offset + at, len) - } -} - -/// The bytes of a value that is either in memory (`data`) or still in its source (`stored`). -pub(crate) fn encoded<'a>(data: &'a [u8], stored: Option<&Stored>) -> Result> { - match stored { - Some(s) => s.read().map(Cow::Owned), - None => Ok(Cow::Borrowed(data)), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn slice_reads_are_bounds_checked() { - let v = vec![1u8, 2, 3, 4]; - let mut b = [0u8; 2]; - v.read_at(2, &mut b).unwrap(); - assert_eq!(b, [3, 4]); - assert!(v.read_at(3, &mut b).is_err()); - assert!(v.read_at(u64::MAX, &mut b).is_err()); - let s = Source::new(v); - assert_eq!(s.read_vec(1, 2).unwrap(), vec![2, 3]); - assert!(s.read_vec(1, u64::MAX).is_err()); - let st = Stored { source: s.clone(), offset: 1, len: 3 }; - assert_eq!(st.read().unwrap(), vec![2, 3, 4]); - assert_eq!(st.read_part(1, 2).unwrap(), vec![3, 4]); - assert!(st.read_part(2, 2).is_err()); - assert_eq!(st.source, s); - assert_ne!(st.source, Source::new(vec![1u8, 2, 3, 4])); - } - - #[cfg(any(unix, windows))] - #[test] - fn file_source_reads_at_offsets() { - use std::io::Write; - let dir = std::env::temp_dir().join(format!("photocraft-psd-source-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("f.bin"); - std::fs::File::create(&path).unwrap().write_all(&[9, 8, 7, 6, 5]).unwrap(); - let f = FileSource::open(&path).unwrap(); - assert_eq!(ReadAt::len(&f), 5); - let mut b = [0u8; 3]; - f.read_at(2, &mut b).unwrap(); - assert_eq!(b, [7, 6, 5]); - assert!(f.read_at(3, &mut b).is_err()); - std::fs::remove_dir_all(&dir).unwrap(); - } -} diff --git a/crates/psd/src/tagged.rs b/crates/psd/src/tagged.rs deleted file mode 100644 index e54f4daf0b..0000000000 --- a/crates/psd/src/tagged.rs +++ /dev/null @@ -1,681 +0,0 @@ -//! Additional layer information ("tagged blocks"). -//! -//! Every block is stored raw (signature, key, data and the exact padding that -//! followed it in the file) so unknown and partially-understood blocks -//! round-trip byte for byte. [`TaggedBlock::parsed`] offers typed views of -//! simple, well-known keys. - -use crate::blend::BlendMode; -use crate::descriptor::UnicodeString; -use crate::error::{PsdError, Result}; -use crate::header::Version; -use crate::io::{Reader, Sink, WriteExt}; - -/// Keys whose length field is 8 bytes in PSB files: the thirteen the Adobe -/// specification lists, plus keys Photoshop also writes with 8-byte lengths -/// although the specification omits them (observed in real PSB files, e.g. -/// `lnkE` and `cinf` in the ag-psd test set, and expected by other PSB -/// readers such as psd-tools). Reading one of these with a 4-byte length -/// misaligns every block after it (#200). -pub const PSB_LONG_KEYS: [&[u8; 4]; 21] = [ - b"LMsk", b"Lr16", b"Lr32", b"Layr", b"Mt16", b"Mt32", b"Mtrn", b"Alph", b"FMsk", b"lnk2", b"FEid", b"FXid", b"PxSD", // spec - b"lnk3", b"lnkE", b"pths", b"extd", b"extn", b"FELS", b"cinf", b"artd", // observed -]; - -/// Returns `true` if `key` uses an 8-byte length in `version`. -pub fn uses_long_length(version: Version, key: &[u8; 4]) -> bool { - version.is_psb() && PSB_LONG_KEYS.contains(&key) -} - -/// A tagged block, stored raw. -#[derive(Debug, Clone, PartialEq, Eq)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct TaggedBlock { - /// `8BIM` or `8B64`. - pub signature: [u8; 4], - /// Four-character key. - pub key: [u8; 4], - /// Block data exactly as covered by the length field. - pub data: Vec, - /// Bytes that followed the data before the next block. `None` means - /// "default": zero-pad to a multiple of four for global blocks, two for - /// layer blocks. Parsing yields `None` whenever - /// the stored padding equals the default, so the model stays canonical. - pub padding: Option>, -} - -/// Layer section divider type (`lsct`). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub enum SectionType { - /// 0: any other type of layer. - Other, - /// 1: open folder. - OpenFolder, - /// 2: closed folder. - ClosedFolder, - /// 3: bounding section divider (hidden in the UI; marks the group end - /// in bottom-to-top order). - BoundingDivider, - /// Unknown value. - Unknown(u32), -} - -impl SectionType { - /// From stored value. - pub fn from_u32(v: u32) -> Self { - match v { - 0 => SectionType::Other, - 1 => SectionType::OpenFolder, - 2 => SectionType::ClosedFolder, - 3 => SectionType::BoundingDivider, - v => SectionType::Unknown(v), - } - } - /// Stored value. - pub fn as_u32(self) -> u32 { - match self { - SectionType::Other => 0, - SectionType::OpenFolder => 1, - SectionType::ClosedFolder => 2, - SectionType::BoundingDivider => 3, - SectionType::Unknown(v) => v, - } - } - /// `true` for open or closed folders. - pub fn is_folder(self) -> bool { - matches!(self, SectionType::OpenFolder | SectionType::ClosedFolder) - } -} - -/// Section divider setting (`lsct` / `lsdk`). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct SectionDivider { - /// Divider type. - pub kind: SectionType, - /// Group blend mode (present if length >= 12). - pub blend_mode: Option, - /// Sub type: 0 normal, 1 scene group (present if length >= 16). - pub sub_type: Option, -} - -/// Typed view of a known block. -#[derive(Debug, Clone, PartialEq)] -#[non_exhaustive] -pub enum BlockData<'a> { - /// `luni` - UnicodeName(String), - /// `lsct` / `lsdk` - SectionDivider(SectionDivider), - /// `lyid` - LayerId(u32), - /// `lnsr` - NameSource([u8; 4]), - /// `clbl` - BlendClippedAsGroup(bool), - /// `infx` - BlendInteriorElements(bool), - /// `knko`: 0 none, 1 shallow, 2 deep. - Knockout(u8), - /// `tsly`: Transparency Shapes Layer. - TransparencyShapes(bool), - /// `lmgm`: Layer Mask Hides Effects ("layer mask as global mask"). - LayerMaskHidesEffects(bool), - /// `vmgm`: Vector Mask Hides Effects. - VectorMaskHidesEffects(bool), - /// `lspf`: bit 0 transparency, bit 1 composite, bit 2 position locked. - Protection(u32), - /// `lclr`: sheet color index (0 none, 1 red, 2 orange, 3 yellow, 4 green, - /// 5 blue, 6 violet, 7 gray). - SheetColor(u16), - /// `iOpa` - FillOpacity(u8), - /// `shmd`: metadata setting, raw. - MetadataSetting(&'a [u8]), -} - -impl TaggedBlock { - /// A new `8BIM` block with default padding. - pub fn new(key: [u8; 4], data: Vec) -> Self { - TaggedBlock { signature: *b"8BIM", key, data, padding: None } - } - - /// `true` for `MIB8` / `46B8`: the signatures [`crate::tiff`] gives a block whose data was - /// kept verbatim from a little-endian TIFF because its layout is unknown. Such a block is - /// written back exactly into a little-endian TIFF, and cannot go into a big-endian file - /// (a PSD) without being dropped: see [`Self::is_foreign_order`]. - pub fn is_foreign_order_signature(b: &[u8]) -> bool { - b.starts_with(b"MIB8") || b.starts_with(b"46B8") - } - - /// Whether this block's data is in little-endian layout (see - /// [`Self::is_foreign_order_signature`]). - pub fn is_foreign_order(&self) -> bool { - Self::is_foreign_order_signature(&self.signature) - } - - /// `true` for a "Saving Merged Transparency" marker: the merged image's first extra channel - /// is its transparency. The specification's keys are `Mtrn`, `Mt16` and `Mt32`; Photoshop - /// (27.11) writes `MTrn`, with a positive layer count, in a layered TIFF saved with Save - /// Transparency next to spot and alpha channels. - pub fn is_merged_transparency_marker(&self) -> bool { - matches!(&self.key, b"Mtrn" | b"MTrn" | b"Mt16" | b"Mt32") - } - - /// Key as a string (lossy). - pub fn key_str(&self) -> String { - String::from_utf8_lossy(&self.key).into_owned() - } - - pub(crate) fn default_padding(len: usize, alignment: usize) -> usize { - (alignment - len % alignment) % alignment - } - - /// Typed view for known keys. `None` for keys without a typed parser, - /// `Some(Err)` if a known key's data is malformed. - pub fn parsed(&self) -> Option>> { - let d = &self.data[..]; - let mut r = Reader::new(d); - Some(match &self.key { - b"luni" => UnicodeString::read(&mut r).map(|s| BlockData::UnicodeName(s.to_string_lossy())), - b"lsct" | b"lsdk" => parse_section(d).map(BlockData::SectionDivider), - b"lyid" => r.u32().map(BlockData::LayerId), - b"lnsr" => r.array().map(BlockData::NameSource), - b"clbl" => r.u8().map(|v| BlockData::BlendClippedAsGroup(v != 0)), - b"infx" => r.u8().map(|v| BlockData::BlendInteriorElements(v != 0)), - b"knko" => r.u8().map(BlockData::Knockout), - b"tsly" => r.u8().map(|v| BlockData::TransparencyShapes(v != 0)), - b"lmgm" => r.u8().map(|v| BlockData::LayerMaskHidesEffects(v != 0)), - b"vmgm" => r.u8().map(|v| BlockData::VectorMaskHidesEffects(v != 0)), - b"lspf" => r.u32().map(BlockData::Protection), - b"lclr" => r.u16().map(BlockData::SheetColor), - b"iOpa" => r.u8().map(BlockData::FillOpacity), - b"shmd" => Ok(BlockData::MetadataSetting(d)), - _ => return None, - }) - } - - /// `luni` block. Data is padded to a multiple of 4, as Photoshop does. - pub fn unicode_name(name: &str) -> Self { - let mut d = Vec::new(); - UnicodeString::new(name).write(&mut d); - while d.len() % 4 != 0 { - d.push(0); - } - Self::new(*b"luni", d) - } - - /// `lsct` block with all fields. - pub fn section_divider(kind: SectionType, blend_mode: Option, sub_type: Option) -> Self { - let mut d = Vec::new(); - d.put_u32(kind.as_u32()); - if let Some(b) = blend_mode { - d.put(b"8BIM"); - d.put(&b.key()); - if let Some(s) = sub_type { - d.put_u32(s); - } - } - Self::new(*b"lsct", d) - } - - /// `lyid` block. - pub fn layer_id(id: u32) -> Self { - Self::new(*b"lyid", id.to_be_bytes().to_vec()) - } - /// `lnsr` block. - pub fn name_source(src: [u8; 4]) -> Self { - Self::new(*b"lnsr", src.to_vec()) - } - fn flag(key: [u8; 4], v: u8) -> Self { - Self::new(key, vec![v, 0, 0, 0]) - } - /// `clbl` block. - pub fn blend_clipped_as_group(v: bool) -> Self { - Self::flag(*b"clbl", u8::from(v)) - } - /// `infx` block. - pub fn blend_interior_elements(v: bool) -> Self { - Self::flag(*b"infx", u8::from(v)) - } - /// `knko` block. - pub fn knockout(v: u8) -> Self { - Self::flag(*b"knko", v) - } - /// `tsly` block. - pub fn transparency_shapes(v: bool) -> Self { - Self::flag(*b"tsly", u8::from(v)) - } - /// `lmgm` block. - pub fn layer_mask_hides_effects(v: bool) -> Self { - Self::flag(*b"lmgm", u8::from(v)) - } - /// `vmgm` block. - pub fn vector_mask_hides_effects(v: bool) -> Self { - Self::flag(*b"vmgm", u8::from(v)) - } - /// `lspf` block. - pub fn protection(flags: u32) -> Self { - Self::new(*b"lspf", flags.to_be_bytes().to_vec()) - } - /// `lclr` block. - pub fn sheet_color(color: u16) -> Self { - let mut d = color.to_be_bytes().to_vec(); - d.extend_from_slice(&[0; 6]); - Self::new(*b"lclr", d) - } - /// `iOpa` block. - pub fn fill_opacity(v: u8) -> Self { - Self::flag(*b"iOpa", v) - } - - /// Strictly re-parses the internal structure of blocks whose data carries - /// its own sizes, so a block that a strict reader would reject is caught - /// here: `PlLd` (a `plcL` placed layer), `SoLd`/`SoLE` (`soLD` placed - /// layer data), `lnk2`/`lnk3`/`lnkD` (length-prefixed linked files) and - /// `lfx2` (object effects). Every inner length must stay inside the block - /// and the parse must end at the end of the data, apart from up to three - /// zero padding bytes. Other keys are not checked and return `Ok`. - /// - /// Layouts follow the Adobe Photoshop File Format specification - /// ("Additional Layer Information": Placed Layer, Placed Layer Data, - /// Linked Layer, Object Based Effects Layer Info). - pub fn check_structure(&self) -> Result<()> { - let d = &self.data[..]; - let mut r = Reader::new(d); - match &self.key { - b"PlLd" => { - expect_kind(&mut r, b"plcL")?; - let _version = r.u32()?; - crate::io::read_pascal(&mut r, 1)?; - // Page, total pages, anti-alias policy, layer type; transform (8 doubles). - r.skip(4 * 4 + 8 * 8)?; - let _warp_version = r.u32()?; - crate::descriptor::VersionedDescriptor::read(&mut r)?; - } - b"SoLd" | b"SoLE" => { - expect_kind(&mut r, b"soLD")?; - let _version = r.u32()?; - let d = crate::descriptor::VersionedDescriptor::read(&mut r)?; - check_placed_descriptor(&d.descriptor).map_err(|e| PsdError::invalid(format!("{}: {e}", self.key_str())))?; - } - b"lfx2" => { - let _effects_version = r.u32()?; - crate::descriptor::VersionedDescriptor::read(&mut r)?; - } - b"lnk2" | b"lnk3" | b"lnkD" => { - while r.remaining() >= 8 { - let len = r.u64()?; - let item = r.bytes_u64(len)?; - if !matches!(item.get(..4), Some(b"liFD" | b"liFE" | b"liFA")) { - return Err(PsdError::invalid(format!("{}: linked item of unknown type", self.key_str()))); - } - if item.get(..4) == Some(b"liFD") { - check_embedded_item(item).map_err(|e| PsdError::invalid(format!("{}: embedded file: {e}", self.key_str())))?; - } - // Items are padded to a multiple of 4. - let pad = ((4 - len % 4) % 4).min(r.remaining() as u64); - r.skip(pad as usize)?; - } - } - b"FEid" | b"FXid" => { - crate::filter_effects::FilterEffects::parse(d).map_err(|e| PsdError::invalid(format!("{}: {e}", self.key_str())))?; - return Ok(()); - } - _ => return Ok(()), - } - let rest = r.peek_rest(); - if rest.len() > 3 || rest.iter().any(|&b| b != 0) { - return Err(PsdError::invalid(format!("{}: {} unexpected bytes after the block structure", self.key_str(), rest.len()))); - } - Ok(()) - } - - pub(crate) fn write(&self, out: &mut Vec, version: Version, alignment: usize) -> Result<()> { - Self::write_parts(out, self.signature, self.key, &self.data, self.padding.as_deref(), version, alignment) - } - - /// Writes a block from its parts (no owned [`TaggedBlock`] needed). `padding: None` writes - /// zeros up to `alignment`. - pub(crate) fn write_parts( - out: &mut Vec, - signature: [u8; 4], - key: [u8; 4], - data: &[u8], - padding: Option<&[u8]>, - version: Version, - alignment: usize, - ) -> Result<()> { - out.put(&signature); - out.put(&key); - out.put_len(data.len() as u64, uses_long_length(version, &key))?; - out.put(data); - match padding { - Some(p) => out.put(p), - None => out.extend(std::iter::repeat_n(0u8, Self::default_padding(data.len(), alignment))), - } - Ok(()) - } - - /// Serialized size in bytes as a layer block (two-byte alignment). - pub fn encoded_len(&self, version: Version) -> usize { - self.encoded_len_aligned(version, 2) - } - - /// Serialized size in bytes as [`Self::write`] writes it with `alignment`. - pub(crate) fn encoded_len_aligned(&self, version: Version, alignment: usize) -> usize { - let l = if uses_long_length(version, &self.key) { 8 } else { 4 }; - let p = self.padding.as_ref().map_or(Self::default_padding(self.data.len(), alignment), Vec::len); - (8 + l + p).saturating_add(self.data.len()) - } -} - -/// Strict check of a `soLD` descriptor: the unique id and transform Photoshop needs to place the -/// layer, and a well-formed smart filter stack (`filterFX`: a `filterFXList` of `filterFX` -/// objects, each with a name, blend options, an enabled flag and a filter id). -fn check_placed_descriptor(d: &crate::descriptor::Descriptor) -> Result<()> { - use crate::descriptor::Value; - if !matches!(d.get("Idnt"), Some(Value::Text(_))) { - return Err(PsdError::invalid("no Idnt (unique id)")); - } - match d.get("Trnf") { - Some(Value::List(l)) if l.len() == 8 && l.iter().all(|v| matches!(v, Value::Double(_))) => {} - _ => return Err(PsdError::invalid("Trnf is not eight doubles")), - } - let Some(fx) = d.get("filterFX") else { return Ok(()) }; - let Value::Descriptor(fx) = fx else { return Err(PsdError::invalid("filterFX is not an object")) }; - let Some(Value::List(list)) = fx.get("filterFXList") else { return Err(PsdError::invalid("filterFX has no filterFXList")) }; - for (i, item) in list.iter().enumerate() { - let Value::Descriptor(f) = item else { return Err(PsdError::invalid(format!("filter {i} is not an object"))) }; - let ok = matches!(f.get("Nm "), Some(Value::Text(_))) - && matches!(f.get("blendOptions"), Some(Value::Descriptor(_))) - && matches!(f.get("enab"), Some(Value::Boolean(_))) - && matches!(f.get("filterID"), Some(Value::Integer(_))) - && f.get("Fltr").is_none_or(|v| matches!(v, Value::Descriptor(_))); - if !ok { - return Err(PsdError::invalid(format!("filter {i} lacks Nm/blendOptions/enab/filterID"))); - } - } - Ok(()) -} - -/// Strict check of one embedded (`liFD`) linked-layer item: type, version, unique id, file name, -/// file type and creator, data length, optional open descriptor, then the file data, which must -/// fit in the item (version 5+ fields follow it). -fn check_embedded_item(item: &[u8]) -> Result<()> { - let mut r = Reader::new(item); - r.skip(4)?; - let version = r.u32()?; - if !(1..=16).contains(&version) { - return Err(PsdError::invalid(format!("version {version}"))); - } - crate::io::read_pascal(&mut r, 1)?; - crate::io::read_unicode_units(&mut r)?; - r.skip(8)?; // file type, creator - let len = r.u64()?; - if r.u8()? != 0 { - crate::descriptor::VersionedDescriptor::read(&mut r)?; - } - r.bytes_u64(len)?; - Ok(()) -} - -fn expect_kind(r: &mut Reader<'_>, kind: &'static [u8; 4]) -> Result<()> { - let found = r.array::<4>()?; - if &found != kind { - return Err(PsdError::invalid(format!("expected {} structure, found {:?}", String::from_utf8_lossy(kind), String::from_utf8_lossy(&found)))); - } - Ok(()) -} - -fn parse_section(d: &[u8]) -> Result { - let mut r = Reader::new(d); - let kind = SectionType::from_u32(r.u32()?); - let mut blend_mode = None; - let mut sub_type = None; - if r.remaining() >= 8 { - let sig = r.array::<4>()?; - if &sig != b"8BIM" { - return Err(PsdError::InvalidSignature { expected: "8BIM", found: sig }); - } - blend_mode = Some(BlendMode::from_key(r.array()?)); - if r.remaining() >= 4 { - sub_type = Some(r.u32()?); - } - } - Ok(SectionDivider { kind, blend_mode, sub_type }) -} - -/// `8BIM` / `8B64`, or the markers `MIB8` / `46B8` that [`crate::tiff`] gives a block kept -/// verbatim from a little-endian TIFF when its layout is unknown (its data is in that order). -pub(crate) fn is_sig(b: &[u8]) -> bool { - b.starts_with(b"8BIM") || b.starts_with(b"8B64") || TaggedBlock::is_foreign_order_signature(b) -} - -/// Reads tagged blocks until the reader is exhausted. Bytes that do not form -/// a block (fewer than 12 bytes or no valid signature) are returned as -/// trailing data. -pub(crate) fn read_blocks(r: &mut Reader<'_>, version: Version) -> Result<(Vec, Vec)> { - read_blocks_with(r, version, 2, |_, _, _, _, _| false) -} - -/// The callback consumes selected payloads before they are copied into owned blocks. -pub(crate) fn read_blocks_with( - r: &mut Reader<'_>, - version: Version, - alignment: usize, - mut consume: impl FnMut(usize, [u8; 4], [u8; 4], &[u8], &Option>) -> bool, -) -> Result<(Vec, Vec)> { - let mut blocks = Vec::new(); - loop { - let rest = r.peek_rest(); - if rest.len() < 12 || !is_sig(rest) { - let trailing = rest.to_vec(); - r.skip(rest.len())?; - return Ok((blocks, trailing)); - } - let signature = r.array::<4>()?; - let key = r.array::<4>()?; - let len = r.len_field(uses_long_length(version, &key))?; - let data = r.bytes_u64(len)?; - // Detect padding: the smallest k in 0..=3 such that the next block - // signature (or the end of the region) follows k bytes later. - let rest = r.peek_rest(); - let mut pad = None; - for k in 0..=3usize { - if k == rest.len() || (k < rest.len() && is_sig(&rest[k..])) { - pad = Some(k); - break; - } - } - // Opaque trailing bytes can follow canonical zero padding without another signature. - let default = TaggedBlock::default_padding(data.len(), alignment); - let pad = pad.unwrap_or_else(|| if rest.get(..default).is_some_and(|p| p.iter().all(|&b| b == 0)) { default } else { 0 }); - let pad_bytes = r.bytes(pad)?.to_vec(); - let padding = - if pad_bytes.len() == TaggedBlock::default_padding(data.len(), alignment) && pad_bytes.iter().all(|&b| b == 0) { None } else { Some(pad_bytes) }; - if !consume(blocks.len(), signature, key, data, &padding) { - blocks.push(TaggedBlock { signature, key, data: data.to_vec(), padding }); - } - } -} - -pub(crate) fn write_blocks(out: &mut Vec, blocks: &[TaggedBlock], version: Version, alignment: usize) -> Result<()> { - for b in blocks { - b.write(out, version, alignment)?; - } - Ok(()) -} - -/// [`write_blocks`] to a streaming [`Sink`]: each block's data goes straight through, so a large -/// opaque block (an embedded smart object) is never copied whole. -pub(crate) fn write_blocks_to( - out: &mut Sink<'_, W>, - blocks: &[TaggedBlock], - version: Version, - alignment: usize, -) -> std::io::Result<()> { - for b in blocks { - out.small(|o| { - o.put(&b.signature); - o.put(&b.key); - o.put_len(b.data.len() as u64, uses_long_length(version, &b.key)) - })?; - out.put(&b.data)?; - match &b.padding { - Some(p) => out.put(p)?, - None => out.zeros(TaggedBlock::default_padding(b.data.len(), alignment))?, - } - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn rt(blocks: &[TaggedBlock], version: Version) -> Vec { - let mut out = Vec::new(); - write_blocks(&mut out, blocks, version, 2).unwrap(); - let (back, trailing) = read_blocks(&mut Reader::new(&out), version).unwrap(); - assert_eq!(back, blocks); - assert!(trailing.is_empty()); - out - } - - #[test] - fn unicode_name_typed() { - let b = TaggedBlock::unicode_name("Ebene \u{e4}\u{1F600}"); - assert_eq!(b.data.len() % 4, 0); - assert_eq!(b.parsed(), Some(Ok(BlockData::UnicodeName("Ebene \u{e4}\u{1F600}".into())))); - rt(&[b], Version::Psd); - } - - #[test] - fn section_divider_variants() { - for (kind, bm, st, len) in [ - (SectionType::OpenFolder, None, None, 4), - (SectionType::ClosedFolder, Some(BlendMode::PassThrough), None, 12), - (SectionType::BoundingDivider, Some(BlendMode::Multiply), Some(1), 16), - ] { - let b = TaggedBlock::section_divider(kind, bm, st); - assert_eq!(b.data.len(), len); - match b.parsed() { - Some(Ok(BlockData::SectionDivider(s))) => { - assert_eq!(s.kind, kind); - assert_eq!(s.blend_mode, bm); - assert_eq!(s.sub_type, st); - } - other => panic!("{other:?}"), - } - } - let mut b = TaggedBlock::section_divider(SectionType::OpenFolder, Some(BlendMode::Normal), None); - b.data[4] = b'X'; - assert!(matches!(b.parsed(), Some(Err(_)))); - let mut lsdk = TaggedBlock::section_divider(SectionType::OpenFolder, None, None); - lsdk.key = *b"lsdk"; - assert!(matches!(lsdk.parsed(), Some(Ok(BlockData::SectionDivider(_))))); - } - - #[test] - fn section_type_values() { - for v in 0..6 { - assert_eq!(SectionType::from_u32(v).as_u32(), v); - } - assert!(SectionType::OpenFolder.is_folder()); - assert!(!SectionType::BoundingDivider.is_folder()); - } - - #[test] - fn simple_typed_blocks() { - assert_eq!(TaggedBlock::layer_id(7).parsed(), Some(Ok(BlockData::LayerId(7)))); - assert_eq!(TaggedBlock::name_source(*b"cont").parsed(), Some(Ok(BlockData::NameSource(*b"cont")))); - assert_eq!(TaggedBlock::blend_clipped_as_group(true).parsed(), Some(Ok(BlockData::BlendClippedAsGroup(true)))); - assert_eq!(TaggedBlock::blend_interior_elements(false).parsed(), Some(Ok(BlockData::BlendInteriorElements(false)))); - assert_eq!(TaggedBlock::knockout(2).parsed(), Some(Ok(BlockData::Knockout(2)))); - assert_eq!(TaggedBlock::transparency_shapes(false).parsed(), Some(Ok(BlockData::TransparencyShapes(false)))); - assert_eq!(TaggedBlock::layer_mask_hides_effects(true).parsed(), Some(Ok(BlockData::LayerMaskHidesEffects(true)))); - assert_eq!(TaggedBlock::vector_mask_hides_effects(true).parsed(), Some(Ok(BlockData::VectorMaskHidesEffects(true)))); - assert_eq!(TaggedBlock::vector_mask_hides_effects(true).data, vec![1, 0, 0, 0]); - assert!(matches!(TaggedBlock::new(*b"tsly", vec![]).parsed(), Some(Err(_))), "truncated flag is an error, not a panic"); - assert_eq!(TaggedBlock::protection(0x8000_0000).parsed(), Some(Ok(BlockData::Protection(0x8000_0000)))); - assert_eq!(TaggedBlock::sheet_color(3).parsed(), Some(Ok(BlockData::SheetColor(3)))); - assert_eq!(TaggedBlock::fill_opacity(128).parsed(), Some(Ok(BlockData::FillOpacity(128)))); - let shmd = TaggedBlock::new(*b"shmd", vec![0, 0, 0, 0]); - assert_eq!(shmd.parsed(), Some(Ok(BlockData::MetadataSetting(&[0, 0, 0, 0])))); - assert_eq!(TaggedBlock::new(*b"lfx2", vec![]).parsed(), None); - assert!(matches!(TaggedBlock::new(*b"lyid", vec![1]).parsed(), Some(Err(_)))); - } - - #[test] - fn default_padding_odd_data() { - let b = TaggedBlock::new(*b"abcd", vec![1, 2, 3]); - let out = rt(&[b.clone(), b], Version::Psd); - assert_eq!(out.len(), 2 * (12 + 4)); - } - - #[test] - fn explicit_padding_preserved() { - let mut a = TaggedBlock::new(*b"abcd", vec![1, 2, 3]); - a.padding = Some(vec![0; 3]); // pad-to-4 in a file that aligned to 4 - let mut b = TaggedBlock::new(*b"efgh", vec![1, 2]); - b.padding = Some(vec![0, 0]); - let mut c = TaggedBlock::new(*b"ijkl", vec![1]); - c.padding = Some(vec![]); // no padding at all - rt(&[a, b, c], Version::Psd); - } - - #[test] - fn signature_8b64_preserved() { - let mut b = TaggedBlock::new(*b"Lr16", vec![0; 6]); - b.signature = *b"8B64"; - rt(&[b], Version::Psb); - } - - #[test] - fn psb_long_keys() { - for key in PSB_LONG_KEYS { - assert!(uses_long_length(Version::Psb, key)); - assert!(!uses_long_length(Version::Psd, key)); - let b = TaggedBlock::new(*key, vec![7; 4]); - let out = rt(std::slice::from_ref(&b), Version::Psb); - assert_eq!(out.len(), 8 + 8 + 4); - assert_eq!(b.encoded_len(Version::Psb), out.len()); - let out = rt(std::slice::from_ref(&b), Version::Psd); - assert_eq!(out.len(), 8 + 4 + 4); - } - assert!(!uses_long_length(Version::Psb, b"luni")); - } - - #[test] - fn trailing_bytes_kept() { - let mut out = Vec::new(); - write_blocks(&mut out, &[TaggedBlock::layer_id(1)], Version::Psd, 2).unwrap(); - out.extend_from_slice(&[0; 8]); - let (blocks, trailing) = read_blocks(&mut Reader::new(&out), Version::Psd).unwrap(); - assert_eq!(blocks.len(), 1); - // The detector attributes up to 3 bytes of zeros to padding only when - // they reach the end; 8 zeros are not padding. - assert_eq!(trailing, vec![0; 8]); - } - - #[test] - fn short_trailing_pad_attributed_to_block() { - let mut out = Vec::new(); - write_blocks(&mut out, &[TaggedBlock::layer_id(1)], Version::Psd, 2).unwrap(); - out.extend_from_slice(&[0; 2]); - let (blocks, trailing) = read_blocks(&mut Reader::new(&out), Version::Psd).unwrap(); - assert_eq!(blocks[0].padding, Some(vec![0, 0])); - assert!(trailing.is_empty()); - } - - #[test] - fn overlong_length_errors() { - let mut out = Vec::new(); - out.put(b"8BIMabcd"); - out.put_u32(100); - out.put(&[0; 4]); - assert!(read_blocks(&mut Reader::new(&out), Version::Psd).is_err()); - } -} diff --git a/crates/psd/src/testgen.rs b/crates/psd/src/testgen.rs deleted file mode 100644 index 4898984445..0000000000 --- a/crates/psd/src/testgen.rs +++ /dev/null @@ -1,384 +0,0 @@ -//! Deterministic synthetic PSD/PSB generator for tests and fuzz seeds. -//! -//! Covers every depth (1, 8, 16, 32), color mode (Bitmap, Grayscale, -//! Indexed, RGB, CMYK, Lab, Multichannel, Duotone), every compression, -//! layers with masks (incl. real masks and parameters), nested groups, all -//! blend modes, Unicode names, empty (0×0) layers, large-ish bounds, -//! negative offsets, many tagged blocks, descriptors and image resources. - -use crate::blend::BlendMode; -use crate::compression::Compression; -use crate::descriptor::{Descriptor, Id, UnicodeString, Value, VersionedDescriptor}; -use crate::file::{GlobalLayerMask, LayerInfoPlacement, PsdFile}; -use crate::header::{ColorMode, Header, Version, row_bytes}; -use crate::image_data::ImageData; -use crate::layer::{BlendingRanges, ChannelData, LayerFlags, LayerInfo, LayerMask, LayerRecord, MaskData, MaskParameters, RealMask, Rect}; -use crate::resources::{ImageResource, ResolutionInfo, ids, version_info_resource}; -use crate::tagged::{SectionType, TaggedBlock}; - -/// A named generated file. -#[derive(Debug, Clone)] -pub struct Case { - /// Descriptive name. - pub name: String, - /// The model. - pub file: PsdFile, -} - -/// Deterministic pattern of planar samples for a `w × h` plane. -pub fn pattern_plane(w: usize, h: usize, depth: u16, seed: u32) -> Vec { - let mut out = Vec::with_capacity(row_bytes(w, depth) * h); - let s = seed as usize; - for y in 0..h { - match depth { - 1 => { - for bx in 0..row_bytes(w, 1) { - out.push(((bx * 37 + y * 11 + s) as u8) ^ 0x5a); - } - } - 8 => { - for x in 0..w { - // Mix smooth gradients and runs so RLE sees both. - let v = if (x / 4 + y) % 3 == 0 { (s * 17) as u8 } else { (x * 3 + y * 7 + s) as u8 }; - out.push(v); - } - } - 16 => { - for x in 0..w { - let v = ((x * 997 + y * 131 + s * 7919) % 65536) as u16; - out.extend_from_slice(&v.to_be_bytes()); - } - } - _ => { - for x in 0..w { - let v = ((x + y + s) % 17) as f32 / 16.0; - out.extend_from_slice(&v.to_be_bytes()); - } - } - } - } - out -} - -/// Number of channels used for a color mode in generated files. -pub fn mode_channels(mode: ColorMode) -> u16 { - match mode { - ColorMode::Multichannel => 2, - m => m.color_channels().unwrap_or(1), - } -} - -/// Valid depths for a color mode. -pub fn mode_depths(mode: ColorMode) -> &'static [u16] { - match mode { - ColorMode::Bitmap => &[1], - ColorMode::Indexed | ColorMode::Duotone => &[8], - ColorMode::Multichannel | ColorMode::Cmyk | ColorMode::Lab => &[8, 16], - _ => &[8, 16, 32], - } -} - -/// All color modes covered. -pub const MODES: [ColorMode; 8] = - [ColorMode::Bitmap, ColorMode::Grayscale, ColorMode::Indexed, ColorMode::Rgb, ColorMode::Cmyk, ColorMode::Lab, ColorMode::Multichannel, ColorMode::Duotone]; - -fn color_mode_data(mode: ColorMode) -> Vec { - match mode { - ColorMode::Indexed => (0..768u32).map(|i| (i * 7 % 256) as u8).collect(), - // Duotone data is undocumented; any bytes are preserved. - ColorMode::Duotone => (0..24u8).collect(), - _ => Vec::new(), - } -} - -fn base_resources() -> Vec { - let mut named = ImageResource::new(2000, vec![1, 2, 3]); - named.name = b"Path 1".to_vec(); - vec![ - ImageResource::new(ids::RESOLUTION_INFO, ResolutionInfo::from_dpi(72.0).to_bytes()), - ImageResource::new(ids::GLOBAL_ANGLE, 30i32.to_be_bytes().to_vec()), - ImageResource::new(ids::GLOBAL_ALTITUDE, 30i32.to_be_bytes().to_vec()), - ImageResource::new(ids::ICC_PROFILE, vec![0x42; 13]), - ImageResource::new(ids::XMP, b"".to_vec()), - named, - version_info_resource(true), - ] -} - -/// A flattened file (no layer section) with a patterned merged image. -pub fn merged_only(version: Version, mode: ColorMode, depth: u16, compression: Compression, w: u32, h: u32) -> PsdFile { - let channels = mode_channels(mode); - let header = Header::new(version, w, h, channels, depth, mode); - let mut planes = Vec::new(); - for c in 0..channels { - planes.extend(pattern_plane(w as usize, h as usize, depth, u32::from(c))); - } - let image_data = ImageData::encode(compression, &planes, &header).unwrap_or(ImageData::new(compression, Vec::new())); - PsdFile { - header, - color_mode_data: color_mode_data(mode), - resources: base_resources(), - layer_info: None, - layer_info_placement: LayerInfoPlacement::Section, - global_layer_mask: None, - global_blocks: Vec::new(), - layer_mask_trailing: Vec::new(), - image_data, - write_as_psb: false, - } -} - -/// Parameters for a generated raster layer. -struct LayerGen<'a> { - header: &'a Header, - compression: Compression, - rect: Rect, - name: &'a str, - blend: BlendMode, - mask: Option, - seed: u32, -} - -fn raster_layer(g: LayerGen<'_>) -> LayerRecord { - let (w, h) = g.rect.size().unwrap_or((0, 0)); - let depth = g.header.depth; - let v = g.header.version; - let cc = mode_channels(g.header.color_mode) as i16; - let mut channels = Vec::new(); - for id in -1..cc { - let plane = pattern_plane(w, h, depth, g.seed + (id + 1) as u32); - let comp = if id == -1 && g.compression == Compression::Rle { Compression::Zip } else { g.compression }; - channels.push(ChannelData::encode(id, comp, &plane, w, h, depth, v).expect("encode")); - } - let mut mask_data = MaskData::None; - if let Some(m) = g.mask { - let (mw, mh) = m.rect.size().unwrap_or((0, 0)); - channels.push(ChannelData::encode(-2, g.compression, &pattern_plane(mw, mh, depth, g.seed + 99), mw, mh, depth, v).expect("encode")); - if let Some(real) = m.real { - let (rw, rh) = real.rect.size().unwrap_or((0, 0)); - channels.push(ChannelData::encode(-3, g.compression, &pattern_plane(rw, rh, depth, g.seed + 7), rw, rh, depth, v).expect("encode")); - } - mask_data = MaskData::Mask(m); - } - let mut flags = LayerFlags(0); - flags.set_hidden(g.seed % 5 == 4); - LayerRecord { - rect: g.rect, - channels, - blend_mode: g.blend, - opacity: (255 - (g.seed * 13) % 128) as u8, - clipping: u8::from(g.seed % 7 == 3), - flags, - filler: 0, - mask: mask_data, - blending_ranges: BlendingRanges::full(cc as usize), - name: crate::io::encode_legacy_name(g.name), - blocks: vec![TaggedBlock::unicode_name(g.name), TaggedBlock::layer_id(g.seed + 1)], - extra_trailing: Vec::new(), - } -} - -fn group_record(header: &Header, name: &str, kind: SectionType, blend: BlendMode, id: u32) -> LayerRecord { - let cc = mode_channels(header.color_mode) as i16; - let channels = (-1..cc).map(|c| ChannelData::new(c, Some(Compression::Raw), Vec::new())).collect(); - let lsct = if kind == SectionType::BoundingDivider { - TaggedBlock::section_divider(kind, None, None) - } else { - TaggedBlock::section_divider(kind, Some(blend), Some(0)) - }; - LayerRecord { - channels, - blend_mode: blend, - name: crate::io::encode_legacy_name(name), - blending_ranges: BlendingRanges::full(cc as usize), - blocks: vec![TaggedBlock::unicode_name(name), TaggedBlock::layer_id(id), lsct], - ..Default::default() - } -} - -/// A sample versioned descriptor as used by `lfx2`/`SoCo`-style blocks. -pub fn sample_descriptor() -> VersionedDescriptor { - VersionedDescriptor::new( - Descriptor::new("null") - .with("Scl ", Value::UnitFloat { unit: *b"#Prc", value: 100.0 }) - .with("masterFXSwitch", Value::Boolean(true)) - .with( - "DrSh", - Value::Descriptor( - Descriptor::new("DrSh") - .with("enab", Value::Boolean(true)) - .with("Md ", Value::Enumerated { type_id: Id::new("BlnM"), value: Id::new("Mltp") }) - .with("Clr ", Value::Descriptor(Descriptor::new("RGBC").with("Rd ", Value::Double(0.0)))) - .with("lagl", Value::UnitFloat { unit: *b"#Ang", value: 120.0 }) - .with("Nm ", Value::Text(UnicodeString::new_nul("Shadow"))), - ), - ) - .with("list", Value::List(vec![Value::Integer(1), Value::LargeInteger(-2)])), - ) -} - -fn extra_blocks(seed: u32) -> Vec { - let mut lfx2 = vec![0, 0, 0, 0]; - lfx2.extend(sample_descriptor().to_bytes()); - let mut odd = TaggedBlock::new(*b"zOdd", vec![1, 2, 3]); - odd.padding = Some(vec![0, 0, 0]); - let mut unpadded = TaggedBlock::new(*b"zNop", vec![9]); - unpadded.padding = Some(vec![]); - vec![ - TaggedBlock::name_source(*b"cont"), - TaggedBlock::blend_clipped_as_group(true), - TaggedBlock::blend_interior_elements(false), - TaggedBlock::knockout(0), - TaggedBlock::protection(seed % 8), - TaggedBlock::sheet_color((seed % 8) as u16), - TaggedBlock::fill_opacity(200), - TaggedBlock::new(*b"shmd", vec![0, 0, 0, 0]), - TaggedBlock::new(*b"lfx2", lfx2), - TaggedBlock::new(*b"fxrp", vec![0; 16]), - odd, - unpadded, - ] -} - -/// A layered file exercising masks, nested groups, blend modes, names, -/// empty layers, negative offsets and many tagged blocks. -pub fn layered(version: Version, mode: ColorMode, depth: u16, compression: Compression) -> PsdFile { - let (w, h) = (24u32, 16u32); - let channels = mode_channels(mode) + 1; - let header = Header::new(version, w, h, channels, depth, mode); - let mut layers = Vec::new(); - let mut seed = 0u32; - let mut next = |layers: &mut Vec, rect: Rect, name: &str, blend: BlendMode, mask: Option| { - seed += 1; - let mut rec = raster_layer(LayerGen { header: &header, compression, rect, name, blend, mask, seed }); - if seed.is_multiple_of(3) { - rec.blocks.extend(extra_blocks(seed)); - } - layers.push(rec); - }; - // Background. - next(&mut layers, Rect::from_xywh(0, 0, w, h), "Background", BlendMode::Normal, None); - // Negative offsets + simple mask. - next( - &mut layers, - Rect::from_xywh(-5, -3, 10, 7), - "Neg offset \u{1F600}", - BlendMode::Multiply, - Some(LayerMask::new(Rect::from_xywh(-2, -2, 6, 5), 0, LayerMask::FLAG_RELATIVE)), - ); - // Empty layer. - next(&mut layers, Rect::default(), "Empty", BlendMode::Screen, None); - // Large-ish bounds beyond the canvas. - next(&mut layers, Rect::from_xywh(-100, 5, 400, 2), "Wide", BlendMode::Overlay, None); - // Nested groups: outer [ layer, inner [ masked layer (real mask + params) ] ]. - let id0 = 1000; - layers.push(group_record(&header, "", SectionType::BoundingDivider, BlendMode::Normal, id0)); - next(&mut layers, Rect::from_xywh(2, 2, 5, 5), "In outer", BlendMode::Darken, None); - layers.push(group_record(&header, "", SectionType::BoundingDivider, BlendMode::Normal, id0 + 1)); - let real_mask = LayerMask { - rect: Rect::from_xywh(1, 1, 4, 3), - default_color: 255, - flags: LayerMask::FLAG_PARAMETERS, - parameters: Some(MaskParameters { flags: 0b0011, user_density: Some(200), user_feather: Some(1.5), vector_density: None, vector_feather: None }), - real: Some(RealMask { flags: 0, background: 255, rect: Rect::from_xywh(0, 0, 3, 3) }), - trailing: Vec::new(), - real_first: true, - }; - next(&mut layers, Rect::from_xywh(3, 3, 6, 4), "Grüße", BlendMode::ColorDodge, Some(real_mask)); - layers.push(group_record(&header, "Inner", SectionType::ClosedFolder, BlendMode::Normal, id0 + 2)); - layers.push(group_record(&header, "Outer", SectionType::OpenFolder, BlendMode::PassThrough, id0 + 3)); - // One small layer per blend mode. - for (i, m) in BlendMode::ALL.iter().enumerate() { - let name = format!("blend {:?}", m); - next(&mut layers, Rect::from_xywh(i as i32 % 20, i as i32 % 12, 3, 2), &name, *m, None); - } - next(&mut layers, Rect::from_xywh(0, 0, 2, 2), "unknown blend", BlendMode::Unknown(*b"zzzz"), None); - - let mut planes = Vec::new(); - for c in 0..channels { - planes.extend(pattern_plane(w as usize, h as usize, depth, 50 + u32::from(c))); - } - let image_data = ImageData::encode(compression, &planes, &header).expect("encode merged"); - let layer_info = LayerInfo { merged_alpha: true, layers, padding: None }; - let (placement, blocks) = match depth { - 16 | 32 => ( - LayerInfoPlacement::GlobalBlock { index: 1, signature: *b"8BIM", key: if depth == 16 { *b"Lr16" } else { *b"Lr32" }, padding: None }, - vec![TaggedBlock::new(*b"Patt", vec![]), TaggedBlock::new(*b"Txt2", vec![0, 1, 2, 3])], - ), - _ => (LayerInfoPlacement::Section, vec![TaggedBlock::new(*b"Patt", vec![]), TaggedBlock::new(*b"FMsk", vec![0; 10])]), - }; - PsdFile { - header, - color_mode_data: color_mode_data(mode), - resources: base_resources(), - layer_info: Some(layer_info), - layer_info_placement: placement, - global_layer_mask: Some(GlobalLayerMask { data: vec![0, 0, 0xff, 0xff, 0, 0, 0, 0, 0, 0, 0, 50, 128, 0] }), - global_blocks: blocks, - layer_mask_trailing: Vec::new(), - image_data, - write_as_psb: false, - } -} - -/// A tiny layered RGB file (useful for truncation sweeps). -pub fn small(version: Version, compression: Compression) -> PsdFile { - let header = Header::new(version, 4, 3, 4, 8, ColorMode::Rgb); - let mask = LayerMask::new(Rect::from_xywh(0, 0, 2, 2), 0, 0); - let layers = vec![ - raster_layer(LayerGen { - header: &header, - compression, - rect: Rect::from_xywh(0, 0, 4, 3), - name: "a", - blend: BlendMode::Normal, - mask: Some(mask), - seed: 1, - }), - group_record(&header, "", SectionType::BoundingDivider, BlendMode::Normal, 9), - raster_layer(LayerGen { header: &header, compression, rect: Rect::from_xywh(-1, 1, 2, 2), name: "b", blend: BlendMode::Screen, mask: None, seed: 2 }), - group_record(&header, "g", SectionType::OpenFolder, BlendMode::PassThrough, 10), - ]; - let mut planes = Vec::new(); - for c in 0..4 { - planes.extend(pattern_plane(4, 3, 8, c)); - } - PsdFile { - header: header.clone(), - color_mode_data: Vec::new(), - resources: vec![ImageResource::new(ids::RESOLUTION_INFO, ResolutionInfo::from_dpi(72.0).to_bytes())], - layer_info: Some(LayerInfo { merged_alpha: true, layers, padding: None }), - layer_info_placement: LayerInfoPlacement::Section, - global_layer_mask: Some(GlobalLayerMask::default()), - global_blocks: vec![TaggedBlock::new(*b"Patt", vec![1, 2])], - layer_mask_trailing: Vec::new(), - image_data: ImageData::encode(compression, &planes, &header).expect("encode"), - write_as_psb: false, - } -} - -/// Every generated case. -pub fn all_cases() -> Vec { - let mut v = Vec::new(); - for version in [Version::Psd, Version::Psb] { - for mode in MODES { - for &depth in mode_depths(mode) { - for c in Compression::ALL { - v.push(Case { name: format!("merged {version:?} {mode:?} {depth}bit {c:?}"), file: merged_only(version, mode, depth, c, 13, 7) }); - } - } - } - for mode in [ColorMode::Grayscale, ColorMode::Rgb, ColorMode::Cmyk, ColorMode::Lab] { - for &depth in mode_depths(mode).iter().filter(|&&d| d >= 8) { - for c in Compression::ALL { - v.push(Case { name: format!("layered {version:?} {mode:?} {depth}bit {c:?}"), file: layered(version, mode, depth, c) }); - } - } - } - for c in Compression::ALL { - v.push(Case { name: format!("small {version:?} {c:?}"), file: small(version, c) }); - } - v.push(Case { name: format!("1x1 canvas {version:?}"), file: merged_only(version, ColorMode::Rgb, 8, Compression::Rle, 1, 1) }); - } - v -} diff --git a/crates/psd/src/tiff.rs b/crates/psd/src/tiff.rs deleted file mode 100644 index 9fadf9a00c..0000000000 --- a/crates/psd/src/tiff.rs +++ /dev/null @@ -1,339 +0,0 @@ -//! Photoshop layer data embedded in TIFF files (layered TIFF). -//! -//! Photoshop keeps a layered TIFF's layers in two private tags, described by Adobe's -//! *Photoshop TIFF Technical Notes* (March 2002) together with the PSD specification: -//! -//! * **37724 `ImageSourceData`**: the string `"Adobe Photoshop Document Data Block\0"` -//! (`"Adobe Photoshop Document Data V0002\0"` for PSB-sized data, whose long keys carry -//! 8-byte lengths) followed by tagged blocks in the usual `8BIM` + key + length + data form, -//! each padded to a multiple of 4. `Layr` (8-bit), `Lr16` and `Lr32` hold the layer info, the -//! same structure as a PSD's layer info section; `LMsk` holds the global layer mask; every other -//! block is a global additional-layer-information block (`Patt`, `Anno`, `FMsk`, `Txt2`, …). -//! * **34377**: the PSD image resources (`8BIM` resource blocks, without the section length). -//! -//! **Byte order.** Adobe's note is silent, but Photoshop files show (and open-source readers such as psdtags -//! implement) that the block structure follows the TIFF's byte order: in an Intel-order (`II`) -//! file every integer and double is little-endian and every four-character signature and key is -//! stored reversed (`MIB8`, `ryaL`), while the pixel samples inside channel data stay big-endian -//! as in a PSD, and RLE row-count tables follow the container. [`ImageSourceData::from_bytes`] -//! normalizes either order to the big-endian PSD model, and [`ImageSourceData::to_bytes`] writes -//! either order, by transcoding every structure it knows field by field ([`transcode`]). -//! A block the transcoder does not know is never dropped on the way in: its bytes are kept -//! verbatim with the signature `MIB8` (`46B8` for `8B64`) marking that they are in little-endian -//! layout ([`TaggedBlock::is_foreign_order`]), so a later save to a little-endian TIFF writes it -//! back exactly. Only a save to the *other* byte order has to drop such a block, with a warning: -//! a byte-swapped block passed through unchanged would be read as garbage by every other -//! application (and would end the block list for readers that stop at a bad signature). - -use crate::descriptor::MAX_DEPTH; -use crate::error::{PsdError, Result}; -use crate::file::{GlobalLayerMask, LayerInfoPlacement, PsdFile}; -use crate::header::{Header, Version}; -use crate::image_data::ImageData; -use crate::io::{Reader, WriteExt}; -use crate::layer::{LayerInfo, LayerMask, mask_parameter_bytes}; -use crate::resources::ImageResource; -use crate::tagged::{TaggedBlock, read_blocks, uses_long_length}; - -/// The byte order of a TIFF container (and so of the Photoshop data inside it). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub enum ByteOrder { - /// Motorola order (`MM`); the PSD byte order. - #[default] - Big, - /// Intel order (`II`). - Little, -} - -impl ByteOrder { - fn is_little(self) -> bool { - matches!(self, ByteOrder::Little) - } -} - -/// Header string of 37724 data with PSD-sized (4-byte) lengths. -pub const SIGNATURE: &[u8; 36] = b"Adobe Photoshop Document Data Block\0"; -/// Header string of 37724 data with PSB-sized (8-byte) lengths for the long keys. -pub const SIGNATURE_PSB: &[u8; 36] = b"Adobe Photoshop Document Data V0002\0"; - -/// The layer-info block key Photoshop uses for a bit depth. -pub fn layer_info_key(depth: u16) -> [u8; 4] { - match depth { - 16 => *b"Lr16", - 32 => *b"Lr32", - _ => *b"Layr", - } -} - -/// The contents of an `ImageSourceData` tag, in the big-endian PSD model. -#[derive(Debug, Clone, PartialEq)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct ImageSourceData { - /// PSD (4-byte lengths) or PSB (8-byte lengths for the long keys). - pub version: Version, - /// The byte order the data was read in (what [`Self::to_bytes`] should normally write back). - pub byte_order: ByteOrder, - /// The layers, from the `Layr` / `Lr16` / `Lr32` block. - pub layer_info: Option, - /// Which key held the layer info (`Layr` when there was none). - pub layer_info_key: [u8; 4], - /// The `LMsk` block. - pub global_layer_mask: Option, - /// Every other block, in stored order. - pub global_blocks: Vec, - /// Bytes after the last block that do not form a block. - pub trailing: Vec, -} - -impl Default for ImageSourceData { - fn default() -> Self { - ImageSourceData { - version: Version::Psd, - byte_order: ByteOrder::Big, - layer_info: None, - layer_info_key: *b"Layr", - global_layer_mask: None, - global_blocks: Vec::new(), - trailing: Vec::new(), - } - } -} - -fn block_order(rest: &[u8]) -> Result { - match rest.get(..4) { - None | Some(b"8BIM") | Some(b"8B64") => Ok(ByteOrder::Big), - Some(b"MIB8") | Some(b"46B8") => Ok(ByteOrder::Little), - Some(other) => { - Err(PsdError::InvalidSignature { expected: "8BIM or MIB8 (Photoshop image source data block)", found: [other[0], other[1], other[2], other[3]] }) - } - } -} - -impl ImageSourceData { - /// Parses a 37724 tag. Returns the data together with warnings about blocks that were - /// dropped because they could not be converted from the file's byte order. - pub fn from_bytes(data: &[u8]) -> Result<(Self, Vec)> { - let (version, rest) = match data.get(..36) { - Some(s) if s == SIGNATURE => (Version::Psd, &data[36..]), - Some(s) if s == SIGNATURE_PSB => (Version::Psb, &data[36..]), - _ => return Err(PsdError::invalid("not Photoshop image source data (missing \"Adobe Photoshop Document Data Block\")")), - }; - let byte_order = block_order(rest)?; - let mut warnings = Vec::new(); - let big; - let rest = if byte_order.is_little() { - let (b, w) = transcode::transcode_blocks(rest, version, ByteOrder::Little, ByteOrder::Big)?; - warnings = w; - big = b; - &big[..] - } else { - rest - }; - let (mut blocks, trailing) = read_blocks(&mut Reader::new(rest), version)?; - // Blocks in a TIFF are padded to 4 (`to_bytes` re-pads them), so zero padding is the - // canonical default here, as even-length padding is in a PSD. - for b in &mut blocks { - if b.padding.as_ref().is_some_and(|p| p.len() <= 3 && p.iter().all(|&x| x == 0)) { - b.padding = None; - } - } - let mut layer_info = None; - let mut layer_info_key = *b"Layr"; - if let Some(i) = blocks.iter().position(|b| matches!(&b.key, b"Layr" | b"Lr16" | b"Lr32")) { - let b = blocks.remove(i); - layer_info = Some(LayerInfo::read_body(&b.data, version)?); - layer_info_key = b.key; - } - let global_layer_mask = blocks.iter().position(|b| &b.key == b"LMsk").map(|i| GlobalLayerMask { data: blocks.remove(i).data }); - Ok((ImageSourceData { version, byte_order, layer_info, layer_info_key, global_layer_mask, global_blocks: blocks, trailing }, warnings)) - } - - /// Serializes the data in `order`. Returns the bytes and warnings about blocks that were - /// dropped because they could not be converted to a little-endian layout. - pub fn to_bytes(&self, order: ByteOrder) -> Result<(Vec, Vec)> { - Self::finish(self.body()?, self.version, order) - } - - /// [`Self::to_bytes`] that frees the layers once their block is written, before the bytes - /// are converted to `order` (a copy of them all): a layered TIFF save holds them one time - /// fewer (#6607). - pub fn into_bytes(self, order: ByteOrder) -> Result<(Vec, Vec)> { - let (body, version) = (self.body()?, self.version); - drop(self); - Self::finish(body, version, order) - } - - /// The signature, then the blocks in big-endian order. - fn body(&self) -> Result> { - let version = self.version; - let signature = if version.is_psb() { SIGNATURE_PSB } else { SIGNATURE }; - let len_field = |key: &[u8; 4]| if uses_long_length(version, key) { 8u64 } else { 4 }; - // Reserved at its exact length, as the buffer holds all the layers' pixels: one that is a - // few bytes short doubles at its last append, copying them once more (#6607). - let mut len = signature.len() as u64; - let info_pad = match &self.layer_info { - Some(info) => { - let pad = info.pad_len(version, 4)?; - len = len.saturating_add(8 + len_field(&self.layer_info_key)).saturating_add(info.unpadded_len(version)?).saturating_add(pad as u64); - pad - } - None => 0, - }; - if let Some(m) = &self.global_layer_mask { - let pad = TaggedBlock::default_padding(m.data.len(), 4); - len = len.saturating_add(8 + len_field(b"LMsk")).saturating_add(m.data.len() as u64).saturating_add(pad as u64); - } - len = self.global_blocks.iter().fold(len, |n, b| n.saturating_add(b.encoded_len_aligned(version, 4) as u64)); - len = len.saturating_add(self.trailing.len() as u64); - let no_memory = || PsdError::OutOfMemory("not enough memory for the layer data"); - let mut body = Vec::new(); - body.try_reserve_exact(usize::try_from(len).map_err(|_| no_memory())?).map_err(|_| no_memory())?; - body.put(signature); - if let Some(info) = &self.layer_info { - // Written straight into the body, padded to 4 inside its length (as a padded copy - // would be): a separate buffer for it would hold all the pixels a second time. - let long = uses_long_length(version, &self.layer_info_key); - body.put(b"8BIM"); - body.put(&self.layer_info_key); - let at = body.begin_len(long); - info.write_unpadded(&mut body, version)?; - body.resize(body.len() + info_pad, 0); - body.end_len(at, long)?; - } - // Blocks without explicit padding are padded to 4 as they are written (alignment 4), so - // the global mask and blocks are written from the model, without a copy of their data. - // The signature stays (a foreign-order marker included). - if let Some(m) = &self.global_layer_mask { - TaggedBlock::write_parts(&mut body, *b"8BIM", *b"LMsk", &m.data, None, version, 4)?; - } - for b in &self.global_blocks { - b.write(&mut body, version, 4)?; - } - body.put(&self.trailing); - Ok(body) - } - - /// The `body` (signature included) converted to `order`. - fn finish(body: Vec, version: Version, order: ByteOrder) -> Result<(Vec, Vec)> { - if !order.is_little() { - return Ok((body, Vec::new())); - } - let signature = if version.is_psb() { SIGNATURE_PSB } else { SIGNATURE }; - let blocks = body.get(signature.len()..).ok_or_else(|| PsdError::invalid("image source data without its signature"))?; - // Converted behind the signature, into one buffer sized for both. - transcode::transcode_blocks_after(signature, blocks, version, ByteOrder::Big, ByteOrder::Little) - } - - /// The layers, global mask and global blocks of a PSD as image source data (byte order Big). - pub fn from_psd(file: &PsdFile) -> Self { - let key = match &file.layer_info_placement { - LayerInfoPlacement::GlobalBlock { key, .. } => *key, - LayerInfoPlacement::Section => layer_info_key(file.header.depth), - }; - ImageSourceData { - version: file.header.version, - byte_order: ByteOrder::Big, - layer_info: file.layer_info.clone(), - layer_info_key: key, - global_layer_mask: file.global_layer_mask.clone(), - global_blocks: file.global_blocks.clone(), - trailing: file.layer_mask_trailing.clone(), - } - } - - /// [`Self::from_psd`] that moves the layers, global mask and global blocks out of `file` - /// instead of copying them: a layered TIFF save would otherwise hold every layer's pixels - /// twice (#6607). `file` keeps its header, resources and merged image. - pub fn take_from_psd(file: &mut PsdFile) -> Self { - let key = match &file.layer_info_placement { - LayerInfoPlacement::GlobalBlock { key, .. } => *key, - LayerInfoPlacement::Section => layer_info_key(file.header.depth), - }; - ImageSourceData { - version: file.header.version, - byte_order: ByteOrder::Big, - layer_info: file.layer_info.take(), - layer_info_key: key, - global_layer_mask: file.global_layer_mask.take(), - global_blocks: std::mem::take(&mut file.global_blocks), - trailing: std::mem::take(&mut file.layer_mask_trailing), - } - } - - /// Assembles a PSD model from this data plus the parts a TIFF stores natively: the header - /// (dimensions, depth, colour mode, channel count), the image resources (tag 34377) and the - /// merged image (the TIFF's pixels). - pub fn into_psd(self, header: Header, resources: Vec, image_data: ImageData) -> PsdFile { - let placement = if matches!(&self.layer_info_key, b"Lr16" | b"Lr32") && self.layer_info.is_some() { - LayerInfoPlacement::GlobalBlock { index: 0, signature: *b"8BIM", key: self.layer_info_key, padding: None } - } else { - LayerInfoPlacement::Section - }; - PsdFile { - header, - color_mode_data: Vec::new(), - resources, - layer_info: self.layer_info, - layer_info_placement: placement, - global_layer_mask: self.global_layer_mask, - global_blocks: self.global_blocks, - layer_mask_trailing: self.trailing, - image_data, - write_as_psb: false, - } - } -} - -/// A block padded to a multiple of 4, as Photoshop writes them in TIFFs. -#[cfg(test)] -fn padded_block(key: [u8; 4], data: Vec) -> TaggedBlock { - let pad = (4 - data.len() % 4) % 4; - TaggedBlock { signature: *b"8BIM", key, data, padding: Some(vec![0; pad]) } -} - -/// Parses a 34377 tag (image resources). Little-endian resource blocks (`MIB8`) are converted; -/// resource payloads other than the typed numeric ones are kept as stored. -pub fn resources_from_bytes(data: &[u8]) -> Result<(Vec, Vec)> { - let order = block_order(data)?; - let mut warnings = Vec::new(); - let big; - let data = if order.is_little() { - let (b, w) = transcode::transcode_resources(data, ByteOrder::Little, ByteOrder::Big)?; - warnings = w; - big = b; - &big[..] - } else { - data - }; - let mut r = Reader::new(data); - let mut out = Vec::new(); - while !r.is_empty() { - // Photoshop pads the tag to an even length; a lone trailing byte is not a resource. - if r.remaining() < 12 && r.peek_rest().iter().all(|&b| b == 0) { - break; - } - out.push(ImageResource::read(&mut r)?); - } - Ok((out, warnings)) -} - -/// Serializes image resources for a 34377 tag (big-endian, which every reader expects). -pub fn resources_to_bytes(resources: &[ImageResource]) -> Result> { - let mut out = Vec::new(); - for r in resources { - r.write(&mut out)?; - } - Ok(out) -} - -/// Field-by-field byte-order conversion of Photoshop structures. -/// -/// Every function reads one structure in the source order and writes it in the target order, -/// so output lengths equal input lengths and every length field stays valid. Layouts follow the -/// Adobe PSD specification; the walker only needs to know where the integers, doubles and -/// four-character keys are, not what they mean. -pub mod transcode; - -#[cfg(test)] -mod tests; diff --git a/crates/psd/src/tiff/tests.rs b/crates/psd/src/tiff/tests.rs deleted file mode 100644 index 533e534430..0000000000 --- a/crates/psd/src/tiff/tests.rs +++ /dev/null @@ -1,1043 +0,0 @@ -use super::transcode::*; -use super::*; -use crate::compression::Compression; -use crate::descriptor::{Descriptor, Id, UnicodeString, Value, VersionedDescriptor}; -use crate::header::ColorMode; -use crate::testgen; - -/// Keys the generator plants that the transcoder (rightly) does not know. -const UNKNOWN: [&[u8; 4]; 3] = [b"zOdd", b"zNop", b"Txt2"]; - -/// #6607: moving the layers out of a PSD writes the bytes copying them does, and leaves the -/// rest of the file (its merged image) as it was. The big-endian body is reserved at its exact -/// length, so the buffer is never grown (a copy of every layer's pixels). -#[test] -fn taking_the_layers_writes_the_same_bytes_as_copying_them() { - for case in testgen::all_cases() { - for order in [ByteOrder::Big, ByteOrder::Little] { - let copied = ImageSourceData::from_psd(&case.file).to_bytes(order); - let mut file = case.file.clone(); - let taken = ImageSourceData::take_from_psd(&mut file).into_bytes(order); - match (copied, taken) { - (Ok(a), Ok(b)) => { - assert_eq!(a, b, "{} {order:?}", case.name); - if !order.is_little() { - assert_eq!(b.0.capacity(), b.0.len(), "{}: the body grew", case.name); - } - } - (a, b) => assert_eq!(a.is_err(), b.is_err(), "{} {order:?}", case.name), - } - assert!(file.layer_info.is_none() && file.global_blocks.is_empty() && file.global_layer_mask.is_none(), "{}", case.name); - assert_eq!(file.image_data, case.file.image_data, "{}", case.name); - assert_eq!(file.header, case.file.header, "{}", case.name); - } - } -} - -/// `d` without the blocks a little-endian round trip drops, and without the layer-info -/// padding (which follows the body length, so it changes when blocks are dropped). -fn known(d: &ImageSourceData) -> ImageSourceData { - let mut d = d.clone(); - d.byte_order = ByteOrder::Big; - d.global_blocks.retain(|b| !UNKNOWN.contains(&&b.key)); - if d.layer_info.is_none() { - d.layer_info_key = *b"Layr"; - } - if let Some(info) = &mut d.layer_info { - info.padding = None; - for l in &mut info.layers { - l.blocks.retain(|b| !UNKNOWN.contains(&&b.key)); - } - } - d -} - -fn roundtrip(d: &ImageSourceData) -> ImageSourceData { - let (big, w) = d.to_bytes(ByteOrder::Big).unwrap(); - assert!(w.is_empty(), "{w:?}"); - let (little, w) = d.to_bytes(ByteOrder::Little).unwrap(); - assert!(w.iter().all(|w| UNKNOWN.iter().any(|k| w.starts_with(std::str::from_utf8(*k).unwrap()))), "{w:?}"); - if w.is_empty() { - assert_eq!(big.len(), little.len()); - } - if big.len() > 36 { - assert_ne!(big, little); - assert!(little[36..].starts_with(b"MIB8")); - } - let (back_big, w) = ImageSourceData::from_bytes(&big).unwrap(); - assert!(w.is_empty(), "{w:?}"); - let (back_little, w) = ImageSourceData::from_bytes(&little).unwrap(); - assert!(w.is_empty(), "{w:?}"); - if little.len() > 36 { - assert_eq!(back_little.byte_order, ByteOrder::Little); - } - assert_eq!(known(&back_little), known(&back_big)); - // The little-endian bytes are a fixed point (from the second generation on when blocks - // were dropped, since the layer-info padding then follows the shorter body). - let (little2, _) = back_little.to_bytes(ByteOrder::Little).unwrap(); - let little3 = ImageSourceData::from_bytes(&little2).unwrap().0.to_bytes(ByteOrder::Little).unwrap().0; - assert_eq!(little3, little2); - back_big -} - -#[test] -fn generated_files_survive_both_orders() { - for case in testgen::all_cases() { - let d = ImageSourceData::from_psd(&case.file); - let back = roundtrip(&d); - assert_eq!(back.version, d.version, "{}", case.name); - assert_eq!(known(&back), known(&d), "{}", case.name); - // Nothing but the planted unknown blocks is lost on the little-endian path. - let (little, _) = d.to_bytes(ByteOrder::Little).unwrap(); - let (back, _) = ImageSourceData::from_bytes(&little).unwrap(); - assert_eq!(known(&back), known(&d), "{}", case.name); - } -} - -#[test] -fn same_order_transcoding_is_the_identity() { - for case in testgen::all_cases() { - let d = ImageSourceData::from_psd(&case.file); - let (big, _) = d.to_bytes(ByteOrder::Big).unwrap(); - let (same, w) = transcode_blocks(&big[36..], d.version, ByteOrder::Big, ByteOrder::Big).unwrap(); - assert!(w.is_empty()); - assert_eq!(same, big[36..].to_vec(), "{}", case.name); - } -} - -fn sample_layers() -> ImageSourceData { - let file = testgen::layered(Version::Psd, ColorMode::Rgb, 8, Compression::Rle); - ImageSourceData::from_psd(&file) -} - -#[test] -fn signature_selects_the_version() { - let mut d = sample_layers(); - let (b, _) = d.to_bytes(ByteOrder::Big).unwrap(); - assert!(b.starts_with(SIGNATURE)); - d.version = Version::Psb; - let (b, _) = d.to_bytes(ByteOrder::Big).unwrap(); - assert!(b.starts_with(SIGNATURE_PSB)); - assert_eq!(ImageSourceData::from_bytes(&b).unwrap().0.version, Version::Psb); - roundtrip(&d); -} - -#[test] -fn layer_key_follows_depth() { - assert_eq!(layer_info_key(8), *b"Layr"); - assert_eq!(layer_info_key(16), *b"Lr16"); - assert_eq!(layer_info_key(32), *b"Lr32"); - for (depth, comp) in [(16, Compression::ZipPrediction), (32, Compression::ZipPrediction), (16, Compression::Rle)] { - let file = testgen::layered(Version::Psd, ColorMode::Rgb, depth, comp); - let d = ImageSourceData::from_psd(&file); - assert_eq!(d.layer_info_key, layer_info_key(depth)); - let back = roundtrip(&d); - assert_eq!(back.layer_info_key, d.layer_info_key); - // The 16/32-bit model goes back into a PSD as a global block, like Photoshop's. - let psd = back.into_psd(file.header.clone(), file.resources.clone(), file.image_data.clone()); - assert!(matches!(psd.layer_info_placement, LayerInfoPlacement::GlobalBlock { .. })); - let again = PsdFile::from_bytes(&psd.to_bytes().unwrap()).unwrap(); - assert_eq!(known(&ImageSourceData::from_psd(&again)).layer_info, known(&d).layer_info); - let (little, _) = d.to_bytes(ByteOrder::Little).unwrap(); - let (back, _) = ImageSourceData::from_bytes(&little).unwrap(); - assert_eq!(known(&back).layer_info, known(&d).layer_info, "depth {depth}"); - } -} - -#[test] -fn blocks_are_padded_to_four() { - let d = sample_layers(); - let (b, _) = d.to_bytes(ByteOrder::Big).unwrap(); - let mut at = 36; - while at < b.len() { - assert_eq!(&b[at..at + 4], b"8BIM"); - let len = u32::from_be_bytes(b[at + 8..at + 12].try_into().unwrap()) as usize; - at += 12 + len; - at += (4 - at % 4) % 4; - } - assert_eq!(at, b.len()); -} - -fn block_rt(key: [u8; 4], data: Vec) -> (Vec, Vec) { - let d = ImageSourceData { global_blocks: vec![TaggedBlock::new(key, data.clone())], ..Default::default() }; - let (big, _) = d.to_bytes(ByteOrder::Big).unwrap(); - let (little, w) = d.to_bytes(ByteOrder::Little).unwrap(); - assert!(w.is_empty(), "{w:?}"); - let (back, w) = ImageSourceData::from_bytes(&little).unwrap(); - assert!(w.is_empty(), "{w:?}"); - assert_eq!(back.global_blocks[0].data, data); - assert_eq!(back.to_bytes(ByteOrder::Big).unwrap().0, big); - (big, little) -} - -#[test] -fn descriptor_blocks_swap_keys_and_numbers() { - let mut desc = Descriptor::new("null"); - desc.items.push((Id::new("Opct"), Value::UnitFloat { unit: *b"#Prc", value: 75.5 })); - desc.items.push((Id::new("Nm "), Value::Text(UnicodeString::new_nul("Drop Shadow")))); - desc.items.push((Id::new("enab"), Value::Boolean(true))); - desc.items.push((Id::new("Md "), Value::Enumerated { type_id: Id::new("BlnM"), value: Id::new("Mltp") })); - desc.items.push((Id::new("list"), Value::List(vec![Value::Integer(-3), Value::Double(1.5), Value::LargeInteger(7)]))); - desc.items.push((Id::new("longKeyName"), Value::Descriptor(Descriptor::new("innr")))); - let mut data = 0u32.to_be_bytes().to_vec(); - data.extend(VersionedDescriptor::new(desc).to_bytes()); - let (big, little) = block_rt(*b"lfx2", data); - // Keys are reversed and the version field swapped in the little-endian bytes. - assert!(little[36..].starts_with(b"MIB82xfl")); - let body_at = 48; - assert_eq!(&little[body_at..body_at + 4], &[0, 0, 0, 0]); - assert_eq!(&little[body_at + 4..body_at + 8], &[16, 0, 0, 0]); - assert_eq!(&big[body_at + 4..body_at + 8], &[0, 0, 0, 16]); - assert!(little.windows(4).any(|w| w == b"crP#"), "unit key reversed"); - assert!(little.windows(11).any(|w| w == b"longKeyName"), "string ids are not reversed"); -} - -#[test] -fn descriptor_references_by_identifier_and_index_keep_their_class() { - use crate::descriptor::{Class, ReferenceItem}; - let class = || Class { name: UnicodeString::new(""), class_id: Id::new("Lyr ") }; - let desc = Descriptor::new("null") - .with("null", Value::Reference(vec![ReferenceItem::Identifier { class: class(), id: 7 }, ReferenceItem::Index { class: class(), index: 2 }])); - let mut data = 0u32.to_be_bytes().to_vec(); - data.extend(VersionedDescriptor::new(desc).to_bytes()); - block_rt(*b"lfx2", data); -} - -fn type_layer_data() -> Vec { - let mut data = Vec::new(); - data.put_u16(1); - for value in [1.5, 0.0, -0.25, 1.75, 12.5, -3.25] { - data.put_f64(value); - } - data.put_u16(50); - let text = Descriptor::new("TxLr").with("Txt ", Value::Text(UnicodeString::new_nul("Synthetic Ω 🦀"))); - data.extend(VersionedDescriptor::new(text).to_bytes()); - data.put_u16(1); - data.extend(VersionedDescriptor::new(Descriptor::new("warp")).to_bytes()); - for value in [11, -22, 333, 4_444] { - data.put_i32(value); - } - data -} - -fn tagged_payload<'a>(bytes: &'a [u8], signature_and_key: &[u8; 8], little: bool) -> &'a [u8] { - let block_at = bytes.windows(8).position(|window| window == signature_and_key).expect("tagged block"); - let length_at = block_at + 8; - let length_bytes: [u8; 4] = bytes.get(length_at..length_at + 4).expect("tagged block length").try_into().expect("four-byte length"); - let length = if little { u32::from_le_bytes(length_bytes) } else { u32::from_be_bytes(length_bytes) }; - let data_at = length_at + 4; - let data_end = data_at + usize::try_from(length).expect("test block length fits usize"); - bytes.get(data_at..data_end).expect("tagged block payload") -} - -fn legacy_little_endian_type_block() -> Vec { - let mut tysh = Vec::new(); - tysh.extend_from_slice(&1u16.to_le_bytes()); - for value in [1.5f64, 0.0, -0.25, 1.75, 12.5, -3.25] { - tysh.extend_from_slice(&value.to_le_bytes()); - } - tysh.extend_from_slice(&50u16.to_le_bytes()); - // Independently encode a little-endian `VersionedDescriptor` with a single TEXT value. - tysh.extend_from_slice(&16u32.to_le_bytes()); - tysh.extend_from_slice(&0u32.to_le_bytes()); // empty descriptor name - tysh.extend_from_slice(&0u32.to_le_bytes()); // four-character class id - tysh.extend_from_slice(b"rLxT"); // little-endian four-character class id - tysh.extend_from_slice(&1u32.to_le_bytes()); // one item - tysh.extend_from_slice(&0u32.to_le_bytes()); // four-character key - tysh.extend_from_slice(b" txT"); // little-endian four-character key - tysh.extend_from_slice(b"TXET"); // little-endian OSType - let mut units: Vec = "Synthetic Ω 🦀".encode_utf16().collect(); - units.push(0); // `new_nul`, matching the canonical fixture - tysh.extend_from_slice(&u32::try_from(units.len()).expect("short test string").to_le_bytes()); - for unit in units { - tysh.extend_from_slice(&unit.to_le_bytes()); - } - tysh.extend_from_slice(&1u16.to_le_bytes()); - // Empty warp descriptor, also encoded independently in little-endian order. - tysh.extend_from_slice(&16u32.to_le_bytes()); - tysh.extend_from_slice(&0u32.to_le_bytes()); // empty descriptor name - tysh.extend_from_slice(&4u32.to_le_bytes()); // `warp` is a four-byte string id - tysh.extend_from_slice(b"warp"); - tysh.extend_from_slice(&0u32.to_le_bytes()); // no items - for value in [11i32, -22, 333, 4_444] { - tysh.extend_from_slice(&value.to_le_bytes()); - } - - let mut image_source = super::SIGNATURE.to_vec(); - image_source.extend_from_slice(b"MIB8hSyT"); - image_source.extend_from_slice(&u32::try_from(tysh.len()).expect("short test block").to_le_bytes()); - image_source.extend_from_slice(&tysh); - let padding = (4 - tysh.len() % 4) % 4; - image_source.resize(image_source.len() + padding, 0); - image_source -} - -#[test] -fn type_block_write_keeps_text_descriptor_in_psd_order() { - let canonical = type_layer_data(); - let source = ImageSourceData { global_blocks: vec![TaggedBlock::new(*b"TySh", canonical.clone())], ..Default::default() }; - - let (little, warnings) = source.to_bytes(ByteOrder::Little).unwrap(); - assert!(warnings.is_empty(), "{warnings:?}"); - let payload = tagged_payload(&little, b"MIB8hSyT", true); - assert_eq!(payload.get(..2), Some(&[1, 0][..]), "TySh header stays in TIFF order"); - for (index, expected) in [1.5f64, 0.0, -0.25, 1.75, 12.5, -3.25].into_iter().enumerate() { - let start = 2 + index * 8; - let bytes: [u8; 8] = payload.get(start..start + 8).unwrap().try_into().unwrap(); - assert_eq!(f64::from_le_bytes(bytes), expected, "matrix element {index}"); - } - - let (text_descriptor, descriptor_len) = VersionedDescriptor::parse_prefix(canonical.get(52..).unwrap()).unwrap(); - let text_section_len = 2 + descriptor_len; - assert_eq!(payload.get(50..52), Some(&50u16.to_be_bytes()[..])); - assert_eq!(payload.get(52..56), Some(&16u32.to_be_bytes()[..])); - assert_eq!(payload.get(50..50 + text_section_len), canonical.get(50..50 + text_section_len)); - assert!(matches!(text_descriptor.descriptor.get("Txt "), Some(Value::Text(value)) if value.to_string_lossy() == "Synthetic Ω 🦀")); - let text_descriptor_from_output = VersionedDescriptor::parse_prefix(payload.get(52..).unwrap()).unwrap().0; - assert!(matches!(text_descriptor_from_output.descriptor.get("Txt "), Some(Value::Text(value)) if value.to_string_lossy() == "Synthetic Ω 🦀")); - - let warp_at = 50 + text_section_len; - assert_eq!(payload.get(warp_at..warp_at + 2), Some(&1u16.to_le_bytes()[..])); - let (_, warp_len) = VersionedDescriptor::parse_prefix(canonical.get(warp_at + 2..).unwrap()).unwrap(); - let warp_descriptor_at = warp_at + 2; - assert_eq!(payload.get(warp_descriptor_at..warp_descriptor_at + 4), Some(&16u32.to_le_bytes()[..])); - assert_eq!(payload.get(warp_descriptor_at + 4..warp_descriptor_at + 8), Some(&0u32.to_le_bytes()[..])); - assert_eq!(payload.get(warp_descriptor_at + 8..warp_descriptor_at + 12), Some(&4u32.to_le_bytes()[..])); - assert_eq!(payload.get(warp_descriptor_at + 12..warp_descriptor_at + 16), Some(b"warp".as_slice())); - assert_eq!(payload.get(warp_descriptor_at + 16..warp_descriptor_at + 20), Some(&0u32.to_le_bytes()[..])); - let bounds_at = warp_descriptor_at + warp_len; - for (index, expected) in [11i32, -22, 333, 4_444].into_iter().enumerate() { - let start = bounds_at + index * 4; - assert_eq!(payload.get(start..start + 4), Some(&expected.to_le_bytes()[..]), "bound {index}"); - } - - let (big, warnings) = source.to_bytes(ByteOrder::Big).unwrap(); - assert!(warnings.is_empty(), "{warnings:?}"); - assert_eq!(tagged_payload(&big, b"8BIMTySh", false), canonical); - - let (reopened, warnings) = ImageSourceData::from_bytes(&little).unwrap(); - assert!(warnings.is_empty(), "{warnings:?}"); - assert_eq!(reopened.global_blocks[0].data, canonical); - assert_eq!(reopened.to_bytes(ByteOrder::Big).unwrap().0, big); -} - -#[test] -fn legacy_little_endian_type_descriptor_still_imports() { - let input = legacy_little_endian_type_block(); - let (reopened, warnings) = ImageSourceData::from_bytes(&input).unwrap(); - assert!(warnings.is_empty(), "{warnings:?}"); - assert_eq!(reopened.global_blocks[0].data, type_layer_data()); -} - -#[test] -fn type_block_layout() { - let mut data = Vec::new(); - data.put_u16(1); - for v in [1.0, 0.0, 0.0, 1.0, 12.5, -3.0] { - data.put_f64(v); - } - data.put_u16(50); - data.extend(VersionedDescriptor::new(Descriptor::new("TxLr")).to_bytes()); - data.put_u16(1); - data.extend(VersionedDescriptor::new(Descriptor::new("warp")).to_bytes()); - for v in [0.0, 0.0, 100.0, 20.0] { - data.put_f64(v); - } - let (_, little) = block_rt(*b"TySh", data); - assert!(little[48..].starts_with(&[1, 0])); - assert_eq!(&little[50..58], &1.0f64.to_le_bytes()); -} - -#[test] -fn photoshop_type_block_switches_to_big_endian_after_its_matrix() { - let mut data = Vec::new(); - data.put_u16(1); - for v in [1.8054202887117468, 0.0, -0.143, 1.8063853899082987, 247.25, 2864.5] { - data.put_f64(v); - } - data.put_u16(50); - let text = Descriptor::new("TxLr").with("Txt ", Value::Text(UnicodeString::new_nul("Synthetic headline"))); - data.extend(VersionedDescriptor::new(text).to_bytes()); - data.put_u16(1); - data.extend(VersionedDescriptor::new(Descriptor::new("warp")).to_bytes()); - for v in [0, 0, 120, 30] { - data.put_i32(v); - } - let (_, mut little) = block_rt(*b"TySh", data.clone()); - // Photoshop uses big-endian text but returns to little-endian for warp/bounds. - let (_, used) = VersionedDescriptor::parse_prefix(&data[52..]).unwrap(); - little[98..100 + used].copy_from_slice(&data[50..52 + used]); - let (back, warnings) = ImageSourceData::from_bytes(&little).unwrap(); - assert!(warnings.is_empty(), "{warnings:?}"); - assert!(!back.global_blocks[0].is_foreign_order()); - assert_eq!(back.global_blocks[0].data, data); - // The normalized model remains writable in either order. - for order in [ByteOrder::Big, ByteOrder::Little] { - let (bytes, warnings) = back.to_bytes(order).unwrap(); - assert!(warnings.is_empty()); - let (again, warnings) = ImageSourceData::from_bytes(&bytes).unwrap(); - assert!(warnings.is_empty()); - assert_eq!(again.global_blocks[0].data, data); - } -} - -#[test] -fn legacy_effects_and_adjustments() { - // lrFX with a drop shadow and a solid fill. - let mut fx = Vec::new(); - fx.put_u16(0); - fx.put_u16(3); - fx.put(b"8BIMcmnS"); - fx.put_u32(7); - fx.put_u32(0); - fx.put_u8(1); - fx.put_u16(0); - fx.put(b"8BIMdsdw"); - let mut sh = Vec::new(); - sh.put_u32(2); - for v in [5 << 16, 75 << 16, 120 << 16, 7 << 16] { - sh.put_i32(v); - } - for _ in 0..5 { - sh.put_u16(0); - } - sh.put(b"8BIMmul "); - sh.put_u8(1); - sh.put_u8(1); - sh.put_u8(191); - sh.put_u16(0); - sh.put_u16(0x1234); - sh.put_u16(0); - sh.put_u16(0); - sh.put_u16(0); - fx.put_u32(sh.len() as u32); - fx.put(&sh); - fx.put(b"8BIMsofi"); - let mut so = Vec::new(); - so.put_u32(2); - so.put(b"8BIMnorm"); - so.put_u16(0); - so.put_u16(0xffff); - so.put_u16(0); - so.put_u16(0); - so.put_u16(0); - so.put_u8(255); - so.put_u8(1); - so.put_u16(0); - so.put_u16(0xffff); - so.put_u16(0); - so.put_u16(0); - so.put_u16(0); - fx.put_u32(so.len() as u32); - fx.put(&so); - let (_, little) = block_rt(*b"lrFX", fx); - assert!(little.windows(8).any(|w| w == b"MIB8wdsd"), "effect keys reversed"); - assert!(little.windows(2).any(|w| w == [0x34, 0x12]), "colour component swapped"); - - // levl: version + 29 records + the Lvls extension. - let mut lv = Vec::new(); - lv.put_u16(2); - for i in 0..29 * 5 { - lv.put_u16(i as u16); - } - lv.put(b"Lvls"); - lv.put_u16(3); - lv.put_u16(33); - for i in 0..4 * 5 { - lv.put_u16(1000 + i as u16); - } - let (_, little) = block_rt(*b"levl", lv); - assert!(little.windows(4).any(|w| w == b"slvL")); - - // curv with the Crv extension. - let mut cv = vec![0u8]; - cv.put_u16(1); - cv.put_u32(0b101); - cv.put_u16(2); - cv.put_u16(0); - cv.put_u16(0); - cv.put_u16(255); - cv.put_u16(255); - cv.put_u16(1); - cv.put_u16(128); - cv.put_u16(100); - cv.put(b"Crv "); - cv.put_u16(4); - cv.put_u32(1); - cv.put_u16(2); - cv.put_u16(1); - cv.put_u16(7); - cv.put_u16(9); - block_rt(*b"curv", cv); - - for (key, data) in [ - (*b"hue2", vec![0, 2, 0, 0, 0, 10, 0, 20, 0, 30, 0, 40, 0, 50, 0, 60]), - (*b"brit", vec![0, 10, 0, 20, 0, 30, 1, 0]), - (*b"thrs", vec![0, 128, 0, 0]), - (*b"post", vec![0, 4, 0, 0]), - (*b"nvrt", vec![]), - (*b"expA", [1u16.to_be_bytes().to_vec(), 0.5f32.to_be_bytes().to_vec(), 0.0f32.to_be_bytes().to_vec(), 1.0f32.to_be_bytes().to_vec()].concat()), - (*b"blnc", vec![0; 20]), - (*b"mixr", vec![0, 1, 0, 0, 0, 100, 0, 0, 0, 0, 0, 0, 0, 0]), - (*b"selc", vec![0, 1, 0, 0, 0, 5, 0, 6, 0, 7, 0, 8]), - (*b"phfl", vec![0, 3, 0, 0, 1, 0, 0, 0, 2, 0, 0, 0, 3, 0, 0, 0, 25, 1, 0]), - (*b"phfl", vec![0, 2, 0, 0, 1, 0, 2, 0, 3, 0, 4, 0, 0, 0, 25, 1, 0]), - (*b"lclr", vec![0, 1, 0, 0, 0, 0, 0, 0]), - (*b"fxrp", [1.5f64.to_be_bytes().to_vec(), 2.5f64.to_be_bytes().to_vec()].concat()), - (*b"brst", vec![0, 0, 0, 1, 0, 0, 0, 2]), - (*b"FMsk", vec![0, 0, 255, 255, 0, 0, 0, 0, 0, 0, 0, 50]), - (*b"lsct", [3u32.to_be_bytes().to_vec(), b"8BIMpass".to_vec()].concat()), - (*b"lspf", vec![0, 0, 0, 5]), - (*b"lnsr", b"layr".to_vec()), - (*b"knko", vec![1, 0, 0, 0]), - (*b"knko", vec![2, 0, 0, 0]), - (*b"infx", vec![1, 0, 0, 0]), - (*b"clbl", vec![0, 0, 0, 0]), - (*b"tsly", vec![0, 0, 0, 0]), - (*b"lmgm", vec![1, 0, 0, 0]), - (*b"vmgm", vec![1, 0, 0, 0]), - (*b"iOpa", vec![128, 0, 0, 0]), - ] { - let (big, little) = block_rt(key, data.clone()); - if data.len() >= 2 && key != *b"nvrt" { - assert_ne!(big, little, "{}", String::from_utf8_lossy(&key)); - } - } -} - -#[test] -fn global_layer_mask_block() { - let d = ImageSourceData { global_layer_mask: Some(GlobalLayerMask { data: vec![0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 50, 128, 0] }), ..Default::default() }; - let (little, w) = d.to_bytes(ByteOrder::Little).unwrap(); - assert!(w.is_empty()); - assert!(little[36..].starts_with(b"MIB8ksML")); - let (back, _) = ImageSourceData::from_bytes(&little).unwrap(); - assert_eq!(back.global_layer_mask, d.global_layer_mask); - assert_eq!(back.global_layer_mask.unwrap().opacity(), Some(50)); -} - -#[test] -fn gradient_map_layout() { - let mut g = Vec::new(); - g.put_u16(3); - g.put_u8(0); - g.put_u8(1); - g.put(b"Lnr "); - g.put_u32(2); - g.put_u16(u16::from(b'a')); - g.put_u16(0); - g.put_u16(2); - for (loc, mid) in [(0u32, 50u32), (4096, 50)] { - g.put_u32(loc); - g.put_u32(mid); - g.put_u16(0); - g.put_u16(0x1111); - g.put_u16(0x2222); - g.put_u16(0x3333); - g.put_u16(0); - g.put_u16(0); - } - g.put_u16(2); - for loc in [0u32, 4096] { - g.put_u32(loc); - g.put_u32(50); - g.put_u16(100); - } - g.put_u16(2); - g.put_u16(1); - g.put_u16(32); - g.put_u16(0); - g.put_u32(0xdead_beef); - for _ in 0..12 { - g.put_u16(0); - } - let (_, little) = block_rt(*b"grdm", g); - assert!(little.windows(4).any(|w| w == 0xdead_beefu32.to_le_bytes())); - assert!(little.windows(4).any(|w| w == b" rnL")); -} - -#[test] -fn vector_mask_records() { - let mut v = Vec::new(); - v.put_u32(3); - v.put_u32(0); - // Closed length record, then two linked knots. - v.put_u16(0); - v.put_u16(2); - v.put_i16(1); - v.put(&[0; 20]); - for sel in [1u16, 1] { - v.put_u16(sel); - for i in 0..6 { - v.put_i32((i + 1) * 0x0010_0000); - } - } - let (_, little) = block_rt(*b"vmsk", v); - assert!(little.windows(4).any(|w| w == [0, 0, 0x10, 0]), "fixed-point coordinate swapped"); -} - -#[test] -fn pattern_block() { - let plane: Vec = (0..16u8).collect(); - let p = crate::patterns::PsdPattern { - mode: 3, - width: 4, - height: 4, - name: "dots".into(), - id: "abc".into(), - palette: None, - depth: 8, - channels: vec![plane.clone(), plane.clone(), plane.clone()], - alpha: Some(plane), - }; - let data = crate::patterns::write_pattern_block(std::slice::from_ref(&p)).unwrap(); - block_rt(*b"Patt", data.clone()); - // Little-endian pattern data parses back into the same pattern. - let d = ImageSourceData { global_blocks: vec![TaggedBlock::new(*b"Patt", data)], ..Default::default() }; - let (little, _) = d.to_bytes(ByteOrder::Little).unwrap(); - let (back, _) = ImageSourceData::from_bytes(&little).unwrap(); - assert_eq!(crate::patterns::parse_pattern_block(&back.global_blocks[0].data).unwrap(), vec![p]); -} - -#[test] -fn filter_effects_block() { - use crate::filter_effects::{EffectsPlane, FilterEffects, FilterEffectsItem}; - let plane = EffectsPlane::encode(Compression::Rle, &[1, 2, 3, 4, 5, 6], 3, 2, 8).unwrap(); - let mask = EffectsPlane::encode(Compression::Raw, &[9; 6], 3, 2, 8).unwrap(); - let rect = crate::layer::Rect { top: 0, left: 0, bottom: 2, right: 3 }; - let fx = FilterEffects { - version: 1, - items: vec![FilterEffectsItem { - id: "x".into(), - version: 1, - rect, - depth: 8, - max_channels: 1, - slots: vec![Some(plane.clone()), None, Some(plane)], - mask: Some((rect, mask)), - }], - }; - let data = fx.to_bytes(); - block_rt(*b"FEid", data.clone()); - let d = ImageSourceData { global_blocks: vec![TaggedBlock::new(*b"FEid", data)], ..Default::default() }; - let (little, _) = d.to_bytes(ByteOrder::Little).unwrap(); - let (back, _) = ImageSourceData::from_bytes(&little).unwrap(); - assert_eq!(FilterEffects::parse(&back.global_blocks[0].data).unwrap(), fx); -} - -/// Photoshop writes one `FEid` section per smart object: every section's lengths and -/// numbers are swapped, not only the first one's. -#[test] -fn filter_effects_block_swaps_every_section() { - use crate::filter_effects::{EffectsPlane, FilterEffects, FilterEffectsItem}; - let rect = crate::layer::Rect { top: 0, left: 0, bottom: 2, right: 3 }; - let item = |id: &str, v: u8| FilterEffectsItem { - id: id.into(), - version: 1, - rect, - depth: 8, - max_channels: 1, - slots: vec![Some(EffectsPlane::encode(Compression::Raw, &[v; 6], 3, 2, 8).unwrap()), None, None], - mask: None, - }; - let fx = FilterEffects { version: 1, items: vec![item("first", 1), item("second-object", 2)] }; - let data = fx.to_bytes(); - let len1 = u64::from_be_bytes(data[4..12].try_into().unwrap()) as usize; - let second = 12 + len1 + (4 - len1 % 4) % 4; - let len2 = &data[second..second + 8]; - block_rt(*b"FEid", data.clone()); - let d = ImageSourceData { global_blocks: vec![TaggedBlock::new(*b"FEid", data.clone())], ..Default::default() }; - let (little, _) = d.to_bytes(ByteOrder::Little).unwrap(); - // 36-byte signature, then `MIB8`, the reversed key and a u32 length: the block data. - let body = &little[36 + 12..]; - let mut swapped = len2.to_vec(); - swapped.reverse(); - assert_eq!(&body[second..second + 8], &swapped[..], "second section's length is little-endian"); - let (back, w) = ImageSourceData::from_bytes(&little).unwrap(); - assert!(w.is_empty(), "{w:?}"); - assert_eq!(FilterEffects::parse(&back.global_blocks[0].data).unwrap(), fx); -} - -#[test] -fn metadata_setting_items() { - let desc = VersionedDescriptor::new(Descriptor::new("null")).to_bytes(); - let items = vec![crate::metadata::MetadataItem::new(*b"cmls", desc)]; - block_rt(*b"shmd", crate::metadata::write_shmd(&items)); - // An unknown item cannot be converted: it is dropped on its own, the rest stays. - let items = vec![ - crate::metadata::MetadataItem::new(*b"zzzz", vec![1, 2, 3, 4, 5, 6, 7, 8]), - crate::metadata::MetadataItem::new(*b"mdyn", vec![0, 0, 0, 1]), - crate::metadata::MetadataItem::new(*b"cmls", VersionedDescriptor::new(Descriptor::new("null")).to_bytes()), - ]; - let d = ImageSourceData { global_blocks: vec![TaggedBlock::new(*b"shmd", crate::metadata::write_shmd(&items))], ..Default::default() }; - let (little, w) = d.to_bytes(ByteOrder::Little).unwrap(); - assert_eq!(w.len(), 1, "{w:?}"); - assert!(w[0].starts_with("shmd item zzzz dropped"), "{}", w[0]); - let (back, w) = ImageSourceData::from_bytes(&little).unwrap(); - assert!(w.is_empty()); - let back_items = crate::metadata::parse_shmd(&back.global_blocks[0].data).unwrap(); - assert_eq!(back_items, items[1..].to_vec()); -} - -#[test] -fn unknown_descriptor_shaped_blocks_are_converted() { - // A key the transcoder has never heard of, holding a versioned descriptor (as every - // block Photoshop added since CS6 does): converted, not dropped. - let mut desc = Descriptor::new("null"); - desc.items.push((Id::new("Nm "), Value::Text(UnicodeString::new_nul("x")))); - let data = VersionedDescriptor::new(desc.clone()).to_bytes(); - block_rt(*b"Qqqq", data); - let mut with_version = 3u32.to_be_bytes().to_vec(); - with_version.extend(VersionedDescriptor::new(desc).to_bytes()); - block_rt(*b"Qqqq", with_version); - // Empty data needs no conversion either. - block_rt(*b"Mt32", Vec::new()); -} - -#[test] -fn linked_layer_items() { - // One embedded file (liFD, version 7) as photocraft-io writes it. - let mut item = Vec::new(); - item.put(b"liFD"); - item.put_u32(7); - item.put_u8(4); - item.put(b"uuid"); - let units: Vec = "a.png\0".encode_utf16().collect(); - item.put_u32(units.len() as u32); - for u in units { - item.put_u16(u); - } - item.put(b"png "); - item.put(b"8BIM"); - let bytes = [0x89, b'P', b'N', b'G', 1, 2, 3]; - item.put_u64(bytes.len() as u64); - item.put_u8(0); - item.put(&bytes); - item.put_u32(0); - item.put_f64(0.0); - item.put_u8(0); - let mut data = (item.len() as u64).to_be_bytes().to_vec(); - data.put(&item); - while !data.len().is_multiple_of(4) { - data.push(0); - } - let (_, little) = block_rt(*b"lnk2", data); - assert!(little.windows(4).any(|w| w == b"DFil"), "item type key reversed"); - assert!(little.windows(7).any(|w| w == bytes), "file bytes untouched"); -} - -#[test] -fn annotation_block() { - let mut a = Vec::new(); - a.put_u16(2); - a.put_u16(1); - a.put_u32(1); - let mut item = Vec::new(); - item.put(b"txtA"); - item.put_u8(0); - item.put_u8(1); - item.put_u16(0); - for v in [10, 20, 30, 40, 50, 60, 70, 80] { - item.put_i32(v); - } - item.put_u16(0); - for v in [0xffff, 0, 0, 0] { - item.put_u16(v); - } - for s in [&b"me"[..], b"note", b"2026"] { - item.put_u8(s.len() as u8); - item.put(s); - if (1 + s.len()) % 2 == 1 { - item.put_u8(0); - } - } - let text = [0xfe, 0xff, 0, b'h', 0, b'i']; - let mut d = Vec::new(); - d.put(b"txtC"); - d.put_u32(text.len() as u32); - d.put(&text); - item.put_u32(d.len() as u32 + 4); - item.put(&d); - a.put_u32(item.len() as u32 + 4); - a.put(&item); - let (_, little) = block_rt(*b"Anno", a); - assert!(little.windows(4).any(|w| w == b"Atxt")); - assert!(little.windows(6).any(|w| w == text), "text kept as stored"); -} - -#[test] -fn unknown_blocks_kept_in_big_dropped_in_little() { - let mut d = sample_layers(); - d.global_blocks.push(TaggedBlock::new(*b"Zzzz", vec![1, 2, 3, 4, 5])); - let (big, w) = d.to_bytes(ByteOrder::Big).unwrap(); - assert!(w.is_empty()); - let (back, w) = ImageSourceData::from_bytes(&big).unwrap(); - assert!(w.is_empty()); - assert_eq!(back.global_blocks, d.global_blocks); - // Big-endian data of unknown layout cannot go into a little-endian file. - let (little, w) = d.to_bytes(ByteOrder::Little).unwrap(); - assert_eq!(w.iter().filter(|w| w.starts_with("Zzzz block (5 bytes) dropped")).count(), 1, "{w:?}"); - let (back, w) = ImageSourceData::from_bytes(&little).unwrap(); - assert!(w.is_empty()); - assert_eq!(back.global_blocks.iter().map(|b| b.key).collect::>(), vec![*b"Patt", *b"FMsk"]); - assert_eq!(known(&back).layer_info, known(&d).layer_info); -} - -#[test] -fn unknown_blocks_from_a_little_endian_file_are_kept_with_their_byte_order() { - let d = sample_layers(); - let (little, _) = d.to_bytes(ByteOrder::Little).unwrap(); - // A little-endian file with a block of unknown layout, among known ones. - let mut raw = little.clone(); - raw.extend_from_slice(b"MIB8zzzZ"); - raw.extend_from_slice(&6u32.to_le_bytes()); - raw.extend_from_slice(&[9, 8, 7, 6, 5, 4, 0, 0]); - raw.extend_from_slice(b"MIB8diyl"); - raw.extend_from_slice(&4u32.to_le_bytes()); - raw.extend_from_slice(&7u32.to_le_bytes()); - let (back, w) = ImageSourceData::from_bytes(&raw).unwrap(); - assert!(w.is_empty(), "{w:?}"); - assert_eq!(known(&back).layer_info, known(&d).layer_info); - let kept = back.global_blocks.iter().find(|b| &b.key == b"Zzzz").expect("kept"); - assert!(kept.is_foreign_order()); - assert_eq!(kept.signature, *b"MIB8"); - assert_eq!(kept.data, [9, 8, 7, 6, 5, 4]); - // The known block after it was still converted. - assert_eq!(back.global_blocks.iter().find(|b| &b.key == b"lyid").unwrap().data, 7u32.to_be_bytes()); - // Written back to a little-endian TIFF, the bytes are exactly what was read. - let (again, w) = back.to_bytes(ByteOrder::Little).unwrap(); - assert!(w.is_empty(), "{w:?}"); - let tail = &raw[raw.len() - 18 - 16..]; - assert!(again.windows(tail.len()).any(|x| x == tail), "foreign-order block restored verbatim"); - assert_eq!(ImageSourceData::from_bytes(&again).unwrap().0.global_blocks, back.global_blocks); - // The big-endian (PSD) model keeps the marker, so a PSD writer can tell it apart. - let (big, _) = back.to_bytes(ByteOrder::Big).unwrap(); - assert!(big.windows(8).any(|x| x == b"MIB8Zzzz")); - assert_eq!(ImageSourceData::from_bytes(&big).unwrap().0.global_blocks, back.global_blocks); -} - -/// A PSB keeps `Mt16`/`Mt32`/`Mtrn`/`Alph` blocks of unknown layout verbatim, and their -/// length field is 8 bytes (a PSB long key). Writing them into a little-endian TIFF must -/// read that width; the old code looked the key up after reversing it for the target -/// order, which never matches `PSB_LONG_KEYS`. -#[test] -fn psb_foreign_order_long_key_roundtrips() { - let d = ImageSourceData { - version: Version::Psb, - byte_order: ByteOrder::Little, - global_blocks: vec![TaggedBlock { signature: *b"MIB8", key: *b"Mt16", data: vec![9, 8, 7, 6, 5, 4], padding: None }], - ..Default::default() - }; - let (little, w) = d.to_bytes(ByteOrder::Little).unwrap(); - assert!(w.is_empty(), "{w:?}"); - let (back, w) = ImageSourceData::from_bytes(&little).unwrap(); - assert!(w.is_empty(), "{w:?}"); - assert_eq!(back.global_blocks, d.global_blocks, "the 8-byte length and the bytes survive"); -} - -#[test] -fn rle_counts_follow_the_container_and_samples_do_not() { - let file = testgen::layered(Version::Psd, ColorMode::Rgb, 16, Compression::Rle); - let d = ImageSourceData::from_psd(&file); - let (big, _) = d.to_bytes(ByteOrder::Big).unwrap(); - let (little, _) = d.to_bytes(ByteOrder::Little).unwrap(); - let (back, _) = ImageSourceData::from_bytes(&little).unwrap(); - let d = known(&d); - let (a, b) = (d.layer_info.as_ref().unwrap(), back.layer_info.as_ref().unwrap()); - for (la, lb) in a.layers.iter().zip(&b.layers) { - for (ca, cb) in la.channels.iter().zip(&lb.channels) { - assert_eq!(ca, cb); - let (w, h) = la.channel_rect(ca.id).size().unwrap(); - assert_eq!(ca.decode(w, h, 16, Version::Psd).unwrap(), cb.decode(w, h, 16, Version::Psd).unwrap()); - } - } - // The encoded samples (after the count table) are identical bytes in both files, while - // the count table itself is swapped. - let (rec, ch) = a - .layers - .iter() - .flat_map(|l| l.channels.iter().filter(|c| c.id >= -1 && c.compression == Some(Compression::Rle)).map(move |c| (l, c))) - .find(|(l, c)| l.rect.height() > 1 && c.data.len() > l.rect.height() as usize * 2 + 8) - .unwrap(); - let table = rec.rect.height() as usize * 2; - let samples = &ch.data[table..]; - assert!(big.windows(samples.len()).any(|w| w == samples)); - assert!(little.windows(samples.len()).any(|w| w == samples)); - let counts = &ch.data[..table]; - let swapped: Vec = counts.chunks(2).flat_map(|c| [c[1], c[0]]).collect(); - assert!(little.windows(table + samples.len()).any(|w| w[..table] == swapped[..] && w[table..] == *samples)); -} - -#[test] -fn blending_ranges_are_bytes_in_either_order() { - let mut file = testgen::layered(Version::Psd, ColorMode::Rgb, 8, Compression::Rle); - let ranges: Vec = [0u8, 0, 255, 255].repeat(10); - let marked: Vec = (1u8..=40).collect(); - for (i, l) in file.layer_info.as_mut().unwrap().layers.iter_mut().enumerate() { - l.blending_ranges.data = if i == 0 { marked.clone() } else { ranges.clone() }; - } - let d = ImageSourceData::from_psd(&file); - let (little, _) = d.to_bytes(ByteOrder::Little).unwrap(); - // Written verbatim (what Photoshop writes in an `II` TIFF), not swapped per u32. - assert!(little.windows(marked.len()).any(|w| w == marked)); - let (back, _) = ImageSourceData::from_bytes(&little).unwrap(); - for (a, b) in d.layer_info.as_ref().unwrap().layers.iter().zip(&back.layer_info.as_ref().unwrap().layers) { - assert_eq!(a.blending_ranges, b.blending_ranges); - } -} - -#[test] -fn psb_sized_data_uses_long_lengths_and_counts() { - let file = testgen::layered(Version::Psb, ColorMode::Cmyk, 8, Compression::Rle); - let d = ImageSourceData::from_psd(&file); - assert_eq!(d.version, Version::Psb); - let back = roundtrip(&d); - assert_eq!(back.layer_info, d.layer_info); - let (little, _) = d.to_bytes(ByteOrder::Little).unwrap(); - assert!(little[36..].starts_with(b"MIB8")); - let (back, _) = ImageSourceData::from_bytes(&little).unwrap(); - assert_eq!(known(&back).layer_info, known(&d).layer_info); -} - -#[test] -fn resources_both_orders() { - let res = vec![ - ImageResource::new(1005, vec![0, 72, 0, 0, 0, 1, 0, 1, 0, 72, 0, 0, 0, 1, 0, 1]), - ImageResource::new(1037, vec![0, 0, 0, 120]), - ImageResource::new(1039, vec![1, 2, 3]), - ImageResource { signature: *b"8BIM", id: 1026, name: b"grp".to_vec(), data: vec![0, 1, 0, 1, 0, 0] }, - ]; - let big = resources_to_bytes(&res).unwrap(); - let (back, w) = resources_from_bytes(&big).unwrap(); - assert!(w.is_empty()); - assert_eq!(back, res); - let (little, w) = transcode_resources(&big, ByteOrder::Big, ByteOrder::Little).unwrap(); - assert!(w.is_empty()); - assert!(little.starts_with(b"MIB8")); - assert_eq!(little.len(), big.len()); - assert_eq!(&little[4..6], &[0xed, 3]); - let (back, w) = resources_from_bytes(&little).unwrap(); - assert!(w.is_empty()); - assert_eq!(back, res); - // The ICC blob is untouched by the swap. - assert!(little.windows(3).any(|w| w == [1, 2, 3])); - // An odd trailing pad byte is tolerated. - let mut padded = big.clone(); - padded.push(0); - assert_eq!(resources_from_bytes(&padded).unwrap().0, res); -} - -#[test] -fn malformed_input_errors_without_panicking() { - assert!(ImageSourceData::from_bytes(b"").is_err()); - assert!(ImageSourceData::from_bytes(b"Adobe Photoshop Document Data Block").is_err()); - let mut bad = SIGNATURE.to_vec(); - bad.extend_from_slice(b"XXXXLayr"); - assert!(ImageSourceData::from_bytes(&bad).is_err()); - let d = sample_layers(); - for order in [ByteOrder::Big, ByteOrder::Little] { - let (bytes, _) = d.to_bytes(order).unwrap(); - for cut in (0..bytes.len()).step_by(7).chain(bytes.len().saturating_sub(8)..bytes.len()) { - let _ = ImageSourceData::from_bytes(&bytes[..cut]); - } - // Bit flips in the structure (not the signature). - for i in (36..bytes.len()).step_by(13) { - let mut b = bytes.clone(); - b[i] ^= 0x5a; - let _ = ImageSourceData::from_bytes(&b); - } - } - // A block that claims more data than exists. - let mut huge = SIGNATURE.to_vec(); - huge.extend_from_slice(b"8BIMluni"); - huge.extend_from_slice(&u32::MAX.to_be_bytes()); - assert!(ImageSourceData::from_bytes(&huge).is_err()); - assert!(resources_from_bytes(b"8BIM\x03\xed\x00").is_err()); -} - -#[test] -fn into_psd_roundtrips_through_the_psd_writer() { - let file = testgen::layered(Version::Psd, ColorMode::Grayscale, 8, Compression::Zip); - let d = ImageSourceData::from_psd(&file); - let (little, _) = d.to_bytes(ByteOrder::Little).unwrap(); - let (back, _) = ImageSourceData::from_bytes(&little).unwrap(); - let psd = back.into_psd(file.header.clone(), file.resources.clone(), file.image_data.clone()); - let bytes = psd.to_bytes().unwrap(); - let again = PsdFile::from_bytes(&bytes).unwrap(); - let expected = known(&ImageSourceData::from_psd(&file)); - assert_eq!(known(&ImageSourceData::from_psd(&again)).layer_info, expected.layer_info); - assert_eq!(again.global_blocks, expected.global_blocks); - assert_eq!(again.layer_tree().len(), file.layer_tree().len()); -} - -/// Global blocks are padded as they are written, not on a copy: the bytes are those of the -/// former path (each block cloned with explicit padding to 4, written with `write_blocks`). -#[test] -fn global_blocks_written_without_copies_keep_their_bytes() { - for version in [Version::Psd, Version::Psb] { - let mut d = ImageSourceData { version, global_layer_mask: Some(GlobalLayerMask { data: vec![0, 1, 0, 2, 0, 3, 0, 4, 9] }), ..Default::default() }; - for n in 0..8u8 { - d.global_blocks.push(TaggedBlock::new(*b"FMsk", (0..n).collect())); - } - let mut explicit = TaggedBlock::new(*b"FMsk", vec![1, 2, 3]); - explicit.padding = Some(vec![]); - d.global_blocks.push(explicit); - let mut odd = TaggedBlock::new(*b"FMsk", vec![1, 2]); - odd.padding = Some(vec![0, 0, 0, 0, 0]); - d.global_blocks.push(odd); - let mut copies = vec![padded_block(*b"LMsk", d.global_layer_mask.as_ref().unwrap().data.clone())]; - for b in &d.global_blocks { - let mut b = b.clone(); - if b.padding.is_none() { - b.padding = Some(vec![0; (4 - b.data.len() % 4) % 4]); - } - copies.push(b); - } - let mut body = Vec::new(); - crate::tagged::write_blocks(&mut body, &copies, version, 2).unwrap(); - let signature = if version.is_psb() { SIGNATURE_PSB } else { SIGNATURE }; - assert_eq!(d.to_bytes(ByteOrder::Big).unwrap().0, [signature, &body[..]].concat()); - let (little, _) = transcode_blocks(&body, version, ByteOrder::Big, ByteOrder::Little).unwrap(); - assert_eq!(d.to_bytes(ByteOrder::Little).unwrap().0, [signature, &little[..]].concat()); - // Converted behind the signature in the buffer it starts with: never grown (growing - // would at least double it), so the body is not moved to make room. - let (after, _) = transcode_blocks_after(signature, &body, version, ByteOrder::Big, ByteOrder::Little).unwrap(); - assert_eq!(after, [signature, &little[..]].concat()); - assert_eq!(after.capacity(), signature.len() + body.len()); - } -} - -/// Nested structures are transcoded into one shared buffer. A block whose body fails -/// part-way leaves no partial bytes: dropped on the way out (with the enclosing lengths -/// patched as if it had never been there), or kept exactly as stored on the way in. -#[test] -fn a_nested_block_that_fails_part_way_leaves_nothing_behind() { - let clean = sample_layers(); - let mut fx = Vec::new(); - fx.put_u32(0); - fx.extend_from_slice(&VersionedDescriptor::new(Descriptor::new("null").with("Opct", Value::Double(1.0))).to_bytes()); - // A whole number of 4-byte units, so dropping it cannot change any padding. - fx.resize(fx.len().next_multiple_of(4), 0); - let mut d = clean.clone(); - d.layer_info.as_mut().unwrap().layers[0].blocks.insert(0, TaggedBlock::new(*b"lfx2", fx.clone())); - let (mut little, w) = d.to_bytes(ByteOrder::Little).unwrap(); - assert!(!w.iter().any(|w| w.starts_with("lfx2")), "{w:?}"); - // An unknown value type after the descriptor's header: parsing fails part-way. - let at = little.windows(4).position(|w| w == b"buod").unwrap(); - little[at..at + 4].copy_from_slice(b"????"); - let (back, w) = ImageSourceData::from_bytes(&little).unwrap(); - assert_eq!(w.iter().filter(|w| w.starts_with("lfx2 block")).count(), 1, "{w:?}"); - let kept = &back.layer_info.as_ref().unwrap().layers[0].blocks[0]; - assert!(kept.is_foreign_order()); - let stored = little.windows(4).position(|w| w == b"2xfl").unwrap() + 8; - assert_eq!(kept.data, little[stored..stored + fx.len()]); - // Big-endian data that fails part-way cannot go into a little-endian file: dropped. - let mut big_bad = d.clone(); - let mut broken = fx.clone(); - let at = broken.windows(4).position(|w| w == b"doub").unwrap(); - broken[at..at + 4].copy_from_slice(b"????"); - big_bad.layer_info.as_mut().unwrap().layers[0].blocks[0].data = broken; - let (little, w) = big_bad.to_bytes(ByteOrder::Little).unwrap(); - assert_eq!(w.iter().filter(|w| w.starts_with("lfx2 block")).count(), 1, "{w:?}"); - assert_eq!(little, clean.to_bytes(ByteOrder::Little).unwrap().0); -} diff --git a/crates/psd/src/tiff/transcode.rs b/crates/psd/src/tiff/transcode.rs deleted file mode 100644 index 15c33d4a9d..0000000000 --- a/crates/psd/src/tiff/transcode.rs +++ /dev/null @@ -1,1399 +0,0 @@ -use super::{ByteOrder, LayerMask, MAX_DEPTH, PsdError, Reader, Result, TaggedBlock, Version, mask_parameter_bytes, uses_long_length}; - -/// Converts the tagged blocks after the 37724 header string from `from` to `to`. -pub fn transcode_blocks(data: &[u8], version: Version, from: ByteOrder, to: ByteOrder) -> Result<(Vec, Vec)> { - let mut t = Tx::new(data, version, from, to); - t.blocks()?; - Ok(t.finish()) -} - -/// [`transcode_blocks`] written after `prefix`, in one buffer sized for both. -pub fn transcode_blocks_after(prefix: &[u8], data: &[u8], version: Version, from: ByteOrder, to: ByteOrder) -> Result<(Vec, Vec)> { - let mut out = Vec::new(); - out.try_reserve_exact(prefix.len().saturating_add(data.len())).map_err(|_| PsdError::OutOfMemory("not enough memory for the layer data"))?; - out.extend_from_slice(prefix); - let mut t = Tx::appending(out, data, version, from, to); - t.blocks()?; - Ok(t.finish()) -} - -/// Converts a layer-info body (the data of a `Layr` / `Lr16` / `Lr32` block) between orders. -pub fn transcode_layer_info(data: &[u8], version: Version, from: ByteOrder, to: ByteOrder) -> Result<(Vec, Vec)> { - let mut t = Tx::new(data, version, from, to); - t.layer_info()?; - Ok(t.finish()) -} - -/// Converts image-resource blocks (a 34377 tag) between orders. -pub fn transcode_resources(data: &[u8], from: ByteOrder, to: ByteOrder) -> Result<(Vec, Vec)> { - let mut t = Tx::new(data, Version::Psd, from, to); - t.resources()?; - Ok(t.finish()) -} - -/// A block signature as stored in `little` or big-endian data. Big-endian data (the model) -/// also carries the `MIB8` / `46B8` markers of blocks kept from a little-endian file. -fn is_sig(b: &[u8], little: bool) -> bool { - if little { - b.starts_with(b"MIB8") || b.starts_with(b"46B8") - } else { - b.starts_with(b"8BIM") || b.starts_with(b"8B64") || TaggedBlock::is_foreign_order_signature(b) - } -} - -fn height(top: i32, bottom: i32) -> usize { - usize::try_from(bottom.saturating_sub(top)).unwrap_or(0) -} - -/// Per layer record: the channel (id, length) list and the heights of the layer, mask and -/// real-mask rects. -type RecordShape = (Vec<(i16, u64)>, [usize; 3]); - -struct Tx<'a> { - r: Reader<'a>, - out: Vec, - from: ByteOrder, - to: ByteOrder, - version: Version, - warnings: Vec, - depth: usize, -} - -macro_rules! copy_int { - ($name:ident, $ty:ty) => { - fn $name(&mut self) -> Result<$ty> { - let v = self.r.$name()?; - self.out.extend_from_slice(&if self.to.is_little() { v.to_le_bytes() } else { v.to_be_bytes() }); - Ok(v) - } - }; -} - -impl<'a> Tx<'a> { - fn new(data: &'a [u8], version: Version, from: ByteOrder, to: ByteOrder) -> Self { - Self::appending(Vec::with_capacity(data.len()), data, version, from, to) - } - - /// A walker that appends to `out`. - fn appending(out: Vec, data: &'a [u8], version: Version, from: ByteOrder, to: ByteOrder) -> Self { - let r = if from.is_little() { Reader::new_little(data) } else { Reader::new(data) }; - Tx { r, out, from, to, version, warnings: Vec::new(), depth: 0 } - } - - fn finish(self) -> (Vec, Vec) { - (self.out, self.warnings) - } - - /// A walker over `data` (a nested structure) that writes into this walker's output: it - /// takes the buffer, so a nested structure is written once, not copied up per level. Hand - /// the buffer back with [`Self::adopt`] before returning, error or not. - fn nested<'b>(&mut self, data: &'b [u8], depth: usize) -> Tx<'b> { - let r = if self.from.is_little() { Reader::new_little(data) } else { Reader::new(data) }; - Tx { r, out: std::mem::take(&mut self.out), from: self.from, to: self.to, version: self.version, warnings: Vec::new(), depth } - } - - /// Takes the output buffer back from a [`Self::nested`] walker. - fn adopt(&mut self, child: &mut Tx<'_>) { - self.out = std::mem::take(&mut child.out); - } - - fn same_order(&self) -> bool { - self.from == self.to - } - - fn remaining(&self) -> usize { - self.r.remaining() - } - - copy_int!(u16, u16); - copy_int!(i16, i16); - copy_int!(u32, u32); - copy_int!(i32, i32); - copy_int!(u64, u64); - copy_int!(i64, i64); - copy_int!(f64, f64); - - fn u8(&mut self) -> Result { - let v = self.r.u8()?; - self.out.push(v); - Ok(v) - } - - fn f32(&mut self) -> Result<()> { - let b = self.r.array::<4>()?; - let v = if self.from.is_little() { f32::from_le_bytes(b) } else { f32::from_be_bytes(b) }; - self.out.extend_from_slice(&if self.to.is_little() { v.to_le_bytes() } else { v.to_be_bytes() }); - Ok(()) - } - - /// Bytes copied as they are. - fn raw(&mut self, n: usize) -> Result<()> { - let b = self.r.bytes(n)?; - self.out.extend_from_slice(b); - Ok(()) - } - - fn rest(&mut self) -> Result<()> { - let n = self.remaining(); - self.raw(n) - } - - /// A four-character signature or key: stored reversed in little-endian data. Returns the - /// canonical (big-endian) spelling. - fn key(&mut self) -> Result<[u8; 4]> { - let mut k = self.r.array::<4>()?; - if self.from.is_little() { - k.reverse(); - } - let mut w = k; - if self.to.is_little() { - w.reverse(); - } - self.out.extend_from_slice(&w); - Ok(k) - } - - fn len(&mut self, long: bool) -> Result { - if long { self.u64() } else { Ok(u64::from(self.u32()?)) } - } - - fn u16s(&mut self, n: usize) -> Result<()> { - for _ in 0..n { - self.u16()?; - } - Ok(()) - } - - /// Up to `n` `u16`s: a block shorter than its full layout keeps what it has. - fn u16s_upto(&mut self, n: usize) -> Result<()> { - for _ in 0..n { - if self.remaining() < 2 { - break; - } - self.u16()?; - } - Ok(()) - } - - fn i32s(&mut self, n: usize) -> Result<()> { - for _ in 0..n { - self.i32()?; - } - Ok(()) - } - - fn f64s(&mut self, n: usize) -> Result<()> { - for _ in 0..n { - self.f64()?; - } - Ok(()) - } - - /// `u16`s to the end (a lone odd byte is copied). - fn u16s_to_end(&mut self) -> Result<()> { - while self.remaining() >= 2 { - self.u16()?; - } - self.rest() - } - - fn u32s_to_end(&mut self) -> Result<()> { - while self.remaining() >= 4 { - self.u32()?; - } - self.rest() - } - - /// A Photoshop Unicode string: u32 count + UTF-16 units. - fn unicode(&mut self) -> Result<()> { - let n = self.u32()?; - self.r.check_count(u64::from(n), 2)?; - self.u16s(n as usize) - } - - /// A Pascal string padded to `align`. - fn pascal(&mut self, align: usize) -> Result<()> { - let n = usize::from(self.u8()?); - self.raw(n)?; - let total = 1 + n; - self.raw((align - total % align) % align) - } - - /// Transcodes the next `len` bytes with `f`; whatever `f` leaves is copied verbatim. - fn region(&mut self, len: u64, f: impl FnOnce(&mut Tx<'_>) -> Result<()>) -> Result<()> { - let data = self.r.bytes_u64(len)?; - let start = self.out.len(); - let mut child = self.nested(data, self.depth); - let res = f(&mut child).and_then(|()| child.rest()); - self.adopt(&mut child); - res?; - if self.out.len() - start != data.len() { - return Err(PsdError::invalid("byte-order conversion changed a structure's length")); - } - self.warnings.append(&mut child.warnings); - Ok(()) - } - - /// Reads a length field (4 bytes, or 8 when `long`), transcodes that many bytes with `f` - /// and writes the length of the result: blocks dropped inside shrink the field. - fn sized_region(&mut self, long: bool, f: impl FnOnce(&mut Tx<'_>) -> Result<()>) -> Result<()> { - self.sized_region_counting(long, false, f) - } - - /// [`Self::sized_region`] for a u32 length that counts its own 4 bytes. - fn sized_region_inclusive(&mut self, f: impl FnOnce(&mut Tx<'_>) -> Result<()>) -> Result<()> { - self.sized_region_counting(false, true, f) - } - - fn sized_region_counting(&mut self, long: bool, inclusive: bool, f: impl FnOnce(&mut Tx<'_>) -> Result<()>) -> Result<()> { - let mut len = self.r.len_field(long)?; - let at = self.out.len(); - self.out.extend_from_slice(if long { &[0u8; 8] } else { &[0u8; 4] }); - if inclusive { - len = len.checked_sub(4).ok_or_else(|| PsdError::invalid("length shorter than its own field"))?; - } - let data = self.r.bytes_u64(len)?; - let start = self.out.len(); - let mut child = self.nested(data, self.depth); - let res = f(&mut child).and_then(|()| child.rest()); - self.adopt(&mut child); - res?; - self.warnings.append(&mut child.warnings); - self.patch_len(at, long, (self.out.len() - start) as u64 + if inclusive { 4 } else { 0 })?; - Ok(()) - } - - /// Tries `f` on the rest of the data; it counts as a match only when at most three - /// trailing zero bytes are left. On a miss nothing is consumed or written. - fn attempt(&mut self, f: impl FnOnce(&mut Tx<'_>) -> Result<()>) -> bool { - let (reader, written) = (self.r.clone(), self.out.len()); - let ok = f(self).is_ok() && self.remaining() <= 3 && self.r.peek_rest().iter().all(|&b| b == 0); - if !ok { - self.r = reader; - self.out.truncate(written); - } - ok - } - - /// Blocks whose layout is not known by key are very often a versioned descriptor, - /// optionally behind a u32 version (every Photoshop block introduced since CS6 is): - /// recognized by parsing to the end. - fn descriptor_shaped(&mut self) -> bool { - self.attempt(|t| t.versioned_descriptor()) - || self.attempt(|t| { - t.u32()?; - t.versioned_descriptor() - }) - } - - /// Writes `n` into the length placeholder at `at`. - fn patch_len(&mut self, at: usize, long: bool, n: u64) -> Result<()> { - if long { - let b = if self.to.is_little() { n.to_le_bytes() } else { n.to_be_bytes() }; - if let Some(slot) = self.out.get_mut(at..at + 8) { - slot.copy_from_slice(&b); - } - } else { - let n = u32::try_from(n).map_err(|_| PsdError::LimitExceeded("structure too large for a 32-bit length"))?; - let b = if self.to.is_little() { n.to_le_bytes() } else { n.to_be_bytes() }; - if let Some(slot) = self.out.get_mut(at..at + 4) { - slot.copy_from_slice(&b); - } - } - Ok(()) - } - - /// An RLE row-count table (`rows` entries of 2 bytes, 4 in PSB data). - fn rle_counts(&mut self, rows: usize) -> Result<()> { - for _ in 0..rows { - if self.version.is_psb() { - if self.remaining() < 4 { - break; - } - self.u32()?; - } else { - if self.remaining() < 2 { - break; - } - self.u16()?; - } - } - Ok(()) - } - - /// Encoded channel data for a plane of `rows` rows: the compression field, then the RLE - /// row counts (container order) and the samples (always big-endian: copied). - fn channel_data(&mut self, rows: usize, long_counts: bool) -> Result<()> { - let saved = self.version; - if long_counts { - self.version = Version::Psb; - } - let compression = self.u16()?; - let res = if compression == 1 { self.rle_counts(rows) } else { Ok(()) }; - self.version = saved; - res?; - self.rest() - } - - // ---- image resources (34377) ---- - - fn resources(&mut self) -> Result<()> { - while self.remaining() >= 12 && is_sig(self.r.peek_rest(), self.from.is_little()) { - self.key()?; - let id = self.u16()?; - self.pascal(2)?; - let odd = self.r.clone().u32()? % 2 == 1; - self.sized_region(false, |t| t.resource_data(id))?; - if odd && self.remaining() > 0 { - self.raw(1)?; - } - } - self.rest() - } - - /// Typed numeric resources; every other payload is a byte blob (ICC, XMP, EXIF, …) or - /// unknown and is copied as stored. - fn resource_data(&mut self, id: u16) -> Result<()> { - match id { - // Resolution info: fixed 16.16 h res, u16 unit, u16 width unit, then the same for v. - 1005 => { - self.u32()?; - self.u16()?; - self.u16()?; - self.u32()?; - self.u16()?; - self.u16()?; - } - // Layer state (u16 target), layer group info (u16 per layer). - 1024 | 1026 => self.u16s_to_end()?, - // Global angle / altitude. - 1037 | 1049 => { - self.u32()?; - } - // Layer selection ids: u16 count + u32 ids. - 1069 => { - self.u16()?; - self.u32s_to_end()?; - } - // Version info: u32 version, u8 has real merged data, two Unicode strings, u32. - 1057 => { - self.u32()?; - self.u8()?; - self.unicode()?; - self.unicode()?; - self.u32()?; - } - // Grid and guides: u32 version, 2 × u32 grid, u32 count, (u32 location, u8 direction)… - 1032 => { - self.u32()?; - self.u32()?; - self.u32()?; - let n = self.u32()?; - for _ in 0..n { - if self.remaining() < 5 { - break; - } - self.u32()?; - self.u8()?; - } - } - // Path resources: 26-byte records. - 2000..=2997 | 2999 => self.path_records()?, - _ => {} - } - Ok(()) - } - - // ---- tagged blocks ---- - - /// Tagged blocks to the end; bytes that do not form a block are copied. - fn blocks(&mut self) -> Result<()> { - loop { - let rest = self.r.peek_rest(); - if rest.len() < 12 || !is_sig(rest, self.from.is_little()) { - return self.rest(); - } - self.block()?; - } - } - - fn block(&mut self) -> Result<()> { - let before = self.out.len(); - // A `MIB8` / `46B8` marker in big-endian data: a block kept verbatim from a - // little-endian file. Written back as it was into little-endian output (its `8BIM` - // signature reversed, as every signature there), copied as is otherwise. - if !self.from.is_little() && TaggedBlock::is_foreign_order_signature(self.r.peek_rest()) { - let sig = self.r.array::<4>()?; - let mut key = self.r.array::<4>()?; - // Only reached with `from == Big`: the key is canonical here whatever the - // target order. `TaggedBlock::write` sizes the length field from that same key. - // Reversing it for the target order before the check - // asked about a key `PSB_LONG_KEYS` never contains, so a PSB long key's 8-byte - // length was read as 4 and every later block was copied unconverted. - let long = uses_long_length(self.version, &key); - let len = self.r.len_field(long)?; - let data = self.r.bytes_u64(len)?; - if self.to.is_little() { - key.reverse(); - } - self.out.extend_from_slice(&sig); - self.out.extend_from_slice(&key); - let n = self.out.len(); - self.out.extend_from_slice(if long { &[0u8; 8] } else { &[0u8; 4] }); - self.patch_len(n, long, len)?; - self.out.extend_from_slice(data); - let rest = self.r.peek_rest(); - let pad = (0..=3usize).find(|&k| k == rest.len() || (k < rest.len() && is_sig(&rest[k..], self.from.is_little()))).unwrap_or(0); - let pad_bytes = self.r.bytes(pad)?; - self.out.extend_from_slice(pad_bytes); - return Ok(()); - } - let sig_at = self.out.len(); - let sig = self.key()?; - let key = self.key()?; - let long = uses_long_length(self.version, &key); - let len = self.r.len_field(long)?; - let len_at = self.out.len(); - self.out.extend_from_slice(if long { &[0u8; 8] } else { &[0u8; 4] }); - let data = self.r.bytes_u64(len)?; - // The body is transcoded straight into this output, after the length placeholder. - let data_at = self.out.len(); - let mut child = self.nested(data, self.depth + 1); - let handled = if child.depth > MAX_DEPTH { - Err(PsdError::LimitExceeded("tagged block nesting too deep")) - } else if data.is_empty() { - Ok(true) - } else { - child.block_body(&key) - }; - let rest = if matches!(handled, Ok(true)) { child.rest() } else { Ok(()) }; - self.adopt(&mut child); - rest?; - if !matches!(handled, Ok(true)) { - // A body that was not transcoded leaves nothing: it is copied as stored or dropped. - self.out.truncate(data_at); - } - // Padding: the smallest k in 0..=3 after which the next block (or the end) starts. - let rest = self.r.peek_rest(); - let pad = (0..=3usize).find(|&k| k == rest.len() || (k < rest.len() && is_sig(&rest[k..], self.from.is_little()))).unwrap_or(0); - let pad_bytes = self.r.bytes(pad)?; - match handled { - Ok(true) => { - self.warnings.append(&mut child.warnings); - self.patch_len(len_at, long, (self.out.len() - data_at) as u64)?; - self.out.extend_from_slice(pad_bytes); - } - Ok(false) if self.same_order() => { - self.patch_len(len_at, long, data.len() as u64)?; - self.out.extend_from_slice(data); - self.out.extend_from_slice(pad_bytes); - } - // Little-endian in, big-endian model: keep the bytes as they are, marked. - Ok(false) if self.from.is_little() => { - let marker: &[u8; 4] = if &sig == b"8B64" { b"46B8" } else { b"MIB8" }; - self.out[sig_at..sig_at + 4].copy_from_slice(marker); - self.patch_len(len_at, long, data.len() as u64)?; - self.out.extend_from_slice(data); - self.out.extend_from_slice(pad_bytes); - } - Ok(false) => { - self.out.truncate(before); - self.warnings.push(format!( - "{} block ({} bytes) dropped: its layout is unknown and cannot be converted to the file's byte order", - String::from_utf8_lossy(&key), - data.len() - )); - } - Err(e) if self.same_order() => return Err(PsdError::invalid(format!("{} block: {e}", String::from_utf8_lossy(&key)))), - Err(e) if self.from.is_little() => { - let marker: &[u8; 4] = if &sig == b"8B64" { b"46B8" } else { b"MIB8" }; - self.out[sig_at..sig_at + 4].copy_from_slice(marker); - self.patch_len(len_at, long, data.len() as u64)?; - self.out.extend_from_slice(data); - self.out.extend_from_slice(pad_bytes); - self.warnings.push(format!("{} block ({} bytes) kept as stored, in little-endian layout: {e}", String::from_utf8_lossy(&key), data.len())); - } - Err(e) => { - self.out.truncate(before); - self.warnings.push(format!("{} block ({} bytes) dropped: {e}", String::from_utf8_lossy(&key), data.len())); - } - } - Ok(()) - } - - /// Transcodes a block's data by key. `Ok(false)` for keys without a known layout. - fn block_body(&mut self, key: &[u8; 4]) -> Result { - match key { - b"Layr" | b"Lr16" | b"Lr32" => self.layer_info()?, - b"luni" => self.unicode()?, - b"lsct" | b"lsdk" => { - self.u32()?; - if self.remaining() >= 8 { - self.key()?; - self.key()?; - } - if self.remaining() >= 4 { - self.u32()?; - } - } - // lyvr layer version, sn2P and vowv (shape and vector flags). - b"lyid" | b"lspf" | b"lyvr" | b"sn2P" | b"vowv" => { - self.u32()?; - } - // Text-engine global data: a textual structure, no byte order. - b"Txt2" => self.rest()?, - b"lnsr" => { - self.key()?; - } - // Advanced Blending flags and fill opacity: one byte, then padding (Adobe spec, - // "Additional Layer Information"; `lmgm`/`vmgm` = layer/vector mask hides effects). - b"clbl" | b"infx" | b"knko" | b"tsly" | b"lmgm" | b"vmgm" | b"iOpa" => { - self.u8()?; - } - b"lclr" => self.u16s_upto(4)?, - b"fxrp" => { - while self.remaining() >= 8 { - self.f64()?; - } - } - b"brst" => self.u32s_to_end()?, - b"LMsk" => self.u16s_upto(6)?, - b"FMsk" => self.u16s_to_end()?, - b"shmd" => self.metadata_setting()?, - // Effects (lfxs: the effects as applied to a group), content credentials (CAI). - b"lfx2" | b"lmfx" | b"lfxs" | b"CAI " => { - self.u32()?; - self.versioned_descriptor()?; - } - b"SoCo" | b"GdFl" | b"PtFl" | b"vstk" | b"blwh" | b"vibA" | b"CgEd" | b"artb" | b"artd" | b"cinf" | b"cust" | b"PxSD" | b"GenI" | b"OCIO" - | b"extn" | b"pths" => { - self.versioned_descriptor()?; - } - // Color Lookup: u16 version, then the descriptor. - b"clrL" => { - self.u16()?; - self.versioned_descriptor()?; - } - b"lnk2" | b"lnk3" | b"lnkD" | b"lnkE" => self.linked_layers()?, - b"Anno" => self.annotations()?, - b"vogk" => { - self.u32()?; - self.versioned_descriptor()?; - } - b"vscg" => { - self.key()?; - self.versioned_descriptor()?; - } - b"SoLd" | b"SoLE" => { - self.key()?; - self.u32()?; - self.versioned_descriptor()?; - } - b"PlLd" => { - self.key()?; - self.u32()?; - self.pascal(1)?; - for _ in 0..4 { - self.u32()?; - } - self.f64s(8)?; - self.u32()?; - self.versioned_descriptor()?; - } - // Type: version, transform, text version + descriptor, warp version + descriptor, - // and the bounds as four 32-bit values (the specification says doubles; Photoshop - // writes 16 bytes). - b"TySh" => self.type_layer()?, - b"vmsk" | b"vsms" => { - self.u32()?; - self.u32()?; - self.path_records()?; - } - b"Patt" | b"Pat2" | b"Pat3" => { - while self.remaining() >= 4 { - let len = self.r.clone().u32()?; - if len == 0 { - self.u32()?; - continue; - } - self.sized_region(false, Tx::pattern)?; - // Each pattern is padded to a multiple of 4. - let pad = ((4 - len % 4) % 4) as usize; - self.raw(pad.min(self.remaining()))?; - } - } - b"FEid" | b"FXid" => self.filter_effects()?, - b"lrFX" => self.legacy_effects()?, - b"levl" => { - self.u16()?; - for _ in 0..29 * 5 { - if self.remaining() < 2 { - break; - } - self.u16()?; - } - if self.remaining() >= 4 { - self.key()?; - } - self.u16s_to_end()?; - } - b"curv" => self.curves()?, - b"hue2" => { - self.u16()?; - self.u8()?; - self.u8()?; - self.u16s_to_end()?; - } - b"brit" => self.u16s_upto(3)?, - b"thrs" | b"post" => { - self.u16()?; - } - b"nvrt" => {} - b"expA" => { - self.u16()?; - for _ in 0..3 { - self.f32()?; - } - } - b"blnc" => self.u16s_upto(9)?, - b"mixr" | b"selc" => self.u16s_to_end()?, - b"phfl" => match self.u16()? { - 2 => { - self.u16s(5)?; - self.u32()?; - self.u8()?; - } - 3 => { - for _ in 0..4 { - self.u32()?; - } - self.u8()?; - } - v => return Err(PsdError::Unsupported(format!("photo filter version {v}"))), - }, - b"grdm" => self.gradient_map()?, - _ if !self.same_order() => return Ok(self.descriptor_shaped()), - _ => return Ok(false), - } - Ok(true) - } - - /// `lnk2` / `lnk3` / `lnkD` / `lnkE` (linked and embedded smart-object files): items of - /// u64 length + data, each padded to 4. Item: type (`liFD` embedded, `liFE` external, - /// `liFA` alias), version 1–7, Pascal uuid, Unicode name, file type and creator keys, u64 - /// data length, open-descriptor flag (+ descriptor), per-type fields, the file bytes for - /// `liFD`, then the fields later versions added. - fn linked_layers(&mut self) -> Result<()> { - while self.remaining() >= 8 { - let len = self.r.clone().u64()?; - self.sized_region(true, |t| { - let kind = t.key()?; - let version = t.u32()?; - t.pascal(1)?; - t.unicode()?; - t.key()?; - t.key()?; - let data_len = t.u64()?; - if t.u8()? != 0 { - t.versioned_descriptor()?; - } - if &kind == b"liFE" && version > 3 { - t.i32()?; - for _ in 0..4 { - t.u8()?; - } - t.f64()?; - } - if &kind == b"liFE" { - t.u64()?; - } - if &kind == b"liFA" { - t.raw(8)?; - } - let data_len = usize::try_from(data_len).map_err(|_| PsdError::LimitExceeded("linked file length"))?; - if &kind == b"liFD" { - t.raw(data_len)?; - } - if version >= 5 { - t.unicode()?; - } - if version >= 6 { - t.f64()?; - } - if version >= 7 { - t.u8()?; - } - if &kind == b"liFE" && version == 2 { - t.raw(data_len)?; - } - Ok(()) - })?; - let pad = ((4 - len % 4) % 4) as usize; - self.raw(pad.min(self.remaining()))?; - } - Ok(()) - } - - /// `Anno`: u16 major, u16 minor, u32 count, then per annotation a self-counting u32 - /// length, type key, open and flags bytes, u16 optional blocks, icon and popup rects, a - /// colour, three even-padded Pascal strings, and a self-counting data block (key, u32 - /// size, text with its own byte-order mark). - fn annotations(&mut self) -> Result<()> { - self.u16()?; - self.u16()?; - let n = self.u32()?; - for _ in 0..n { - if self.remaining() < 4 { - break; - } - self.sized_region_inclusive(|t| { - t.key()?; - t.u8()?; - t.u8()?; - t.u16()?; - t.i32s(8)?; - t.u16s(5)?; - t.pascal(2)?; - t.pascal(2)?; - t.pascal(2)?; - t.sized_region_inclusive(|d| { - d.key()?; - d.u32()?; - Ok(()) - }) - })?; - } - Ok(()) - } - - // ---- layer info ---- - - fn layer_info(&mut self) -> Result<()> { - let count = self.i16()?; - let n = usize::from(count.unsigned_abs()); - self.r.check_count(n as u64, 34)?; - // Per layer: the channel ids and lengths, and the heights of the layer, mask and - // real-mask rects (what each channel's RLE count table spans). - let mut records: Vec = Vec::with_capacity(n); - for _ in 0..n { - let top = self.i32()?; - self.i32()?; - let bottom = self.i32()?; - self.i32()?; - let nch = self.u16()?; - if nch > crate::layer::MAX_LAYER_CHANNELS { - return Err(PsdError::LimitExceeded("too many channels in layer")); - } - let mut chans = Vec::with_capacity(usize::from(nch)); - let psb = self.version.is_psb(); - for _ in 0..nch { - let id = self.i16()?; - let len = self.len(psb)?; - chans.push((id, len)); - } - let sig = self.key()?; - if &sig != b"8BIM" { - return Err(PsdError::InvalidSignature { expected: "8BIM (layer blend mode)", found: sig }); - } - self.key()?; - for _ in 0..4 { - self.u8()?; - } - let mut heights = [height(top, bottom), 0, 0]; - let mut mask_heights = (0usize, 0usize); - self.sized_region(false, |t| { - let mut mh = (0, 0); - t.sized_region(false, |m| { - mh = m.mask()?; - Ok(()) - })?; - mask_heights = mh; - // Blending ranges are bytes (black low/high, white low/high per channel), so - // Photoshop stores them in the same order in either byte order: an Intel-order - // layered TIFF holds the default ranges as `00 00 ff ff`. Swapping them as u32s - // turned every range into `ff ff 00 00`, which hides every pixel of the layer. - t.sized_region(false, |x| x.rest())?; - t.pascal(4)?; - t.blocks() - })?; - heights[1] = mask_heights.0; - heights[2] = mask_heights.1; - records.push((chans, heights)); - } - for (chans, heights) in &records { - for &(id, len) in chans { - if len == 0 { - continue; - } - if len == 1 { - return Err(PsdError::invalid("channel data length 1")); - } - let rows = match id { - -2 => heights[1], - -3 => heights[2], - _ => heights[0], - }; - self.region(len, |t| t.channel_data(rows, false))?; - } - } - self.rest() - } - - /// A layer mask record. Returns the heights of the mask and real-mask rects. - fn mask(&mut self) -> Result<(usize, usize)> { - let whole = self.r.peek_rest(); - if whole.is_empty() { - return Ok((0, 0)); - } - let top = self.i32()?; - self.i32()?; - let bottom = self.i32()?; - self.i32()?; - self.u8()?; - let flags = self.u8()?; - let mut real_h = 0; - if self.remaining() <= 2 { - return Ok((height(top, bottom), 0)); - } - let has_parameters = flags & LayerMask::FLAG_PARAMETERS != 0; - // The helper only looks at lengths and the parameter-flags byte, so it reads the - // same in either order. - let real_first = has_parameters && LayerMask::real_before_parameters(whole); - let mut read_real = |t: &mut Tx<'_>| -> Result<()> { - if t.remaining() >= 18 { - t.u8()?; - t.u8()?; - let rt = t.i32()?; - t.i32()?; - let rb = t.i32()?; - t.i32()?; - real_h = height(rt, rb); - } - Ok(()) - }; - if real_first { - read_real(self)?; - } - if has_parameters { - let pf = self.u8()?; - let need = mask_parameter_bytes(pf); - if need > self.remaining() { - return Err(PsdError::UnexpectedEof { offset: self.r.pos(), needed: need - self.remaining() }); - } - if pf & 1 != 0 { - self.u8()?; - } - if pf & 2 != 0 { - self.f64()?; - } - if pf & 4 != 0 { - self.u8()?; - } - if pf & 8 != 0 { - self.f64()?; - } - } - if !real_first { - read_real(self)?; - } - Ok((height(top, bottom), real_h)) - } - - // ---- descriptors ---- - - /// Photoshop's Intel-order TIFF type blocks can use PSD byte order for the - /// text descriptor, then container order again for warp and bounds (#4758). - fn type_layer(&mut self) -> Result<()> { - self.u16()?; - self.f64s(6)?; - self.type_descriptor(50, ByteOrder::Big)?; - self.type_descriptor(1, self.to)?; - self.i32s(4) - } - - fn type_descriptor(&mut self, version: u16, to: ByteOrder) -> Result<()> { - let [hi, lo] = version.to_be_bytes(); - let from = if self.from.is_little() && self.r.peek_rest().starts_with(&[hi, lo, 0, 0, 0, 16]) { ByteOrder::Big } else { self.from }; - let data = self.r.peek_rest(); - let mut descriptor = Tx::new(data, self.version, from, to); - descriptor.depth = self.depth; - descriptor.u16()?; - descriptor.versioned_descriptor()?; - let used = data.len() - descriptor.remaining(); - self.r.bytes(used)?; - self.warnings.append(&mut descriptor.warnings); - self.out.extend_from_slice(&descriptor.out); - Ok(()) - } - - fn versioned_descriptor(&mut self) -> Result<()> { - self.u32()?; - self.descriptor() - } - - fn descriptor(&mut self) -> Result<()> { - if self.depth > MAX_DEPTH { - return Err(PsdError::LimitExceeded("descriptor nesting too deep")); - } - self.depth += 1; - self.unicode()?; - self.id()?; - let n = self.u32()?; - self.r.check_count(u64::from(n), 10)?; - for _ in 0..n { - self.id()?; - self.value()?; - } - self.depth -= 1; - Ok(()) - } - - fn id(&mut self) -> Result<()> { - let n = self.u32()?; - if n == 0 { - self.key()?; - } else { - self.raw(usize::try_from(n).map_err(|_| PsdError::LimitExceeded("id length"))?)?; - } - Ok(()) - } - - fn class(&mut self) -> Result<()> { - self.unicode()?; - self.id() - } - - fn value(&mut self) -> Result<()> { - if self.depth > MAX_DEPTH { - return Err(PsdError::LimitExceeded("descriptor nesting too deep")); - } - let ty = self.key()?; - match &ty { - b"obj " => { - let n = self.u32()?; - self.r.check_count(u64::from(n), 8)?; - for _ in 0..n { - self.reference_item()?; - } - } - b"Objc" | b"GlbO" => self.descriptor()?, - b"VlLs" => { - let n = self.u32()?; - self.r.check_count(u64::from(n), 4)?; - self.depth += 1; - for _ in 0..n { - self.value()?; - } - self.depth -= 1; - } - b"doub" => { - self.f64()?; - } - b"UntF" => { - self.key()?; - self.f64()?; - } - b"UnFl" => { - self.key()?; - let n = self.u32()?; - self.r.check_count(u64::from(n), 8)?; - self.f64s(n as usize)?; - } - b"TEXT" => self.unicode()?, - b"enum" => { - self.id()?; - self.id()?; - } - b"long" => { - self.i32()?; - } - b"comp" => { - self.i64()?; - } - b"bool" => { - self.u8()?; - } - b"type" | b"GlbC" => self.class()?, - b"alis" | b"Pth " | b"tdta" => { - let n = self.u32()?; - self.raw(usize::try_from(n).map_err(|_| PsdError::LimitExceeded("descriptor data length"))?)?; - } - b"ObAr" => { - self.u32()?; - self.descriptor()?; - } - other => return Err(PsdError::Unsupported(format!("descriptor OSType {:?}", String::from_utf8_lossy(other)))), - } - Ok(()) - } - - fn reference_item(&mut self) -> Result<()> { - let ty = self.key()?; - match &ty { - b"prop" => { - self.class()?; - self.id()?; - } - b"Clss" => self.class()?, - b"Enmr" => { - self.class()?; - self.id()?; - self.id()?; - } - b"rele" => { - self.class()?; - self.i32()?; - } - b"Idnt" | b"indx" => { - self.class()?; - self.u32()?; - } - b"name" => { - self.class()?; - self.unicode()?; - } - other => return Err(PsdError::Unsupported(format!("reference item type {:?}", String::from_utf8_lossy(other)))), - } - Ok(()) - } - - // ---- other block layouts ---- - - /// `shmd`: u32 count, then per item signature, key, copy flag, 3 bytes, u32 length, data. - /// An item whose layout is unknown is dropped on its own (and the count patched) rather - /// than taking the whole block with it. - fn metadata_setting(&mut self) -> Result<()> { - let n = self.r.u32()?; - self.r.check_count(u64::from(n), 16)?; - let count_at = self.out.len(); - self.out.extend_from_slice(&[0; 4]); - let mut kept = 0u32; - for _ in 0..n { - let before = self.out.len(); - self.key()?; - let key = self.key()?; - self.u8()?; - self.raw(3)?; - let same = self.same_order(); - let res = self.sized_region(false, |t| match &key { - b"cmls" | b"mlst" | b"cust" | b"tmln" | b"sgrp" | b"cinf" => t.versioned_descriptor(), - // mdyn (dynamic metadata) is one u32. - b"mdyn" => t.u32s_to_end(), - _ if same => Ok(()), - _ if t.descriptor_shaped() => Ok(()), - _ => Err(PsdError::Unsupported(format!("metadata item {:?}", String::from_utf8_lossy(&key)))), - }); - match res { - Ok(()) => kept += 1, - Err(e) if same => return Err(e), - Err(e) => { - self.out.truncate(before); - self.warnings.push(format!("shmd item {} dropped: {e}", String::from_utf8_lossy(&key))); - } - } - } - self.patch_len(count_at, false, u64::from(kept)) - } - - /// 26-byte path records: a u16 selector then, for knots, six fixed-point coordinates. - fn path_records(&mut self) -> Result<()> { - while self.remaining() >= 26 { - let selector = self.u16()?; - match selector { - 1 | 2 | 4 | 5 => self.i32s(6)?, - // Length records: knot count, path operation, then fields Photoshop writes as - // 16-bit values (zero in practice). - 0 | 3 => self.u16s(12)?, - // Clipboard: four fixed-point coordinates and a resolution. - 7 => { - self.i32s(5)?; - self.raw(4)?; - } - 8 => { - self.u16()?; - self.raw(22)?; - } - _ => self.raw(24)?, - } - } - Ok(()) - } - - /// One pattern of a `Patt` block (the data after its u32 length). - fn pattern(t: &mut Tx<'_>) -> Result<()> { - t.u32()?; - let mode = t.u32()?; - t.u16()?; - t.u16()?; - t.unicode()?; - t.pascal(1)?; - if mode == 2 { - t.raw(768)?; - // Some writers follow the palette with 4 extra bytes before the array list version. - let next_is_version = t.r.clone().u32().is_ok_and(|v| v == 3); - if !next_is_version && t.remaining() >= 4 { - t.raw(4)?; - } - } - t.u32()?; - t.sized_region(false, |v| { - v.i32s(4)?; - let count = v.u32()?; - for _ in 0..count.saturating_add(2) { - if v.remaining() < 4 { - break; - } - if v.u32()? == 0 { - continue; - } - if v.r.clone().u32()? == 0 { - v.u32()?; - continue; - } - v.sized_region(false, |c| { - c.u32()?; - let ct = c.i32()?; - c.i32()?; - let cb = c.i32()?; - c.i32()?; - c.u16()?; - let comp = c.u8()?; - if comp == 1 { - let saved = c.version; - c.version = Version::Psd; - let res = c.rle_counts(height(ct, cb)); - c.version = saved; - res?; - } - Ok(()) - })?; - } - Ok(()) - }) - } - - /// `FEid` / `FXid`: version, then one section per smart object (u64 length, items with - /// per-channel planes and 4-byte RLE counts), each padded to 4 bytes. - fn filter_effects(&mut self) -> Result<()> { - self.u32()?; - loop { - let len = self.r.clone().len_field(true)?; - self.filter_effects_section()?; - // Mirrors `FilterEffects::parse`: the last section's padding may be cut short. - let pad = usize::try_from((4 - len % 4) % 4).unwrap_or(0).min(self.remaining()); - self.raw(pad)?; - let rest = self.r.peek_rest(); - if rest.len() <= 3 && rest.iter().all(|&b| b == 0) { - return Ok(()); - } - if rest.len() < 8 { - return Err(PsdError::invalid(format!("filter effects: {} unexpected bytes after the data", rest.len()))); - } - } - } - - fn filter_effects_section(&mut self) -> Result<()> { - self.sized_region(true, |t| { - while !t.r.is_empty() { - t.pascal(1)?; - t.u32()?; - t.sized_region(true, |it| { - let top = it.i32()?; - it.i32()?; - let bottom = it.i32()?; - it.i32()?; - let rect_h = height(top, bottom); - it.u32()?; - let max = it.u32()?; - if max > crate::filter_effects::MAX_SLOTS { - return Err(PsdError::LimitExceeded("filter effects: too many channels")); - } - for _ in 0..max + 2 { - if it.u32()? != 0 { - it.effects_plane(rect_h)?; - } - } - Ok(()) - })?; - if !t.r.is_empty() && t.u8()? != 0 { - let top = t.i32()?; - t.i32()?; - let bottom = t.i32()?; - t.i32()?; - t.effects_plane(height(top, bottom))?; - } - } - Ok(()) - }) - } - - fn effects_plane(&mut self, rows: usize) -> Result<()> { - self.sized_region(true, |p| p.channel_data(rows, true)) - } - - /// `lrFX`: version, count, then effects (`cmnS`, `dsdw`, `isdw`, `oglw`, `iglw`, `bevl`, - /// `sofi`), each a signature, key, u32 size and fixed fields. - fn legacy_effects(&mut self) -> Result<()> { - self.u16()?; - let n = self.u16()?; - for _ in 0..n { - if self.remaining() < 12 { - break; - } - self.key()?; - let key = self.key()?; - self.sized_region(false, |t| { - // A colour: u16 space + 4 × u16 components. - let colour = |t: &mut Tx<'_>| t.u16s(5); - let blend = |t: &mut Tx<'_>| -> Result<()> { - t.key()?; - t.key()?; - Ok(()) - }; - match &key { - b"cmnS" => { - t.u32()?; - t.u8()?; - t.u16()?; - } - b"dsdw" | b"isdw" => { - t.u32()?; - t.i32s(4)?; - colour(t)?; - blend(t)?; - t.u8()?; - t.u8()?; - t.u8()?; - if t.remaining() >= 10 { - colour(t)?; - } - } - b"oglw" => { - t.u32()?; - t.i32s(2)?; - colour(t)?; - blend(t)?; - t.u8()?; - t.u8()?; - if t.remaining() >= 10 { - colour(t)?; - } - } - b"iglw" => { - t.u32()?; - t.i32s(2)?; - colour(t)?; - blend(t)?; - t.u8()?; - t.u8()?; - if t.remaining() >= 11 { - t.u8()?; - colour(t)?; - } - } - b"bevl" => { - t.u32()?; - t.i32s(3)?; - blend(t)?; - blend(t)?; - colour(t)?; - colour(t)?; - for _ in 0..6 { - t.u8()?; - } - if t.remaining() >= 20 { - colour(t)?; - colour(t)?; - } - } - b"sofi" => { - t.u32()?; - blend(t)?; - colour(t)?; - t.u8()?; - t.u8()?; - if t.remaining() >= 10 { - colour(t)?; - } - } - _ => {} - } - Ok(()) - })?; - } - Ok(()) - } - - /// `curv`: pad byte, version, channel bitmap, per set bit a point list; then an optional - /// `Crv ` extension with per-channel point lists. - fn curves(&mut self) -> Result<()> { - self.u8()?; - self.u16()?; - let bits = self.u32()?; - for bit in 0..32 { - if bits & (1 << bit) == 0 { - continue; - } - if self.remaining() < 2 { - break; - } - let n = self.u16()?; - self.u16s(usize::from(n) * 2)?; - } - if self.remaining() >= 10 { - self.key()?; - self.u16()?; - let n = self.u32()?; - for _ in 0..n { - if self.remaining() < 4 { - break; - } - self.u16()?; - let pts = self.u16()?; - self.u16s(usize::from(pts) * 2)?; - } - } - Ok(()) - } - - /// `grdm`: version, reverse, dither, [method key], name, colour stops, transparency - /// stops, then the noise-gradient fields (a u32 seed among u16s). - fn gradient_map(&mut self) -> Result<()> { - let version = self.u16()?; - self.u8()?; - self.u8()?; - if version == 3 { - self.key()?; - } - self.unicode()?; - let n = self.u16()?; - for _ in 0..n { - if self.remaining() < 20 { - break; - } - self.u32()?; - self.u32()?; - self.u16s(6)?; - } - let nt = self.u16()?; - for _ in 0..nt { - if self.remaining() < 10 { - break; - } - self.u32()?; - self.u32()?; - self.u16()?; - } - // expansion, interpolation, length, mode, u32 seed, then u16 fields. - for _ in 0..4 { - if self.remaining() < 2 { - return Ok(()); - } - self.u16()?; - } - if self.remaining() >= 4 { - self.u32()?; - } - self.u16s_to_end() - } -} diff --git a/crates/psd/src/tree.rs b/crates/psd/src/tree.rs deleted file mode 100644 index 606d9b2087..0000000000 --- a/crates/psd/src/tree.rs +++ /dev/null @@ -1,126 +0,0 @@ -//! Nested layer tree reconstruction from section dividers. -//! -//! In the file, layers are stored bottom-to-top. A group appears as a -//! *bounding section divider* (`lsct` type 3, usually named -//! ``) below its children, followed by the children, followed -//! by the group's own record (`lsct` type 1 = open or 2 = closed folder). - -use crate::file::PsdFile; -use crate::tagged::SectionType; - -/// A node of the layer tree. Children are in file order (bottom-to-top). -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum LayerNode { - /// A non-group layer. - Layer { - /// Index into [`PsdFile::layers`]. - index: usize, - }, - /// A group (folder). - Group { - /// Index of the group's own record (the folder, top of the group). - index: usize, - /// Index of the bounding section divider, if one was found. - divider: Option, - /// Children, bottom-to-top. - children: Vec, - }, -} - -impl LayerNode { - /// Record index of this node. - pub fn index(&self) -> usize { - match self { - LayerNode::Layer { index } | LayerNode::Group { index, .. } => *index, - } - } - /// Children (empty for layers). - pub fn children(&self) -> &[LayerNode] { - match self { - LayerNode::Layer { .. } => &[], - LayerNode::Group { children, .. } => children, - } - } - /// `true` for groups. - pub fn is_group(&self) -> bool { - matches!(self, LayerNode::Group { .. }) - } - /// Total number of nodes in this subtree (including self). - pub fn count(&self) -> usize { - 1 + self.children().iter().map(LayerNode::count).sum::() - } -} - -impl PsdFile { - /// Builds the nested layer tree. Root nodes and children are ordered - /// bottom-to-top (file order). - /// - /// Malformed nesting is tolerated: a folder without a matching divider - /// becomes an empty group; unclosed dividers at the end are dropped and - /// their children are hoisted to the enclosing level. - /// - /// The tree can be as deep as the file nests its groups (up to ~16000 - /// levels). Code that walks it recursively should use - /// [`layer_tree_max_depth`](Self::layer_tree_max_depth). - pub fn layer_tree(&self) -> Vec { - self.layer_tree_max_depth(usize::MAX) - } - - /// [`layer_tree`](Self::layer_tree) with at most `max_depth` levels of - /// groups: a group nested `max_depth` or more groups deep (a root node is - /// at depth 0) keeps its node but not its children. Layers under a divider - /// that is never closed sit at the enclosing level, as in `layer_tree`. Built iteratively, so - /// no file can make it recurse, and the result is no deeper than - /// `max_depth + 1` levels, so walking or dropping it recursively is bounded. - pub fn layer_tree_max_depth(&self, max_depth: usize) -> Vec { - let layers = self.layers(); - // First pass: find the dividers a folder closes. A divider that is never closed opens no - // group; its layers belong to the enclosing level, so it opens no frame and every - // frame's depth is the final depth of its nodes (#5758). - let mut closed = vec![false; layers.len()]; - let mut open: Vec = Vec::new(); - for (i, rec) in layers.iter().enumerate() { - match rec.section_type() { - SectionType::BoundingDivider => open.push(i), - SectionType::OpenFolder | SectionType::ClosedFolder => { - if let Some(slot) = open.pop().and_then(|d| closed.get_mut(d)) { - *slot = true; - } - } - _ => {} - } - } - // Stack of (divider index, children); frame `k` holds the nodes at depth `k`. Frames - // past `max_depth` are tracked (to match dividers to folders) but collect nothing. - let mut stack: Vec<(Option, Vec)> = vec![(None, Vec::new())]; - let push = |stack: &mut Vec<(Option, Vec)>, node: LayerNode| { - if stack.len() <= max_depth.saturating_add(1) - && let Some(top) = stack.last_mut() - { - top.1.push(node); - } - }; - for (i, rec) in layers.iter().enumerate() { - match rec.section_type() { - SectionType::BoundingDivider => { - if closed.get(i).copied().unwrap_or(false) { - stack.push((Some(i), Vec::new())); - } - } - SectionType::OpenFolder | SectionType::ClosedFolder => { - let (divider, children) = if stack.len() > 1 { stack.pop().unwrap_or_default() } else { (None, Vec::new()) }; - push(&mut stack, LayerNode::Group { index: i, divider, children }); - } - _ => push(&mut stack, LayerNode::Layer { index: i }), - } - } - // Only closed dividers open frames, so none is left here; this just guards the invariant. - while stack.len() > 1 { - let (_, children) = stack.pop().unwrap_or_default(); - if let Some(top) = stack.last_mut() { - top.1.extend(children); - } - } - stack.pop().map(|(_, c)| c).unwrap_or_default() - } -} diff --git a/crates/psd/tests/csh.rs b/crates/psd/tests/csh.rs deleted file mode 100644 index c220d266ae..0000000000 --- a/crates/psd/tests/csh.rs +++ /dev/null @@ -1,101 +0,0 @@ -use photocraft_psd::PsdError; -use photocraft_psd::csh; -use photocraft_psd::path::{PathData, PsdKnot, PsdSubpath}; - -fn fixture(names: &[&str]) -> Vec { - let mut bytes = b"cush".to_vec(); - bytes.extend_from_slice(&2u32.to_be_bytes()); - bytes.extend_from_slice(&(names.len() as u32).to_be_bytes()); - for name in names { - let units: Vec = name.encode_utf16().chain(std::iter::once(0)).collect(); - bytes.extend_from_slice(&(units.len() as u32).to_be_bytes()); - for unit in units { - bytes.extend_from_slice(&unit.to_be_bytes()); - } - while !bytes.len().is_multiple_of(4) { - bytes.push(0); - } - bytes.extend_from_slice(&1u32.to_be_bytes()); - let mut body = vec![2, b'i', b'd']; - for bound in [-10i32, -20, 90, 180] { - body.extend_from_slice(&bound.to_be_bytes()); - } - let knot = PsdKnot { linked: true, pre: (0.125, 0.25), anchor: (0.5, 0.5), post: (0.75, 0.875) }; - let path = PathData::from_subpaths(&[PsdSubpath { closed: false, operation: 0, knots: vec![knot] }], false); - body.extend_from_slice(&path.to_bytes()); - while !body.len().is_multiple_of(4) { - body.push(0); - } - bytes.extend_from_slice(&(body.len() as u32).to_be_bytes()); - bytes.extend_from_slice(&body); - } - bytes -} - -#[test] -fn reads_multiple_names_padding_signed_bounds_and_cubic_controls() { - let bytes = fixture(&["Even", "Odd", "形😀"]); - let shapes = csh::parse(&bytes).unwrap(); - assert_eq!(shapes.iter().map(|s| s.name.as_str()).collect::>(), ["Even", "Odd", "形😀"]); - for shape in shapes { - assert_eq!(shape.bounds, [-10, -20, 90, 180]); - assert_eq!(shape.id, "id"); - let subpaths = shape.path.subpaths(); - assert_eq!(subpaths.len(), 1); - assert!(!subpaths[0].closed); - let knot = subpaths[0].knots[0]; - assert!(knot.linked); - assert_eq!(knot.pre, (0.125, 0.25)); - assert_eq!(knot.anchor, (0.5, 0.5)); - assert_eq!(knot.post, (0.75, 0.875)); - } -} - -#[test] -fn rejects_every_truncated_prefix_without_panicking() { - let bytes = fixture(&["One", "Two"]); - for end in 0..bytes.len() { - assert!(csh::parse(&bytes[..end]).is_err(), "accepted truncated prefix {end}"); - } -} - -#[test] -fn rejects_unsupported_headers_excessive_counts_and_invalid_unicode() { - let bytes = fixture(&["A"]); - for (offset, value) in [(4, 3u32), (8, u32::MAX), (12, u32::MAX), (24, u32::MAX)] { - let mut broken = bytes.clone(); - broken[offset..offset + 4].copy_from_slice(&value.to_be_bytes()); - assert!(csh::parse(&broken).is_err(), "offset {offset}"); - } - // The count and name limits answer before the reader runs out of bytes, so a removed limit - // cannot hide behind a truncation error. One past each limit is refused by the limit itself. - for (offset, value, limit) in [ - (8, u32::MAX, "CSH shape count"), - (8, csh::MAX_SHAPES as u32 + 1, "CSH shape count"), - (12, u32::MAX, "CSH name length"), - (12, csh::MAX_NAME_UNITS as u32 + 1, "CSH name length"), - ] { - let mut broken = bytes.clone(); - broken[offset..offset + 4].copy_from_slice(&value.to_be_bytes()); - assert_eq!(csh::parse(&broken), Err(PsdError::LimitExceeded(limit)), "offset {offset} value {value}"); - } - let mut broken = bytes.clone(); - broken[16..18].copy_from_slice(&0xd800u16.to_be_bytes()); - assert!(csh::parse(&broken).is_err()); - let mut trailing = bytes; - trailing.push(0); - assert!(csh::parse(&trailing).is_err()); -} - -#[test] -fn rejects_missing_knots_and_mismatched_open_closed_records() { - let bytes = fixture(&["A"]); - // Header/name/version/length = 28; id = 3; bounds = 16; two fill records = 52. - let length_record = 28 + 3 + 16 + 52; - let mut missing = bytes.clone(); - missing[length_record + 2..length_record + 4].copy_from_slice(&2u16.to_be_bytes()); - assert!(csh::parse(&missing).is_err()); - let mut mismatched = bytes; - mismatched[length_record..length_record + 2].copy_from_slice(&0u16.to_be_bytes()); - assert!(csh::parse(&mismatched).is_err()); -} diff --git a/crates/psd/tests/fixtures/atn/README.md b/crates/psd/tests/fixtures/atn/README.md deleted file mode 100644 index e0912ea462..0000000000 --- a/crates/psd/tests/fixtures/atn/README.md +++ /dev/null @@ -1,31 +0,0 @@ -# Synthetic ATN parser fixtures - -These seven tiny files are original synthetic test data. The four version-16 files were independently -encoded field by field from the version-16 layout documented in `src/atn.rs`, without calling -PhotoCraft's writer; the three version-12 files (`v12-*.atn`) encode the version-12 layout documented -there (#10743). They are licensed MIT OR Apache-2.0 under the repository's licenses. They contain no -user artwork, Photoshop program code or proprietary assets. They are not Photoshop-authored -interoperability oracles. - -| File | Bytes | Purpose | SHA-256 | -|---|---:|---|---| -| `empty.atn` | 13 | Empty set and explicit big-endian version | `6fd936f91296a13b0e7456a38b139a100fd17407d1c582d4502ce76413d01539` | -| `metadata.atn` | 214 | UTF-16 surrogate pairs/unpaired units, action order, function key, modifiers, panel flags, disabled step, unknown string event, dialog options | `cec2ff5a25e003aa29e790ea6b110f06ba848f0afd7901f3f8ad4740f782ede2` | -| `descriptors.atn` | 859 | Every descriptor value variant and every reference form currently represented by the parser | `6c76502822c1f36c8060a71adb227c4540663518785bbab1b70951dac0ef58ad` | -| `float-bits.atn` | 428 | NaN payload, negative zero, infinities, and floats inside lists, objects and arrays | `0aa6278a569c777f249b305e889896132c2a87e44a24cb1a55d1d3fc0928fd0c` | -| `v12-stop.atn` | 113 | Version 12: Stop with Unicode text and a Boolean | `293462a7d5cf0043fdc5135630524699f545679088c54bad83f4a86e15160271` | -| `v12-values.atn` | 562 | Version 12: named plug-in event, every legacy value and reference form, bare `warp`/`list`/`time` codes, an object array with three objects and two columns, unpaired UTF-16, opaque path/alias/data bytes, and a descriptorless step | `23d3f377996435988367365ee5c2a15462c0ed42ce506dc4a15039387ac6a853` | -| `v12-commands.atn` | 404 | Version 12: Gaussian Blur, Levels, Select All and Make Layer | `451958b686da9aab45e65ae902c86333b7149d72021c21904e3aae8e39eb77cb` | - -`tests/it/atn_contract.rs` checks the independently encoded version-16 bytes and expected public -structures; `tests/it/atn_v12.rs` (and `photocraft-engine`'s `tests/it/atn_v12.rs`) check the -version-12 files. The version-12 files follow the version 12 layout in `src/atn.rs`, assembled from -the measured grammar in #10603. Their `Idnt` reference follows the inferred index-like layout; no -real version 12 identifier reference was available. Saving them produces version 16, so keep them -out of the version-16 byte-identical corpus oracle. - -The files also seed the `atn` fuzz target: `cargo fuzz run atn crates/psd/tests/fixtures/atn` -(from `crates/psd`: `cargo fuzz run atn tests/fixtures/atn`). - -A fixture change should explain the changed wire fields and update this table; do not regenerate -expectations merely to make a failing parser pass. diff --git a/crates/psd/tests/fixtures/atn/descriptors.atn b/crates/psd/tests/fixtures/atn/descriptors.atn deleted file mode 100644 index 26b1bdd361babd5886fa5b910a41cbba94245c67..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 859 zcmY*Y!EVz)5FHacp(;gHFL1P!J10&^s4Zy99@x~p-oLu zIcz1DtjW1s60ABD28 zZYD?EV~2|%(~D&NXhVDHgXY6sW+bvb{_V?YTd2ZaDtF$WnDpe;x+NmU?WH=G{mD`i z+G&ewpRx|yTmXkl=RdXwQ|fjJ4&rYwzt=zf98oPwez?Z#{dhasnUD)su2(3BIU1=| zUUMcuB!$T_)nyg*hxw;3Uw;zV<4#eiR;4JF!dkvLO$`}8+Wn1u1ExvCK}+*ZAU_ha z5S9$f7s7o|b3!HDeB$OYxlaZ!uC}MVDoT;#v&!!h&&e*xYb#x(!{ diff --git a/crates/psd/tests/fixtures/atn/empty.atn b/crates/psd/tests/fixtures/atn/empty.atn deleted file mode 100644 index dbcd73d1960acb305ebb3f59856a5b13e1c1f265..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13 NcmZQzU=Uz{0RRB401*HH diff --git a/crates/psd/tests/fixtures/atn/float-bits.atn b/crates/psd/tests/fixtures/atn/float-bits.atn deleted file mode 100644 index 0f272adea8052e66dc94016aba18757b1d53925e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 428 zcmY*W%}&EG3{HnSegsm*6Cmvsc!6;!AR)Dvap0D;S(nMOQIbx;g*`GyZo}>xD3nqh;xGJTt`r5O&dq$k0Uzq~ncoF&x;&;PbVhcMLLc#_I$%w$z)?Z;80R zQ`(uJ1}r=GYo(w zfEkWZu#nB>^J>@-JRW=@Fl9E!4jIP7$UVPQTvogsyZcPnsn$sueXeVf+6Y2l} diff --git a/crates/psd/tests/fixtures/atn/v12-values.atn b/crates/psd/tests/fixtures/atn/v12-values.atn deleted file mode 100644 index 0fc46dde8ec6b368b474e8ce45a61bd934cf41c1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 562 zcmYjO!A^rf6da(&n)IO2M2{pM{0uR*RTCnOX&bK#Y>{SJ$g;%Z$v^WqJo*Rv1=ja~ zgCxVeH*a<`*#}?(;1D4sG6*DS0f!_99+6Ac2v2CBd)ajyxXan|k_MAdW+G`9s>yO1 z1AF?2PF{tn;<8kZ%4>V4;wiJu3n_I|Ftb6GI%Ck8@1!g$#yj5voTp_IGd{k$9*xfJ zO56G6VyA*puW^VWi2F%W6mJs--%YKvQ6l3Ipa2nrL-0}CRtW9U6L-J3VP(sRX0sQ0da*a-yPpLpCCYh#oDzTmjmgR_AzdNy)Xm+{u8?yxdt z6}HBIEPQ{e>iRY*^h_6qwaBD;X) VersionedDescriptor { - VersionedDescriptor::new( - Descriptor::default() - .with("AFSt", Value::Integer(7)) - .with( - "FrIn", - Value::List(vec![ - Value::Descriptor( - Descriptor::default().with("FrID", Value::Integer(-3)).with("FrDl", Value::Integer(3)).with("future", Value::Boolean(true)), - ), - Value::Descriptor(Descriptor::default().with("FrID", Value::Integer(21)).with("FrDl", Value::Integer(53))), - ]), - ) - .with( - "FSts", - Value::List(vec![Value::Descriptor( - Descriptor::default() - .with("FsID", Value::Integer(7)) - .with("AFrm", Value::Integer(0)) - .with("FsFr", Value::List(vec![Value::Integer(21), Value::Integer(-3)])) - .with("future", Value::RawData(vec![1, 2, 3])), - )]), - ), - ) -} - -fn resource(data: &[u8]) -> AnimationResource<'_> { - AnimationResource { - records: vec![ - AnimationRecord { key: *b"AnDs", data }, - AnimationRecord { key: *b"Roll", data: &[0; 8] }, - AnimationRecord { key: *b"next", data: &[9, 8, 7] }, - ], - } -} - -#[test] -fn raw_roundtrip_and_typed_views_preserve_order_and_unknown_data() { - let data = descriptor().to_bytes(); - let bytes = resource(&data).to_bytes().unwrap(); - let parsed = AnimationResource::from_bytes(&bytes).unwrap(); - assert_eq!(parsed, resource(&data)); - let d = parsed.records[0].descriptor().unwrap().unwrap(); - assert_eq!(d, descriptor()); - let frames = frame_info(&d.descriptor).unwrap(); - assert_eq!(frames.iter().map(|f| (f.id, f.delay_centiseconds)).collect::>(), [(-3, Some(3)), (21, Some(53))]); - assert_eq!(frames[0].descriptor.get("future"), Some(&Value::Boolean(true))); - let sets = frame_sets(&d.descriptor).unwrap(); - assert_eq!(sets[0].frame_ids, [21, -3], "playback order is not FrIn order or sorted id order"); - assert_eq!(sets[0].id, 7); - assert_eq!(sets[0].active_frame, Some(0)); - assert_eq!(sets[0].descriptor.get("future"), Some(&Value::RawData(vec![1, 2, 3]))); - assert!(parsed.records[1].descriptor().is_none()); - assert_eq!(parsed.to_bytes().unwrap(), bytes); -} - -#[test] -fn plugin_signature_not_a_single_resource_id_selects_the_parser() { - let data = descriptor().to_bytes(); - let bytes = resource(&data).to_bytes().unwrap(); - for id in [4000, 4003, 4999] { - let r = ImageResource::new(id, bytes.clone()); - assert!(matches!(r.parsed(), Some(Ok(ResourceData::Animation(_))))); - } - for id in [3999, 5000] { - assert!(ImageResource::new(id, bytes.clone()).parsed().is_none()); - } - assert!(ImageResource::new(4003, b"different plugin".to_vec()).parsed().is_none()); - assert!(matches!(ImageResource::new(4003, b"mani".to_vec()).parsed(), Some(Err(_)))); - assert!(matches!( - ImageResource::new(1075, VersionedDescriptor::new(Descriptor::default().with("Vrsn", Value::Integer(1))).to_bytes()).parsed(), - Some(Ok(ResourceData::TimelineInfo(_))) - )); -} - -#[test] -fn metadata_views_do_not_touch_xmp_or_other_items() { - let d = VersionedDescriptor::new(Descriptor::default().with("LaID", Value::Integer(42)).with( - "LaSt", - Value::List(vec![Value::Descriptor( - Descriptor::default() - .with("FrLs", Value::List(vec![Value::Integer(-3)])) - .with("blendOptions", Value::Descriptor(Descriptor::default().with("Opct", Value::Double(12.5)))), - )]), - )); - let items = vec![ - MetadataItem::new(*b"cust", vec![7, 9, 11]), - MetadataItem::new(*b"mlst", d.to_bytes()), - MetadataItem::new(*b"tmln", VersionedDescriptor::new(Descriptor::default().with("trackList", Value::List(vec![]))).to_bytes()), - MetadataItem::new(*b"next", vec![255; 4]), - ]; - let raw = write_shmd(&items); - let parsed = parse_shmd(&raw).unwrap(); - assert!(parsed[0].animation_descriptor().is_none()); - let layer_descriptor = parsed[1].animation_descriptor().unwrap().unwrap(); - assert_eq!(layer_descriptor, d); - let states = layer_frame_states(&layer_descriptor.descriptor).unwrap(); - assert_eq!(states.layer_id, 42); - assert_eq!(states.states[0].frame_ids, [-3]); - assert_eq!(states.states[0].descriptor.get("enab"), None, "missing visibility is not false"); - assert_eq!(states.states[0].descriptor.get("blendOptions"), Some(&Value::Descriptor(Descriptor::default().with("Opct", Value::Double(12.5))))); - assert!(parsed[2].animation_descriptor().unwrap().is_ok()); - assert!(parsed[3].animation_descriptor().is_none()); - assert_eq!(write_shmd(&parsed), raw); - let mut bad = parsed[1].clone(); - bad.signature = *b"nope"; - assert!(bad.animation_descriptor().unwrap().is_err()); -} - -#[test] -fn psd_and_psb_keep_animation_resources_and_metadata_byte_stable() { - let data = descriptor().to_bytes(); - let raw = resource(&data).to_bytes().unwrap(); - for version in [Version::Psd, Version::Psb] { - let mut file = PsdBuilder::new(1, 1).version(version).build().unwrap(); - file.resources.push(ImageResource::new(4003, raw.clone())); - file.global_blocks.push(TaggedBlock::new(*b"shmd", write_shmd(&[MetadataItem::new(*b"mlst", data.clone())]))); - let bytes = file.to_bytes().unwrap(); - let back = PsdFile::from_bytes(&bytes).unwrap(); - assert_eq!(back.to_bytes().unwrap(), bytes); - let animation = back.resource(4003).unwrap(); - assert!(matches!(animation.parsed(), Some(Ok(ResourceData::Animation(_))))); - } -} - -#[test] -fn every_truncation_and_hostile_envelope_length_is_rejected() { - let data = descriptor().to_bytes(); - let bytes = resource(&data).to_bytes().unwrap(); - for end in 0..bytes.len() { - assert!(AnimationResource::from_bytes(&bytes[..end]).is_err(), "prefix {end}"); - } - for at in [8, 20] { - for value in [0, 1, u32::MAX] { - let mut bad = bytes.clone(); - bad[at..at + 4].copy_from_slice(&value.to_be_bytes()); - assert!(AnimationResource::from_bytes(&bad).is_err(), "length {at} = {value}"); - } - } - for at in [0, 4, 12] { - let mut bad = bytes.clone(); - bad[at] = b'?'; - assert!(AnimationResource::from_bytes(&bad).is_err()); - } - let mut trailing = bytes.clone(); - trailing.push(0); - assert!(AnimationResource::from_bytes(&trailing).is_err()); -} - -#[test] -fn descriptor_padding_truncation_and_unknown_versions() { - let raw = descriptor().to_bytes(); - for end in 0..raw.len() { - assert!(parse_descriptor(&raw[..end]).is_err(), "descriptor prefix {end}"); - } - for padding in 0..=3 { - let mut padded = raw.clone(); - padded.extend(vec![0; padding]); - assert_eq!(parse_descriptor(&padded).unwrap(), descriptor()); - } - for tail in [vec![0; 4], vec![1], vec![0, 2]] { - let mut bad = raw.clone(); - bad.extend(tail); - assert!(parse_descriptor(&bad).is_err()); - } - let mut future = raw.clone(); - future[..4].copy_from_slice(&17u32.to_be_bytes()); - let bytes = resource(&future).to_bytes().unwrap(); - let r = AnimationResource::from_bytes(&bytes).unwrap(); - assert!(r.records[0].descriptor().unwrap().is_err()); - assert_eq!(r.to_bytes().unwrap(), bytes, "unsupported typed version must survive raw"); -} - -#[test] -fn absent_delays_and_duplicate_ids_are_not_silently_repaired() { - let d = Descriptor::default().with( - "FrIn", - Value::List(vec![ - Value::Descriptor(Descriptor::default().with("FrID", Value::Integer(4))), - Value::Descriptor(Descriptor::default().with("FrID", Value::Integer(4)).with("FrDl", Value::Integer(0))), - ]), - ); - let frames = frame_info(&d).unwrap(); - assert_eq!(frames.len(), 2); - assert_eq!(frames[0].delay_centiseconds, None); - assert_eq!(frames[1].delay_centiseconds, Some(0)); - let bytes = - AnimationResource { records: vec![AnimationRecord { key: *b"next", data: &[] }, AnimationRecord { key: *b"next", data: &[1] }] }.to_bytes().unwrap(); - assert_eq!(AnimationResource::from_bytes(&bytes).unwrap().to_bytes().unwrap(), bytes); -} - -#[test] -fn wrong_frame_and_set_types_return_errors() { - for value in [Value::Boolean(false), Value::List(vec![Value::Integer(1)]), Value::List(vec![Value::Descriptor(Descriptor::default())])] { - assert!(frame_info(&Descriptor::default().with("FrIn", value.clone())).is_err()); - assert!(frame_sets(&Descriptor::default().with("FSts", value)).is_err()); - } - for delay in [Value::Integer(-1), Value::Double(3.0)] { - let frame = Descriptor::default().with("FrID", Value::Integer(1)).with("FrDl", delay); - assert!(frame_info(&Descriptor::default().with("FrIn", Value::List(vec![Value::Descriptor(frame)]))).is_err()); - } - for (key, value) in [("FsID", Value::Boolean(false)), ("AFrm", Value::Double(0.0)), ("FsFr", Value::List(vec![Value::Boolean(true)]))] { - let mut set = Descriptor::default().with("FsID", Value::Integer(1)).with("FsFr", Value::List(vec![])); - set.items.retain(|(id, _)| !id.is(key)); - set = set.with(key, value); - assert!(frame_sets(&Descriptor::default().with("FSts", Value::List(vec![Value::Descriptor(set)]))).is_err()); - } -} - -#[test] -fn sparse_layer_states_retain_missing_properties_and_multiple_references() { - let first = Descriptor::default() - .with("FrLs", Value::List(vec![Value::Integer(21), Value::Integer(-3)])) - .with("enab", Value::Boolean(true)) - .with("Ofst", Value::Descriptor(Descriptor::default().with("Hrzn", Value::Integer(-7)).with("Vrtc", Value::Integer(12)))); - let second = Descriptor::default().with("FrLs", Value::List(vec![Value::Integer(99)])); - let d = Descriptor::default().with("LaID", Value::Integer(8)).with("LaSt", Value::List(vec![Value::Descriptor(first.clone()), Value::Descriptor(second)])); - let view = layer_frame_states(&d).unwrap(); - assert_eq!(view.states[0].frame_ids, [21, -3]); - assert_eq!(view.states[0].descriptor, &first); - assert_eq!(view.states[1].descriptor.get("Ofst"), None); - assert_eq!(view.states[1].descriptor.get("enab"), None); - for d in [ - Descriptor::default(), - Descriptor::default().with("LaID", Value::Boolean(true)), - Descriptor::default().with("LaID", Value::Integer(8)).with("LaSt", Value::Boolean(true)), - Descriptor::default().with("LaID", Value::Integer(8)).with("LaSt", Value::List(vec![Value::Integer(0)])), - ] { - assert!(layer_frame_states(&d).is_err()); - } - for state in [ - Descriptor::default(), - Descriptor::default().with("FrLs", Value::Integer(0)), - Descriptor::default().with("FrLs", Value::List(vec![Value::Boolean(true)])), - ] { - let d = Descriptor::default().with("LaID", Value::Integer(8)).with("LaSt", Value::List(vec![Value::Descriptor(state)])); - assert!(layer_frame_states(&d).is_err()); - } -} - -proptest! { - #[test] - fn arbitrary_animation_bytes_never_panic(data in proptest::collection::vec(any::(), 0..2048)) { - let _ = AnimationResource::from_bytes(&data); - let _ = parse_descriptor(&data); - } - - #[test] - fn opaque_record_roundtrip(data in proptest::collection::vec(any::(), 0..2048), key in any::<[u8; 4]>()) { - let r = AnimationResource { records: vec![AnimationRecord { key, data: &data }] }; - let bytes = r.to_bytes().unwrap(); - prop_assert_eq!(AnimationResource::from_bytes(&bytes).unwrap(), r); - } -} diff --git a/crates/psd/tests/it/atn_contract.rs b/crates/psd/tests/it/atn_contract.rs deleted file mode 100644 index f6c38bf81c..0000000000 --- a/crates/psd/tests/it/atn_contract.rs +++ /dev/null @@ -1,243 +0,0 @@ -//! Public-API contract for the `.atn` parser, against independently encoded wire fixtures. -use photocraft_psd::PsdError; -use photocraft_psd::atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}; -use photocraft_psd::descriptor::{Class, Descriptor, Id, MAX_DEPTH, ObjectArray, ReferenceItem, UnicodeString, Value}; - -#[path = "common/atn_roundtrip.rs"] -mod atn_roundtrip; - -const FIXTURES: &[(&str, &[u8])] = &[ - ("empty", include_bytes!("../fixtures/atn/empty.atn")), - ("metadata", include_bytes!("../fixtures/atn/metadata.atn")), - ("descriptors", include_bytes!("../fixtures/atn/descriptors.atn")), - ("float bits", include_bytes!("../fixtures/atn/float-bits.atn")), -]; - -fn file_with(desc: Descriptor) -> AtnFile { - AtnFile { - actions: vec![AtnAction { - items: vec![AtnItem { event: AtnEvent::Code(*b"test"), descriptor: Some(desc), ..Default::default() }], - ..Default::default() - }], - ..Default::default() - } -} - -fn class(id: &str) -> Class { - Class { name: UnicodeString::default(), class_id: Id::new(id) } -} - -#[test] -fn independent_wire_fixtures_parse_and_write_byte_exactly() { - for (name, bytes) in FIXTURES { - let parsed = atn::parse(bytes).unwrap(); - assert!(parsed.warnings.is_empty(), "{name}"); - assert_eq!(atn::write(&parsed).unwrap(), *bytes, "{name}"); - atn_roundtrip::check(bytes); - } - assert_eq!(atn::write(&AtnFile::default()).unwrap(), FIXTURES[0].1); - assert_eq!(&FIXTURES[0].1[..4], &[0, 0, 0, 16], "the wire version is big-endian on every host"); -} - -#[test] -fn unicode_metadata_unknown_events_and_dialog_flags_are_preserved() { - let file = atn::parse(FIXTURES[1].1).unwrap(); - assert_eq!(file.name.0, vec![0x0053, 0x0065, 0x0074, 0x0020, 0xd83d, 0xde80, 0xd800, 0]); - assert!(file.expanded); - assert_eq!(file.actions.len(), 2); - let action = &file.actions[0]; - assert_eq!(action.name, UnicodeString::new_nul("Action Ω")); - assert_eq!((action.function_key, action.shift, action.command, action.color, action.expanded), (12, true, true, 7, true)); - let blur = &action.items[0]; - assert_eq!((blur.expanded, blur.enabled, blur.with_dialog, blur.dialog_options), (true, true, true, 2)); - assert_eq!(blur.event, AtnEvent::Code(*b"GsnB")); - assert_eq!(blur.descriptor, Some(Descriptor::new("null").with("Rds ", Value::UnitFloat { unit: *b"#Pxl", value: 2.5 }))); - let future = &action.items[1]; - assert_eq!(future.event, AtnEvent::Name("syntheticFutureEvent".into())); - assert_eq!((future.expanded, future.enabled, future.with_dialog, future.dialog_options), (false, false, true, 255)); - assert_eq!(future.dictionary, b"Future event"); - assert!(future.descriptor.is_none()); - assert_eq!(file.actions[1].name, UnicodeString::new_nul("Empty")); - assert!(file.actions[1].items.is_empty()); -} - -#[test] -fn every_descriptor_variant_and_reference_form_is_retained() { - let expected = Descriptor::new("syntheticRoot") - .with( - "reference", - Value::Reference(vec![ - ReferenceItem::Property { class: class("Lyr "), key: Id::new("Nm ") }, - ReferenceItem::Class(class("Dcmn")), - ReferenceItem::Enumerated { class: class("Lyr "), type_id: Id::new("Ordn"), value: Id::new("Trgt") }, - ReferenceItem::Offset { class: class("Lyr "), offset: -3 }, - ReferenceItem::Identifier { class: class("Lyr "), id: 0x1234_5678 }, - ReferenceItem::Index { class: class("Lyr "), index: 4 }, - ReferenceItem::Name { class: class("Lyr "), name: UnicodeString::new_nul("Layer Ω") }, - ]), - ) - .with("nested", Value::Descriptor(Descriptor::new("child").with("flag", Value::Boolean(true)))) - .with("global", Value::GlobalObject(Descriptor::new("global"))) - .with("list", Value::List(vec![Value::Boolean(false), Value::Double(-3.5), Value::Text(UnicodeString::new("Ω"))])) - .with("double", Value::Double(1.25)) - .with("unit", Value::UnitFloat { unit: *b"#Prc", value: 37.5 }) - .with("units", Value::UnitFloats { unit: *b"#Pxl", values: vec![1.25, -0.0, -2.5] }) - .with("text", Value::Text(UnicodeString(vec![0x0054, 0x0065, 0x0078, 0x0074, 0x0020, 0xd83d, 0xde80, 0xdfff, 0]))) - .with("enum", Value::Enumerated { type_id: Id::new("Ordn"), value: Id::new("syntheticValue") }) - .with("integer", Value::Integer(i32::MIN)) - .with("large", Value::LargeInteger(0x0123_4567_89ab_cdef)) - .with("boolean", Value::Boolean(true)) - .with("class", Value::Class(class("Lyr "))) - .with("globalClass", Value::GlobalClass(class("Dcmn"))) - .with("alias", Value::Alias(vec![0, 255, 1, 128])) - .with("path", Value::Path(b"C:\\synthetic\\image.psd\0".to_vec())) - .with("raw", Value::RawData(vec![0, 127, 128, 255])) - .with( - "objectArray", - Value::ObjectArray(ObjectArray { - prefix: 16, - body: Descriptor::new("rationalPoint").with("Hrzn", Value::UnitFloats { unit: *b"#Pxl", values: vec![1.0, 2.0] }), - }), - ); - let file = atn::parse(FIXTURES[2].1).unwrap(); - assert_eq!(file.actions[0].items[0].descriptor, Some(expected)); -} - -#[test] -fn nan_payloads_signed_zero_and_infinities_survive_without_false_fuzz_failures() { - let file = atn::parse(FIXTURES[3].1).unwrap(); - let descriptor = file.actions[0].items[0].descriptor.as_ref().unwrap(); - for (key, bits) in [ - ("nan", 0x7ff8_0000_0000_1234), - ("negativeZero", 0x8000_0000_0000_0000), - ("positiveInfinity", 0x7ff0_0000_0000_0000), - ("negativeInfinity", 0xfff0_0000_0000_0000), - ] { - let Some(Value::Double(value)) = descriptor.get(key) else { panic!("missing {key}") }; - assert_eq!(value.to_bits(), bits, "{key}"); - } - // Ordinary AST PartialEq fails for NaN, despite this valid byte-exact round trip. - assert_ne!(file.actions.clone(), file.actions); - atn_roundtrip::check(FIXTURES[3].1); -} - -#[test] -fn every_truncated_fixture_is_refused() { - for (name, bytes) in FIXTURES { - for end in 0..bytes.len() { - assert!(atn::parse(&bytes[..end]).is_err(), "{name}: cut at {end}/{}", bytes.len()); - } - } -} - -#[test] -fn trailing_bytes_only_add_a_diagnostic() { - for (name, bytes) in FIXTURES { - let mut input = bytes.to_vec(); - input.extend_from_slice(b"extra"); - let mut parsed = atn::parse(&input).unwrap(); - assert_eq!(parsed.warnings, ["5 trailing bytes ignored"], "{name}"); - parsed.warnings.clear(); - assert_eq!(atn::write(&parsed).unwrap(), *bytes, "{name}"); - atn_roundtrip::check(&input); - } -} - -#[test] -fn unsupported_versions_and_invalid_item_tags_are_refused() { - for version in [0_u32, 7, 11, 15, 17, u32::MAX] { - let mut input = FIXTURES[0].1.to_vec(); - input[..4].copy_from_slice(&version.to_be_bytes()); - assert!(matches!(atn::parse(&input), Err(PsdError::Unsupported(_)))); - } - // Version 12 (Photoshop 5) is read too (#10743): the smallest set parses to an empty set. - let mut v12 = FIXTURES[0].1.to_vec(); - v12[..4].copy_from_slice(&12_u32.to_be_bytes()); - assert!(atn::parse(&v12).unwrap().actions.is_empty()); - let file = file_with(Descriptor::new("null")); - let valid = atn::write(&file).unwrap(); - // The empty set/action names put the first step at offset 28. - for kind in [*b"bad!", *b"LONG", [0; 4]] { - let mut input = valid.clone(); - input[32..36].copy_from_slice(&kind); - assert!(matches!(atn::parse(&input), Err(PsdError::Invalid(_)))); - } - for flag in [-2_i32, 1, i32::MAX] { - let mut input = valid.clone(); - input[44..48].copy_from_slice(&flag.to_be_bytes()); - assert!(matches!(atn::parse(&input), Err(PsdError::Invalid(_)))); - } -} - -#[test] -fn declared_action_and_step_limits_are_enforced() { - let mut actions = FIXTURES[0].1.to_vec(); - let count = (atn::MAX_ACTIONS + 1) as u32; - actions[9..13].copy_from_slice(&count.to_be_bytes()); - actions.resize(13 + count as usize * 15, 0); - assert!(matches!(atn::parse(&actions), Err(PsdError::LimitExceeded("too many actions in one set")))); - - let mut steps = atn::write(&AtnFile { actions: vec![AtnAction::default()], ..Default::default() }).unwrap(); - let count = (atn::MAX_ITEMS + 1) as u32; - steps[24..28].copy_from_slice(&count.to_be_bytes()); - steps.resize(28 + count as usize * 20, 0); - assert!(matches!(atn::parse(&steps), Err(PsdError::LimitExceeded("too many steps in one action")))); - - let mut short = FIXTURES[0].1.to_vec(); - short[9..13].copy_from_slice(&u32::MAX.to_be_bytes()); - assert!(matches!(atn::parse(&short), Err(PsdError::UnexpectedEof { .. }))); -} - -#[test] -fn nested_descriptors_obey_the_parser_depth_limit() { - let mut desc = Descriptor::new("leaf").with("flag", Value::Boolean(true)); - for _ in 0..2 { - desc = Descriptor::new("node").with("next", Value::Descriptor(desc)); - } - let shallow = atn::write(&file_with(desc.clone())).unwrap(); - atn_roundtrip::check(&shallow); - assert!(atn::parse(&shallow).is_ok()); - for _ in 0..MAX_DEPTH + 2 { - desc = Descriptor::new("node").with("next", Value::Descriptor(desc)); - } - let deep = atn::write(&file_with(desc)).unwrap(); - assert!(matches!(atn::parse(&deep), Err(PsdError::LimitExceeded(_)))); -} - -#[test] -fn generated_metadata_and_float_patterns_round_trip() { - let mut seed = 0x9e37_79b9_7f4a_7c15_u64; - for index in 0..256_u16 { - seed ^= seed << 13; - seed ^= seed >> 7; - seed ^= seed << 17; - let mut file = file_with(Descriptor::new("generated").with("value", Value::Double(f64::from_bits(seed)))); - file.name = UnicodeString(vec![index, 0xd800 | index, 0, 0xffff]); - file.expanded = index & 1 != 0; - let action = &mut file.actions[0]; - action.function_key = index; - action.shift = index & 2 != 0; - action.command = index & 4 != 0; - action.color = index; - action.expanded = index & 8 != 0; - action.name = UnicodeString(vec![0xdc00 | index, index]); - let item = &mut action.items[0]; - item.expanded = index & 16 != 0; - item.enabled = index & 32 != 0; - item.with_dialog = index & 64 != 0; - item.dialog_options = index as u8; - atn_roundtrip::check(&atn::write(&file).unwrap()); - } -} - -#[test] -fn actual_fuzz_invariant_handles_deterministic_seed_mutations() { - for (_, bytes) in FIXTURES { - for index in 0..128 { - let mut mutated = bytes.to_vec(); - let at = index * 47 % mutated.len(); - mutated[at] ^= (index as u8).wrapping_mul(13).wrapping_add(1); - atn_roundtrip::check(&mutated); - } - } -} diff --git a/crates/psd/tests/it/atn_v12.rs b/crates/psd/tests/it/atn_v12.rs deleted file mode 100644 index 28f3921d1f..0000000000 --- a/crates/psd/tests/it/atn_v12.rs +++ /dev/null @@ -1,145 +0,0 @@ -use photocraft_psd::PsdError; -use photocraft_psd::atn; -use photocraft_psd::descriptor::{Class, Descriptor, Id, MAX_DEPTH, ObjectArray, ReferenceItem as R, UnicodeString, Value as V}; - -const STOP: &[u8] = include_bytes!("../fixtures/atn/v12-stop.atn"); -const VALUES: &[u8] = include_bytes!("../fixtures/atn/v12-values.atn"); -const COMMANDS: &[u8] = include_bytes!("../fixtures/atn/v12-commands.atn"); - -fn code(id: &[u8; 4]) -> Id { - Id::Code(*id) -} - -fn class(id: &[u8; 4]) -> Class { - Class { name: UnicodeString::default(), class_id: code(id) } -} - -fn descriptor(id: &[u8; 4], items: Vec<(Id, V)>) -> Descriptor { - Descriptor { name: UnicodeString::default(), class_id: code(id), items } -} - -/// The fixture bytes are independent of the version 16 writer and legacy reader. -#[test] -fn stop_and_named_event_preserve_headers_and_canonicalize_to_v16() { - let parsed = atn::parse(STOP).unwrap(); - assert_eq!(parsed.name, UnicodeString::new_nul("Legacy")); - let action = &parsed.actions[0]; - assert_eq!((action.function_key, action.shift, action.command, action.color), (3, true, false, 2)); - assert_eq!(action.name, UnicodeString::new_nul("Run")); - let step = &action.items[0]; - assert_eq!((step.expanded, step.enabled, step.with_dialog, step.dialog_options), (true, true, false, 2)); - assert_eq!(step.event, atn::AtnEvent::Code(*b"Stop")); - assert_eq!( - step.descriptor, - Some(descriptor(b"null", vec![(code(b"Msge"), V::Text(UnicodeString::new_nul("Review ✓"))), (code(b"Cntn"), V::Boolean(true)),])) - ); - let plugin = atn::parse(VALUES).unwrap(); - assert_eq!(plugin.actions[0].items[0].event, atn::AtnEvent::name("LegacyPlugin")); - assert!(plugin.actions[0].items[1].descriptor.is_none()); - for bytes in [STOP, VALUES, COMMANDS] { - let original = atn::parse(bytes).unwrap(); - let modern = atn::write(&original).unwrap(); - assert_eq!(&modern[..4], &16_u32.to_be_bytes()); - assert_eq!(atn::parse(&modern).unwrap(), original); - } -} - -#[test] -fn all_legacy_values_and_reference_classes_are_retained() { - let parsed = atn::parse(VALUES).unwrap(); - let actual = parsed.actions[0].items[0].descriptor.as_ref().unwrap(); - let floats = |values| V::UnitFloats { unit: *b"#Pxl", values }; - let expected = descriptor( - b"null", - vec![ - (code(b"warp"), V::Boolean(true)), - (code(b"list"), V::GlobalClass(class(b"list"))), - (code(b"time"), V::Enumerated { type_id: code(b"time"), value: code(b"warp") }), - (code(b"long"), V::Integer(-17)), - (code(b"comp"), V::LargeInteger(i64::MIN + 23)), - (code(b"doub"), V::Double(-0.5)), - (code(b"unit"), V::UnitFloat { unit: *b"#Pxl", value: 2.5 }), - (code(b"TEXT"), V::Text(UnicodeString(vec![0xd800, 0]))), - (code(b"Objc"), V::Descriptor(descriptor(b"warp", vec![(code(b"flag"), V::Boolean(false))]))), - (code(b"GlbO"), V::GlobalObject(descriptor(b"list", vec![]))), - (code(b"type"), V::Class(class(b"time"))), - (code(b"VlLs"), V::List(vec![V::Boolean(false), V::Descriptor(descriptor(b"time", vec![]))])), - ( - code(b"ObAr"), - V::ObjectArray(ObjectArray { - prefix: 3, - body: descriptor(b"time", vec![(code(b"Hrzn"), floats(vec![1.0, 2.0, 3.0])), (code(b"Vrtc"), floats(vec![4.0, 5.0, 6.0]))]), - }), - ), - ( - code(b"refs"), - V::Reference(vec![ - R::Property { class: class(b"Lyr "), key: code(b"Nm ") }, - R::Class(class(b"Dcmn")), - R::Enumerated { class: class(b"Lyr "), type_id: code(b"Ordn"), value: code(b"Trgt") }, - R::Offset { class: class(b"Lyr "), offset: -2 }, - R::Index { class: class(b"Lyr "), index: 3 }, - R::Identifier { class: class(b"Lyr "), id: 9 }, - R::Name { class: class(b"Lyr "), name: UnicodeString::new_nul("Layer ✓") }, - ]), - ), - (code(b"Pth "), V::Path(vec![0, 1, 255, 3])), - (code(b"alis"), V::Alias(b"alias".to_vec())), - (code(b"tdta"), V::RawData(vec![])), - ], - ); - assert_eq!(actual, &expected); -} - -fn with_descriptor(body: &[u8]) -> Vec { - let start = STOP.windows(4).position(|w| w == [255; 4]).unwrap() + 4; - let mut bytes = STOP[..start].to_vec(); - bytes.extend_from_slice(body); - bytes -} - -/// Reject every truncated fixture while preserving trailing data as a warning. -#[test] -fn truncations_fail_and_trailing_bytes_only_warn() { - for bytes in [STOP, VALUES, COMMANDS] { - for cut in 0..bytes.len() { - assert!(atn::parse(&bytes[..cut]).is_err(), "length {}, cut {cut}", bytes.len()); - } - let mut padded = bytes.to_vec(); - padded.extend_from_slice(b"junk"); - let mut parsed = atn::parse(&padded).unwrap(); - assert_eq!(parsed.warnings, ["4 trailing bytes ignored"]); - parsed.warnings.clear(); - assert_eq!(parsed, atn::parse(bytes).unwrap()); - } -} - -/// Reject excessive counts/depth and unsupported forms without dialect fallback. -#[test] -fn legacy_counts_depth_and_unknown_forms_fail_without_fallback() { - assert!(atn::parse(&with_descriptor(&u32::MAX.to_be_bytes())).is_err()); - for prefix in [b"VlLs".as_slice(), b"obj ", b"UnFl#Pxl", b"TEXT", b"Pth ", b"ObArtime\0\0\0\x03"] { - let mut body = b"\0\0\0\x01test".to_vec(); - body.extend_from_slice(prefix); - body.extend_from_slice(&u32::MAX.to_be_bytes()); - assert!(atn::parse(&with_descriptor(&body)).is_err()); - } - // The smallest descriptor item is a bare key/type and one Boolean byte. - assert!(atn::parse(&with_descriptor(b"\0\0\0\x01testbool\x01")).is_ok()); - for ty in [b"VlLs\0\0\0\x01".as_slice(), b"Objcnull\0\0\0\x01test"] { - let mut body = b"\0\0\0\x01test".to_vec(); - for _ in 0..=MAX_DEPTH { - body.extend_from_slice(ty); - } - body.extend_from_slice(b"bool\x01"); - assert!(matches!(atn::parse(&with_descriptor(&body)), Err(PsdError::LimitExceeded(_)))); - } - for body in [b"\0\0\0\x01testwhat\0".as_slice(), b"\0\0\0\x01testobj \0\0\0\x01whatLyr "] { - assert!(matches!(atn::parse(&with_descriptor(body)), Err(PsdError::Unsupported(_)))); - } - for version in [0_u32, 7, 11, 13, 17, 0x0005_1600] { - let mut bytes = STOP.to_vec(); - bytes[..4].copy_from_slice(&version.to_be_bytes()); - assert!(matches!(atn::parse(&bytes), Err(PsdError::Unsupported(message)) if message.contains(&version.to_string()))); - } -} diff --git a/crates/psd/tests/it/builder.rs b/crates/psd/tests/it/builder.rs deleted file mode 100644 index e5c6c09354..0000000000 --- a/crates/psd/tests/it/builder.rs +++ /dev/null @@ -1,309 +0,0 @@ -//! Builder and pixel extraction tests. - -use photocraft_psd::*; - -fn rgba_pattern(w: usize, h: usize, seed: u8) -> Vec { - let mut v = Vec::with_capacity(w * h * 4); - for y in 0..h { - for x in 0..w { - v.extend_from_slice(&[(x * 10) as u8 ^ seed, (y * 20) as u8, (x + y) as u8 ^ seed, if (x + y) % 3 == 0 { 0 } else { 200 + (x % 50) as u8 }]); - } - } - v -} - -fn built(b: &PsdBuilder) -> PsdFile { - let bytes = b.to_bytes().unwrap(); - let f = PsdFile::from_bytes(&bytes).unwrap(); - assert_eq!(f.to_bytes().unwrap(), bytes, "byte stable"); - assert_eq!(f, b.build().unwrap(), "model stable"); - f -} - -macro_rules! rgba8_case { - ($name:ident, $ver:expr, $comp:expr) => { - #[test] - fn $name() { - let px = rgba_pattern(7, 5, 3); - let mut b = PsdBuilder::new(10, 8).version($ver).compression($comp); - b.push_layer(LayerSpec::new("L", -2, 1, 7, 5, PixelData::Rgba8(px.clone()))); - let f = built(&b); - let img = f.layer(0).unwrap().rgba8().unwrap(); - assert_eq!((img.left, img.top, img.width, img.height), (-2, 1, 7, 5)); - assert_eq!(img.data, px); - } - }; -} - -rgba8_case!(rgba8_psd_raw, Version::Psd, Compression::Raw); -rgba8_case!(rgba8_psd_rle, Version::Psd, Compression::Rle); -rgba8_case!(rgba8_psd_zip, Version::Psd, Compression::Zip); -rgba8_case!(rgba8_psd_zipp, Version::Psd, Compression::ZipPrediction); -rgba8_case!(rgba8_psb_raw, Version::Psb, Compression::Raw); -rgba8_case!(rgba8_psb_rle, Version::Psb, Compression::Rle); -rgba8_case!(rgba8_psb_zip, Version::Psb, Compression::Zip); -rgba8_case!(rgba8_psb_zipp, Version::Psb, Compression::ZipPrediction); - -#[test] -fn rgba16_layers_scale_to_8bit() { - let n = 4 * 3; - let px: Vec = (0..n * 4).map(|i| (i as u16).wrapping_mul(5003)).collect(); - for comp in Compression::ALL { - let mut b = PsdBuilder::new(4, 3).depth(16).compression(comp); - b.push_layer(LayerSpec::new("x", 0, 0, 4, 3, PixelData::Rgba16(px.clone()))); - let f = built(&b); - assert!(matches!(f.layer_info_placement, LayerInfoPlacement::GlobalBlock { key: [b'L', b'r', b'1', b'6'], .. })); - let l = f.layer(0).unwrap(); - let img = l.rgba8().unwrap(); - let expect: Vec = px.iter().map(|&v| ((u32::from(v) * 255 + 32767) / 65535) as u8).collect(); - assert_eq!(img.data, expect); - // Raw 16-bit samples are exact. - let red = pixels::samples_u16(&l.channel_bytes(0).unwrap()); - assert_eq!(red, px.iter().step_by(4).copied().collect::>()); - } -} - -#[test] -fn grayscale_builder() { - let px: Vec = (0..6).flat_map(|i| [i * 40, 255 - i]).collect(); - let mut b = PsdBuilder::new(3, 2).color_mode(ColorMode::Grayscale); - b.push_layer(LayerSpec::new("g", 0, 0, 3, 2, PixelData::GrayA8(px.clone()))); - b.composite(PixelData::GrayA8(px.clone())); - let f = built(&b); - assert_eq!(f.header.color_mode, ColorMode::Grayscale); - let img = f.layer(0).unwrap().rgba8().unwrap(); - for i in 0..6 { - assert_eq!(&img.data[i * 4..i * 4 + 4], &[px[i * 2], px[i * 2], px[i * 2], px[i * 2 + 1]]); - } - let c = f.composite_rgba8().unwrap(); - assert_eq!(c.data, img.data); - assert_eq!(f.header.channels, 2); -} - -#[test] -fn grayscale16_builder() { - let px: Vec = vec![0, 65535, 32768, 65535]; - let mut b = PsdBuilder::new(2, 1).color_mode(ColorMode::Grayscale).depth(16); - b.push_layer(LayerSpec::new("g", 0, 0, 2, 1, PixelData::GrayA16(px))); - let f = built(&b); - assert_eq!(f.layer(0).unwrap().rgba8().unwrap().data, vec![0, 0, 0, 255, 128, 128, 128, 255]); -} - -#[test] -fn cmyk_builder_inverts_ink() { - // One pixel: C=0 M=0 Y=0 K=0 ink (white), one: K=255 (black). - let px = vec![0, 0, 0, 0, 255, 0, 0, 0, 255, 255]; - let mut b = PsdBuilder::new(2, 1).color_mode(ColorMode::Cmyk); - b.push_layer(LayerSpec::new("c", 0, 0, 2, 1, PixelData::Cmyka8(px.clone()))); - b.composite(PixelData::Cmyka8(px)); - let f = built(&b); - assert_eq!(f.header.channels, 4); - let l = f.layer(0).unwrap(); - assert_eq!(l.channel_bytes(0).unwrap(), vec![255, 255]); // stored inverted - assert_eq!(l.channel_bytes(3).unwrap(), vec![255, 0]); - assert_eq!(l.rgba8().unwrap().data, vec![255, 255, 255, 255, 0, 0, 0, 255]); - assert_eq!(f.composite_rgba8().unwrap().data, vec![255, 255, 255, 255, 0, 0, 0, 255]); -} - -#[test] -fn cmyk16_builder() { - let px = vec![0u16, 0, 0, 0, 65535]; - let mut b = PsdBuilder::new(1, 1).color_mode(ColorMode::Cmyk).depth(16); - b.push_layer(LayerSpec::new("c", 0, 0, 1, 1, PixelData::Cmyka16(px))); - let f = built(&b); - assert_eq!(f.layer(0).unwrap().rgba8().unwrap().data, vec![255, 255, 255, 255]); -} - -#[test] -fn layer_properties_roundtrip() { - let mut b = PsdBuilder::new(4, 4); - let mut spec = LayerSpec::new("Props \u{1F3A8}", 1, 1, 2, 2, PixelData::Rgba8(vec![9; 16])); - spec.blend_mode = BlendMode::SoftLight; - spec.opacity = 128; - spec.fill_opacity = Some(64); - spec.visible = false; - spec.clipping = true; - spec.extra_blocks.push(TaggedBlock::sheet_color(4)); - b.push_layer(spec); - let f = built(&b); - let r = &f.layers()[0]; - assert_eq!(r.name(), "Props \u{1F3A8}"); - assert_eq!(r.name, b"Props ?"); - assert_eq!(r.blend_mode, BlendMode::SoftLight); - assert_eq!(r.opacity, 128); - assert_eq!(r.fill_opacity(), 64); - assert!(!r.is_visible()); - assert_eq!(r.clipping, 1); - assert_eq!(r.layer_id(), Some(1)); - assert_eq!(r.block(b"lclr").unwrap().parsed(), Some(Ok(BlockData::SheetColor(4)))); -} - -#[test] -fn all_blend_modes_via_builder() { - let mut b = PsdBuilder::new(1, 1); - for m in BlendMode::ALL { - let mut s = LayerSpec::new(format!("{m:?}"), 0, 0, 1, 1, PixelData::Rgba8(vec![1, 2, 3, 4])); - s.blend_mode = m; - b.push_layer(s); - } - let f = built(&b); - let modes: Vec<_> = f.layers().iter().map(|l| l.blend_mode).collect(); - assert_eq!(modes, BlendMode::ALL.to_vec()); -} - -#[test] -fn masks_roundtrip() { - let mask_px: Vec = (0..12).map(|i| i * 20).collect(); - let mut s = LayerSpec::new("m", 0, 0, 3, 3, PixelData::Rgba8(vec![100; 36])); - s.mask = Some(MaskSpec { rect: Rect::from_xywh(-1, 0, 4, 3), data: mask_px.clone(), default_color: 255, disabled: true }); - for depth in [8u16, 16] { - let mut b = PsdBuilder::new(3, 3).depth(depth); - let mut s = s.clone(); - if depth == 16 { - s.pixels = PixelData::Rgba16(vec![25700; 36]); - } - b.push_layer(s); - let f = built(&b); - let l = f.layer(0).unwrap(); - let m = l.user_mask().unwrap().unwrap(); - assert_eq!((m.left, m.top, m.width, m.height), (-1, 0, 4, 3)); - assert_eq!(m.data, mask_px); - assert_eq!(m.default_value, 255); - assert!(l.record.layer_mask().unwrap().disabled()); - // Mask is not applied to rgba8. - assert!(l.rgba8().unwrap().data.chunks(4).all(|p| p == [100, 100, 100, 100])); - } -} - -#[test] -fn no_mask_returns_none() { - let mut b = PsdBuilder::new(1, 1); - b.push_layer(LayerSpec::new("x", 0, 0, 1, 1, PixelData::Rgba8(vec![0; 4]))); - let f = built(&b); - assert!(f.layer(0).unwrap().user_mask().is_none()); -} - -#[test] -fn empty_layer() { - let mut b = PsdBuilder::new(2, 2); - b.push_layer(LayerSpec::new("empty", 0, 0, 0, 0, PixelData::Rgba8(vec![]))); - let f = built(&b); - let img = f.layer(0).unwrap().rgba8().unwrap(); - assert_eq!((img.width, img.height), (0, 0)); - assert!(img.data.is_empty()); -} - -#[test] -fn composite_supplied_with_alpha() { - let px = vec![255, 0, 0, 128, 17, 89, 203, 0]; - let mut b = PsdBuilder::new(2, 1); - b.push_layer(LayerSpec::new("x", 0, 0, 2, 1, PixelData::Rgba8(px.clone()))); - b.composite(PixelData::Rgba8(px.clone())); - let f = built(&b); - assert_eq!(f.header.channels, 4); - assert!(f.layer_info.as_ref().unwrap().merged_alpha); - assert!(f.merged_has_alpha()); - assert_eq!(f.has_real_merged_data(), Some(true)); - assert_eq!(f.decode_merged().unwrap(), [255, 17, 127, 89, 127, 203, 128, 0]); - assert_eq!(f.composite_rgba8().unwrap().data, px); -} - -#[test] -fn composite_opaque_drops_alpha() { - let px: Vec = (0..6).flat_map(|i| [i, i, i, 255]).collect(); - let mut b = PsdBuilder::new(3, 2); - b.composite(PixelData::Rgba8(px.clone())); - let f = built(&b); - assert_eq!(f.header.channels, 3); - assert!(f.layer_info.is_none()); - assert_eq!(f.composite_rgba8().unwrap().data, px); -} - -#[test] -fn composite_16bit() { - let px: Vec = vec![65535, 0, 0, 65535, 0, 65535, 0, 32768]; - let mut b = PsdBuilder::new(2, 1).depth(16); - b.composite(PixelData::Rgba16(px)); - let f = built(&b); - assert_eq!(f.composite_rgba8().unwrap().data, vec![255, 0, 0, 255, 0, 255, 0, 128]); -} - -#[test] -fn placeholder_composite_is_flagged() { - let mut b = PsdBuilder::new(2, 2); - b.push_layer(LayerSpec::new("x", 0, 0, 1, 1, PixelData::Rgba8(vec![0; 4]))); - let f = built(&b); - assert_eq!(f.has_real_merged_data(), Some(false)); - assert!(f.composite_rgba8().unwrap().data.iter().all(|&v| v == 255)); -} - -#[test] -fn builder_resources() { - let b = PsdBuilder::new(1, 1).resolution(300.0).icc_profile(vec![1, 2, 3]); - let f = built(&b); - assert!((f.resolution().unwrap().v_res() - 300.0).abs() < 1e-9); - assert_eq!(f.icc_profile(), Some(&[1u8, 2, 3][..])); -} - -#[test] -fn builder_errors() { - let mut b = PsdBuilder::new(2, 2); - b.push_layer(LayerSpec::new("bad", 0, 0, 2, 2, PixelData::Rgba8(vec![0; 3]))); - assert!(b.build().is_err()); - let mut b = PsdBuilder::new(2, 2); - b.push_layer(LayerSpec::new("wrong fmt", 0, 0, 1, 1, PixelData::GrayA8(vec![0; 2]))); - assert!(b.build().is_err()); - let mut b = PsdBuilder::new(2, 2); - b.begin_group(GroupSpec::new("open")); - assert!(b.build().is_err()); - let mut b = PsdBuilder::new(2, 2); - assert!(b.end_group().is_err()); - assert!(PsdBuilder::new(1, 1).depth(32).build().is_err()); - assert!(PsdBuilder::new(1, 1).color_mode(ColorMode::Lab).build().is_err()); - let mut b = PsdBuilder::new(2, 2); - b.composite(PixelData::Rgba8(vec![0; 4])); - assert!(b.build().is_err()); - let mut b = PsdBuilder::new(2, 2); - let mut s = LayerSpec::new("m", 0, 0, 1, 1, PixelData::Rgba8(vec![0; 4])); - s.mask = Some(MaskSpec { rect: Rect::from_xywh(0, 0, 2, 2), data: vec![0; 3], default_color: 0, disabled: false }); - b.push_layer(s); - assert!(b.build().is_err()); -} - -#[test] -fn group_properties() { - let mut b = PsdBuilder::new(1, 1); - b.begin_group(GroupSpec { blend_mode: BlendMode::Multiply, opacity: 77, visible: false, ..GroupSpec::new("G") }); - b.push_layer(LayerSpec::new("x", 0, 0, 1, 1, PixelData::Rgba8(vec![0; 4]))); - b.end_group().unwrap(); - let f = built(&b); - let g = &f.layers()[2]; - assert_eq!(g.name(), "G"); - assert_eq!(g.opacity, 77); - assert!(!g.is_visible()); - let sd = g.section_divider().unwrap(); - assert_eq!(sd.kind, SectionType::OpenFolder); - assert_eq!(sd.blend_mode, Some(BlendMode::Multiply)); - assert_eq!(f.layers()[0].section_type(), SectionType::BoundingDivider); -} - -#[test] -fn psb_builder_large_canvas_header() { - let b = PsdBuilder::new(40_000, 1).version(Version::Psb).compression(Compression::Rle); - let f = built(&b); - assert_eq!(f.header.width, 40_000); - assert!(f.validate().is_ok()); -} - -#[test] -fn iter_layers_indices() { - let mut b = PsdBuilder::new(1, 1); - for i in 0..3 { - b.push_layer(LayerSpec::new(format!("l{i}"), 0, 0, 1, 1, PixelData::Rgba8(vec![0; 4]))); - } - let f = built(&b); - let v: Vec<_> = f.iter_layers().map(|l| (l.index, l.name())).collect(); - assert_eq!(v, vec![(0, "l0".into()), (1, "l1".into()), (2, "l2".into())]); - assert!(f.layer(3).is_none()); - assert_eq!(f.layer(0).unwrap().version(), Version::Psd); -} diff --git a/crates/psd/tests/it/common/atn_roundtrip.rs b/crates/psd/tests/it/common/atn_roundtrip.rs deleted file mode 100644 index ded43b55e2..0000000000 --- a/crates/psd/tests/it/common/atn_roundtrip.rs +++ /dev/null @@ -1,71 +0,0 @@ -//! Shared round-trip invariant for the `.atn` parser contract suite. -use photocraft_psd::atn::{self, AtnFile}; -use photocraft_psd::descriptor::{Descriptor, Value}; - -fn scalar(value: &mut f64, bits: &mut Vec) { - bits.push(value.to_bits()); - // AST equality must remain meaningful when a valid descriptor contains NaN. - if value.is_nan() { - *value = 0.0; - } -} - -fn descriptor(desc: &mut Descriptor, bits: &mut Vec) { - for (_, item) in &mut desc.items { - value(item, bits); - } -} - -fn value(item: &mut Value, bits: &mut Vec) { - match item { - Value::Double(v) | Value::UnitFloat { value: v, .. } => scalar(v, bits), - Value::UnitFloats { values, .. } => { - for v in values { - scalar(v, bits); - } - } - Value::Descriptor(d) | Value::GlobalObject(d) => descriptor(d, bits), - Value::ObjectArray(array) => descriptor(&mut array.body, bits), - Value::List(items) => { - for item in items { - value(item, bits); - } - } - Value::Reference(_) - | Value::Text(_) - | Value::Enumerated { .. } - | Value::Integer(_) - | Value::LargeInteger(_) - | Value::Boolean(_) - | Value::Class(_) - | Value::GlobalClass(_) - | Value::Alias(_) - | Value::Path(_) - | Value::RawData(_) => {} - } -} - -fn floats(file: &mut AtnFile) -> Vec { - let mut bits = Vec::new(); - for action in &mut file.actions { - for item in &mut action.items { - if let Some(d) = &mut item.descriptor { - descriptor(d, &mut bits); - } - } - } - bits -} - -pub fn check(data: &[u8]) { - if let Ok(mut original) = atn::parse(data) { - let encoded = atn::write(&original).expect("a parsed set is writable"); - let mut again = atn::parse(&encoded).expect("a written set parses"); - assert_eq!(atn::write(&again).expect("a reparsed set is writable"), encoded); - assert_eq!(floats(&mut original), floats(&mut again), "every floating-point bit pattern survives"); - // Trailing-input diagnostics are not serialized into an action set. - original.warnings.clear(); - again.warnings.clear(); - assert_eq!(again, original, "set metadata, action metadata and every descriptor value survive"); - } -} diff --git a/crates/psd/tests/it/corpus.rs b/crates/psd/tests/it/corpus.rs deleted file mode 100644 index a61f911e23..0000000000 --- a/crates/psd/tests/it/corpus.rs +++ /dev/null @@ -1,129 +0,0 @@ -//! Corpus test (feature `corpus`; run with `cargo xtask test-corpus`): iterates -//! `corpus/psd/**/*.{psd,psb}` and `corpus/psd-tools/**/*.{psd,psb}` at the workspace root -//! (gitignored, fetched and verified by `cargo xtask corpus --all`). A missing corpus fails. -#![cfg(feature = "corpus")] - -use std::path::{Path, PathBuf}; - -use photocraft_psd::PsdFile; - -fn collect(dir: &Path, out: &mut Vec) { - let Ok(rd) = std::fs::read_dir(dir) else { return }; - for e in rd.flatten() { - let p = e.path(); - if p.is_dir() { - collect(&p, out); - } else if p.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("psd") || e.eq_ignore_ascii_case("psb")) { - out.push(p); - } - } -} - -/// The existing pinned ag-psd corpus includes public animation cases. Exercise typed syntax -/// against those without adding personal files or new corpus pins. -#[test] -fn corpus_animation_syntax() { - use photocraft_psd::ResourceData; - use photocraft_psd::animation::{frame_info, frame_sets, layer_frame_states}; - use photocraft_psd::metadata::parse_shmd; - - let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/psd/ag-psd/read-write"); - for (case, delay) in [("animation-frame", Some(30)), ("animation-offset", Some(50)), ("animation-effects", None)] { - for name in ["src.psd", "expected.psd"] { - let path = root.join(case).join(name); - let bytes = std::fs::read(&path).unwrap_or_else(|e| panic!("{}: {e}; run cargo xtask corpus --psd", path.display())); - let psd = PsdFile::from_bytes(&bytes).unwrap(); - let mut found = 0; - let mut decoded_descriptors = 0; - for resource in &psd.resources { - if let Some(Ok(ResourceData::Animation(animation))) = resource.parsed() { - found += 1; - assert_eq!(animation.to_bytes().unwrap(), resource.data); - for record in &animation.records { - if let Some(d) = record.descriptor() { - let d = d.unwrap(); - decoded_descriptors += 1; - let frames = frame_info(&d.descriptor).unwrap(); - assert_eq!(frames.len(), 2, "{case}/{name}"); - assert!(frames.iter().all(|f| f.delay_centiseconds == delay), "{case}/{name}"); - let sets = frame_sets(&d.descriptor).unwrap(); - assert_eq!(sets.len(), 1); - assert_eq!(sets[0].frame_ids.len(), 2); - assert!(sets[0].frame_ids.iter().all(|id| frames.iter().any(|f| f.id == *id))); - } - } - } - } - assert_eq!(found, 1, "{case}/{name}"); - assert_eq!(decoded_descriptors, 1, "expected one decoded AnDs descriptor in {case}/{name}"); - let mut state_layers = 0; - for layer in psd.layers() { - for block in &layer.blocks { - if block.key == *b"shmd" { - for item in parse_shmd(&block.data).unwrap() { - if item.key == *b"mlst" { - let d = item.animation_descriptor().unwrap().unwrap(); - let states = layer_frame_states(&d.descriptor).unwrap(); - assert!(!states.states.is_empty()); - state_layers += 1; - } - } - } - } - } - assert_eq!(state_layers, 2, "{case}/{name}"); - assert_eq!(psd.to_bytes().unwrap(), bytes); - } - } -} - -/// Files known to be invalid upstream, with the reason. They are reported but -/// do not fail the test. -const KNOWN_BAD: &[(&str, &str)] = - &[("group-divider-blend-mode.psd", "malformed merged image data; strict parsing rejects it, but the importer recovers its group (#6152)")]; - -#[test] -fn corpus_parse_and_byte_stable() { - for dir in ["corpus/psd", "corpus/psd-tools"] { - parse_and_byte_stable(&Path::new(env!("CARGO_MANIFEST_DIR")).join("../..").join(dir)); - } -} - -fn parse_and_byte_stable(root: &Path) { - assert!(root.is_dir(), "{} is missing: run `cargo xtask corpus --all`", root.display()); - let mut files = Vec::new(); - collect(root, &mut files); - files.sort(); - let mut failures = Vec::new(); - for p in &files { - let name = p.strip_prefix(root).unwrap_or(p).display().to_string(); - let bytes = match std::fs::read(p) { - Ok(b) => b, - Err(e) => { - failures.push(format!("{name}: read error {e}")); - continue; - } - }; - match PsdFile::from_bytes(&bytes) { - Err(e) => { - if let Some((_, why)) = KNOWN_BAD.iter().find(|(f, _)| name.ends_with(f)) { - eprintln!("KNOWN {name}: parse error: {e} ({why})"); - } else { - eprintln!("FAIL {name}: parse error: {e}"); - failures.push(format!("{name}: parse: {e}")); - } - } - Ok(f) => match f.to_bytes() { - Ok(out) if out == bytes => eprintln!("ok {name} ({} layers)", f.layers().len()), - Ok(out) => { - let first = out.iter().zip(&bytes).position(|(a, b)| a != b).unwrap_or(out.len().min(bytes.len())); - eprintln!("DIFF {name}: first difference at {first} (len {} vs {})", out.len(), bytes.len()); - failures.push(format!("{name}: not byte stable (first diff at {first})")); - } - Err(e) => failures.push(format!("{name}: write: {e}")), - }, - } - } - eprintln!("corpus {}: {} files, {} failures", root.display(), files.len(), failures.len()); - assert!(failures.is_empty(), "corpus failures:\n{}", failures.join("\n")); -} diff --git a/crates/psd/tests/it/corpus_atn.rs b/crates/psd/tests/it/corpus_atn.rs deleted file mode 100644 index 32f6367714..0000000000 --- a/crates/psd/tests/it/corpus_atn.rs +++ /dev/null @@ -1,60 +0,0 @@ -//! Corpus test (feature `corpus`; run with `cargo xtask test-corpus`): every `.atn` under -//! `corpus/atn/` (gitignored, fetched by `cargo xtask corpus --atn`) parses, and writing it back -//! reproduces the bytes exactly. A missing corpus fails. -#![cfg(feature = "corpus")] - -use std::path::{Path, PathBuf}; - -use photocraft_psd::atn; - -fn collect(dir: &Path, out: &mut Vec) { - // An unreadable folder fails the test rather than shrinking the corpus. - let rd = std::fs::read_dir(dir).unwrap_or_else(|e| panic!("{}: {e}", dir.display())); - for e in rd { - let p = e.unwrap_or_else(|e| panic!("{}: {e}", dir.display())).path(); - if p.is_dir() { - collect(&p, out); - } else if p.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("atn")) { - out.push(p); - } - } -} - -/// Sets in the corpus; raise, never lower. -const FLOOR: usize = 16; - -#[test] -fn atn_corpus_parses_and_is_byte_stable() { - let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/atn"); - assert!(root.is_dir(), "{} is missing: run `cargo xtask corpus --atn`", root.display()); - let mut files = Vec::new(); - collect(&root, &mut files); - files.sort(); - let mut failures = Vec::new(); - let (mut actions, mut steps) = (0usize, 0usize); - for p in &files { - let name = p.strip_prefix(&root).unwrap_or(p).display().to_string(); - let bytes = std::fs::read(p).unwrap(); - let file = match atn::parse(&bytes) { - Ok(f) => f, - Err(e) => { - failures.push(format!("{name}: {e}")); - continue; - } - }; - if !file.warnings.is_empty() { - failures.push(format!("{name}: warnings {:?}", file.warnings)); - } - actions += file.actions.len(); - steps += file.actions.iter().map(|a| a.items.len()).sum::(); - match atn::write(&file) { - Ok(written) if written == bytes => {} - Ok(written) => failures.push(format!("{name}: rewritten bytes differ ({} vs {} bytes)", written.len(), bytes.len())), - Err(e) => failures.push(format!("{name}: write failed: {e}")), - } - println!("{name}: {} actions, {} steps", file.actions.len(), file.actions.iter().map(|a| a.items.len()).sum::()); - } - println!("{} files, {actions} actions, {steps} steps", files.len()); - assert!(files.len() >= FLOOR, "only {} .atn files; the corpus floor is {FLOOR}", files.len()); - assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.join("\n")); -} diff --git a/crates/psd/tests/it/layer_block_order.rs b/crates/psd/tests/it/layer_block_order.rs deleted file mode 100644 index fb2646c691..0000000000 --- a/crates/psd/tests/it/layer_block_order.rs +++ /dev/null @@ -1,26 +0,0 @@ -//! The global layer block retains its insertion position and neighboring opaque blocks. -use photocraft_psd::*; - -#[test] -fn global_layer_block_roundtrips_with_opaque_neighbors() { - for version in [Version::Psd, Version::Psb] { - for depth in [16, 32] { - for index in 0..=2 { - for signature in [*b"8BIM", *b"8B64"] { - let mut file = testgen::layered(version, ColorMode::Rgb, depth, Compression::Raw); - file.global_blocks = vec![TaggedBlock::new(*b"zz01", vec![7, 8, 9]), TaggedBlock::new(*b"zz02", vec![10, 11])]; - file.layer_info_placement = LayerInfoPlacement::GlobalBlock { - index, - signature, - key: if depth == 16 { *b"Lr16" } else { *b"Lr32" }, - padding: Some(vec![31, 32, 33]), - }; - let bytes = file.to_bytes().unwrap(); - let parsed = PsdFile::from_bytes(&bytes).unwrap(); - assert_eq!(parsed, file, "{version:?} {depth} {index} {signature:?}"); - assert_eq!(parsed.to_bytes().unwrap(), bytes); - } - } - } - } -} diff --git a/crates/psd/tests/it/main.rs b/crates/psd/tests/it/main.rs deleted file mode 100644 index fef8708849..0000000000 --- a/crates/psd/tests/it/main.rs +++ /dev/null @@ -1,15 +0,0 @@ -//! The crate's integration tests, linked into one test binary. - -mod animation; -mod atn_contract; -mod atn_v12; -mod builder; -mod corpus; -mod corpus_atn; -mod layer_block_order; -mod malformed; -mod merged_recovery; -mod proptests; -mod roundtrip; -mod swatch_malformed; -mod tree; diff --git a/crates/psd/tests/it/malformed.rs b/crates/psd/tests/it/malformed.rs deleted file mode 100644 index cd8a0d7dab..0000000000 --- a/crates/psd/tests/it/malformed.rs +++ /dev/null @@ -1,283 +0,0 @@ -//! Malformed input handling: every error path must return `Err`, never panic. - -use photocraft_psd::testgen; -use photocraft_psd::*; -use proptest::prelude::*; - -fn truncation_sweep(bytes: &[u8], name: &str) { - for cut in 0..bytes.len() { - let r = PsdFile::from_bytes(&bytes[..cut]); - assert!(r.is_err(), "{name}: truncation at {cut}/{} parsed successfully", bytes.len()); - } - assert!(PsdFile::from_bytes(bytes).is_ok(), "{name}: full file must parse"); -} - -macro_rules! trunc { - ($name:ident, $ver:expr, $comp:expr) => { - #[test] - fn $name() { - let b = testgen::small($ver, $comp).to_bytes().unwrap(); - truncation_sweep(&b, stringify!($name)); - } - }; -} - -trunc!(truncate_small_psd_raw, Version::Psd, Compression::Raw); -trunc!(truncate_small_psd_rle, Version::Psd, Compression::Rle); -trunc!(truncate_small_psd_zip, Version::Psd, Compression::Zip); -trunc!(truncate_small_psd_zipp, Version::Psd, Compression::ZipPrediction); -trunc!(truncate_small_psb_raw, Version::Psb, Compression::Raw); -trunc!(truncate_small_psb_rle, Version::Psb, Compression::Rle); -trunc!(truncate_small_psb_zip, Version::Psb, Compression::Zip); -trunc!(truncate_small_psb_zipp, Version::Psb, Compression::ZipPrediction); - -#[test] -fn truncate_merged_only_all_modes() { - for mode in testgen::MODES { - for &depth in testgen::mode_depths(mode) { - let b = testgen::merged_only(Version::Psd, mode, depth, Compression::Rle, 5, 3).to_bytes().unwrap(); - truncation_sweep(&b, &format!("{mode:?} {depth}")); - } - } -} - -#[test] -fn truncate_builder_16bit_lr16() { - let mut b = PsdBuilder::new(3, 2).depth(16); - b.push_layer(LayerSpec::new("x", 0, 0, 3, 2, PixelData::Rgba16(vec![1000; 24]))); - let bytes = b.to_bytes().unwrap(); - truncation_sweep(&bytes, "builder 16"); -} - -#[test] -fn empty_input() { - assert!(matches!(PsdFile::from_bytes(&[]), Err(PsdError::UnexpectedEof { .. }))); -} - -#[test] -fn bad_file_signature() { - let mut b = testgen::small(Version::Psd, Compression::Raw).to_bytes().unwrap(); - b[..4].copy_from_slice(b"8BPX"); - assert!(matches!(PsdFile::from_bytes(&b), Err(PsdError::InvalidSignature { .. }))); -} - -#[test] -fn bad_version() { - let mut b = testgen::small(Version::Psd, Compression::Raw).to_bytes().unwrap(); - for v in [0u16, 3, 0xffff] { - b[4..6].copy_from_slice(&v.to_be_bytes()); - assert_eq!(PsdFile::from_bytes(&b), Err(PsdError::UnsupportedVersion(v))); - } -} - -#[test] -fn bad_depth_and_channels() { - let good = testgen::small(Version::Psd, Compression::Raw).to_bytes().unwrap(); - let mut b = good.clone(); - b[22..24].copy_from_slice(&7u16.to_be_bytes()); - assert!(PsdFile::from_bytes(&b).is_err()); - let mut b = good.clone(); - b[12..14].copy_from_slice(&0u16.to_be_bytes()); - assert!(PsdFile::from_bytes(&b).is_err()); - let mut b = good; - b[12..14].copy_from_slice(&57u16.to_be_bytes()); - assert!(PsdFile::from_bytes(&b).is_err()); -} - -#[test] -fn oversized_dimensions_rejected() { - let mut b = testgen::small(Version::Psb, Compression::Raw).to_bytes().unwrap(); - b[14..18].copy_from_slice(&300_001u32.to_be_bytes()); - assert!(matches!(PsdFile::from_bytes(&b), Err(PsdError::LimitExceeded(_)))); -} - -#[test] -fn zero_dimensions_rejected() { - // Height is bytes 14..18 and width 18..22; the spec range starts at 1. - let good = testgen::small(Version::Psd, Compression::Raw).to_bytes().unwrap(); - for range in [14..18, 18..22, 14..22] { - let mut b = good.clone(); - b[range.clone()].fill(0); - let err = PsdFile::from_bytes(&b).unwrap_err(); - assert!(err.to_string().contains("at least 1x1"), "{range:?}: {err}"); - } - let mut f = testgen::small(Version::Psd, Compression::Raw); - f.header.width = 0; - assert!(f.validate().is_err(), "a zero-sized header must not pass the writer's validation"); -} - -#[test] -fn huge_declared_dimensions_small_data() { - // 300000² RLE image with 16 bytes of data must fail fast, not allocate. - let mut f = testgen::merged_only(Version::Psb, ColorMode::Grayscale, 8, Compression::Rle, 1, 1); - f.header.width = 300_000; - f.header.height = 300_000; - let b = f.to_bytes().unwrap(); - assert!(PsdFile::from_bytes(&b).is_err()); - // ZIP over `MAX_DECODED_BYTES` is not inflated at parse time (#2926); decoding refuses it. - f.image_data.compression = Compression::Zip; - f.image_data.data = compression::zip_compress(&[0; 100]); - let b = f.to_bytes().unwrap(); - assert!(matches!(PsdFile::from_bytes(&b).and_then(|f| f.decode_merged()), Err(PsdError::LimitExceeded(_)))); -} - -#[test] -fn bad_resource_signature() { - let f = testgen::small(Version::Psd, Compression::Raw); - let mut b = f.to_bytes().unwrap(); - // header 26 + color mode len 4 + resources len 4 → first resource sig. - b[34..38].copy_from_slice(b"XXXX"); - assert!(matches!(PsdFile::from_bytes(&b), Err(PsdError::InvalidSignature { .. }))); -} - -#[test] -fn bad_layer_blend_signature() { - let f = testgen::small(Version::Psd, Compression::Raw); - let b = f.to_bytes().unwrap(); - let pos = b.windows(8).position(|w| w == b"8BIMnorm").expect("blend sig"); - let mut b2 = b.clone(); - b2[pos..pos + 4].copy_from_slice(b"XBIM"); - assert!(PsdFile::from_bytes(&b2).is_err()); -} - -#[test] -fn oversized_section_lengths() { - let f = testgen::small(Version::Psd, Compression::Raw); - let b = f.to_bytes().unwrap(); - // Color mode data length. - let mut b2 = b.clone(); - b2[26..30].copy_from_slice(&u32::MAX.to_be_bytes()); - assert!(PsdFile::from_bytes(&b2).is_err()); - // Resource section length. - let mut b3 = b.clone(); - b3[30..34].copy_from_slice(&u32::MAX.to_be_bytes()); - assert!(PsdFile::from_bytes(&b3).is_err()); -} - -#[test] -fn corrupt_zip_merged_rejected() { - let mut f = testgen::small(Version::Psd, Compression::Zip); - let n = f.image_data.data.len(); - f.image_data.data[n / 2] ^= 0xff; - f.image_data.data[n / 2 + 1] ^= 0x55; - let b = f.to_bytes().unwrap(); - assert!(PsdFile::from_bytes(&b).is_err()); -} - -#[test] -fn corrupt_layer_channel_is_lazy_error() { - let mut f = testgen::small(Version::Psd, Compression::Rle); - let ch = &mut f.layers_mut()[0].channels[1]; - let id = ch.id; - for b in ch.data.iter_mut() { - *b = 0x7f; - } - let bytes = f.to_bytes().unwrap(); - let p = PsdFile::from_bytes(&bytes).expect("lazy: parse succeeds"); - assert!(p.layers()[0].decode_channel(id, 8, Version::Psd).is_err()); - assert!(p.layer(0).unwrap().rgba8().is_err()); -} - -#[test] -fn negative_layer_rect_decode_errors() { - let mut f = testgen::small(Version::Psd, Compression::Raw); - f.layers_mut()[0].rect = Rect { top: 10, left: 0, bottom: 0, right: 4 }; - let b = f.to_bytes().unwrap(); - let p = PsdFile::from_bytes(&b).unwrap(); - assert!(p.layers()[0].decode_channel(0, 8, Version::Psd).is_err()); - assert!(p.layer(0).unwrap().rgba8().is_err()); -} - -#[test] -fn too_many_layer_channels() { - let f = testgen::small(Version::Psd, Compression::Raw); - let b = f.to_bytes().unwrap(); - let li = b.windows(4).position(|w| w == [0, 0xff, 0xff, 0xff]).is_some(); - let _ = li; - // Construct directly: layer count 1, rect, channel count 0xffff. - let mut f2 = testgen::merged_only(Version::Psd, ColorMode::Rgb, 8, Compression::Raw, 1, 1); - f2.layer_info = Some(LayerInfo::default()); - let mut b2 = f2.to_bytes().unwrap(); - // Find layer info: after resources. Locate the 2-byte count (0) and - // replace with a malformed record. - let res_len = u32::from_be_bytes(b2[30..34].try_into().unwrap()) as usize; - let lm = 34 + res_len; - let mut rec = vec![0, 1]; - rec.extend_from_slice(&[0; 16]); - rec.extend_from_slice(&0xffffu16.to_be_bytes()); - let li_len = rec.len() as u32; - let mut section = li_len.to_be_bytes().to_vec(); - section.extend_from_slice(&rec); - let mut out = b2[..lm].to_vec(); - out.extend_from_slice(&(section.len() as u32).to_be_bytes()); - out.extend_from_slice(§ion); - let old_lm_len = u32::from_be_bytes(b2[lm..lm + 4].try_into().unwrap()) as usize; - out.extend_from_slice(&b2[lm + 4 + old_lm_len..]); - b2 = out; - assert!(matches!(PsdFile::from_bytes(&b2), Err(PsdError::LimitExceeded(_)) | Err(PsdError::UnexpectedEof { .. }))); -} - -proptest! { - #![proptest_config(ProptestConfig::with_cases(256))] - - #[test] - fn random_bytes_never_panic(data in proptest::collection::vec(any::(), 0..512)) { - let _ = PsdFile::from_bytes(&data); - } - - #[test] - fn random_bytes_with_valid_header_never_panic(tail in proptest::collection::vec(any::(), 0..512)) { - let mut data = testgen::small(Version::Psd, Compression::Raw).to_bytes().unwrap()[..26].to_vec(); - data.extend(tail); - let _ = PsdFile::from_bytes(&data); - } - - #[test] - fn mutations_never_panic( - idx in proptest::collection::vec(any::(), 1..8), - vals in proptest::collection::vec(any::(), 8), - psb in any::(), - comp in 0usize..4, - ) { - let v = if psb { Version::Psb } else { Version::Psd }; - let mut b = testgen::small(v, Compression::ALL[comp]).to_bytes().unwrap(); - for (i, ix) in idx.iter().enumerate() { - let at = ix.index(b.len()); - b[at] = vals[i]; - } - if let Ok(f) = PsdFile::from_bytes(&b) { - // Anything that parses must also be writable and decodable - // without panicking. - let _ = f.to_bytes(); - let _ = f.decode_merged(); - let _ = f.composite_rgba8(); - let _ = f.layer_tree(); - for l in f.iter_layers() { - let _ = l.rgba8(); - let _ = l.user_mask(); - let _ = l.name(); - for bl in &l.record.blocks { - let _ = bl.parsed(); - } - } - for r in &f.resources { - let _ = r.parsed(); - } - } - } - - #[test] - fn parsed_mutants_are_byte_stable( - idx in any::(), - val in any::(), - ) { - let mut b = testgen::small(Version::Psd, Compression::Rle).to_bytes().unwrap(); - let at = idx.index(b.len()); - b[at] = val; - if let Ok(f) = PsdFile::from_bytes(&b) { - let out = f.to_bytes().unwrap(); - let g = PsdFile::from_bytes(&out).unwrap(); - prop_assert_eq!(g, f); - } - } -} diff --git a/crates/psd/tests/it/merged_recovery.rs b/crates/psd/tests/it/merged_recovery.rs deleted file mode 100644 index 40e310d8a4..0000000000 --- a/crates/psd/tests/it/merged_recovery.rs +++ /dev/null @@ -1,43 +0,0 @@ -//! Opt-in merged-image recovery must not weaken strict parsing or lose encoded bytes. - -use photocraft_psd::{Compression, PsdError, PsdFile, Version, compression, testgen}; - -#[test] -fn recovery_is_explicit_and_preserves_the_original_file() { - for version in [Version::Psd, Version::Psb] { - for compression in Compression::ALL { - let mut original = testgen::small(version, compression); - original.image_data.data.pop(); - let bytes = original.to_bytes().unwrap(); - let strict_error = PsdFile::from_bytes(&bytes).unwrap_err(); - for want in [false, true] { - let rejected = PsdFile::from_bytes_and_merged_recovering(&bytes, |_| want, |_| false).unwrap_err(); - assert_eq!(rejected, strict_error); - let (file, merged) = PsdFile::from_bytes_and_merged_recovering(&bytes, |_| want, |_| true).unwrap(); - assert_eq!(merged.unwrap().unwrap_err(), strict_error); - assert_eq!(file, original); - assert_eq!(file.to_bytes().unwrap(), bytes, "{version:?} {compression:?}"); - } - } - } -} - -#[test] -fn recovery_does_not_accept_missing_compression_or_incomplete_layers() { - let original = testgen::small(Version::Psd, Compression::Raw); - let bytes = original.to_bytes().unwrap(); - let merged_offset = bytes.len() - original.image_data.data.len() - 2; - for cut in [merged_offset - 1, merged_offset, merged_offset + 1] { - assert!(PsdFile::from_bytes_and_merged_recovering(&bytes[..cut], |_| true, |_| true).is_err(), "cut {cut}"); - } -} - -#[test] -fn recovery_never_suppresses_a_merged_stream_limit_error() { - let mut original = testgen::small(Version::Psd, Compression::Zip); - original.image_data.data = compression::zip_compress(&vec![0; (1 << 20) + 4096]); - let bytes = original.to_bytes().unwrap(); - for want in [false, true] { - assert!(matches!(PsdFile::from_bytes_and_merged_recovering(&bytes, |_| want, |_| true), Err(PsdError::LimitExceeded(_)))); - } -} diff --git a/crates/psd/tests/it/proptests.rs b/crates/psd/tests/it/proptests.rs deleted file mode 100644 index 23d6bcd01b..0000000000 --- a/crates/psd/tests/it/proptests.rs +++ /dev/null @@ -1,549 +0,0 @@ -//! Property-based round-trip tests. - -use photocraft_psd::compression::{PlaneLayout, decode_planes, encode_planes, packbits}; -use photocraft_psd::testgen; -use photocraft_psd::*; -use proptest::prelude::*; - -fn version() -> impl Strategy { - prop_oneof![Just(Version::Psd), Just(Version::Psb)] -} -fn compression() -> impl Strategy { - prop_oneof![Just(Compression::Raw), Just(Compression::Rle), Just(Compression::Zip), Just(Compression::ZipPrediction)] -} -fn depth() -> impl Strategy { - prop_oneof![Just(1u16), Just(8), Just(16), Just(32)] -} - -proptest! { - #![proptest_config(ProptestConfig::with_cases(128))] - - #[test] - fn packbits_roundtrip(data in proptest::collection::vec(any::(), 0..600)) { - let enc = packbits::encode_vec(&data); - prop_assert_eq!(packbits::decode(&enc, data.len()).unwrap(), data.clone()); - // Worst case expansion is 1 header byte per 128 literal bytes. - prop_assert!(enc.len() <= data.len() + data.len().div_ceil(128)); - } - - #[test] - fn packbits_runs_roundtrip(runs in proptest::collection::vec((any::(), 1usize..300), 0..20)) { - let data: Vec = runs.iter().flat_map(|&(b, n)| std::iter::repeat_n(b, n)).collect(); - let enc = packbits::encode_vec(&data); - prop_assert_eq!(packbits::decode(&enc, data.len()).unwrap(), data); - } - - #[test] - fn packbits_decode_garbage_never_panics(data in proptest::collection::vec(any::(), 0..64), n in 0usize..512) { - let _ = packbits::decode(&data, n); - } - - #[test] - fn channel_codecs_roundtrip( - w in 0usize..40, h in 0usize..12, planes in 1usize..4, - d in depth(), v in version(), c in compression(), seed in any::(), - ) { - let l = PlaneLayout { planes, width: w, height: h, depth: d, version: v }; - let n = l.decoded_len().unwrap(); - let mut x = seed; - let data: Vec = (0..n).map(|_| { x = x.wrapping_mul(6364136223846793005).wrapping_add(1); if x >> 62 == 0 { 0 } else { (x >> 33) as u8 } }).collect(); - let enc = encode_planes(c, &data, &l).unwrap(); - prop_assert_eq!(decode_planes(c, &enc, &l).unwrap(), data); - } - - #[test] - fn channel_decode_garbage_never_panics( - data in proptest::collection::vec(any::(), 0..128), - w in 0usize..20, h in 0usize..8, d in depth(), v in version(), c in 0u16..5, - ) { - let l = PlaneLayout { planes: 1, width: w, height: h, depth: d, version: v }; - let _ = decode_planes(Compression::from_u16(c), &data, &l); - } - - #[test] - fn generated_small_files_roundtrip(v in version(), c in compression()) { - let f = testgen::small(v, c); - let b = f.to_bytes().unwrap(); - let p = PsdFile::from_bytes(&b).unwrap(); - prop_assert_eq!(&p, &f); - prop_assert_eq!(p.to_bytes().unwrap(), b); - } - - #[test] - fn merged_only_random_sizes_roundtrip( - w in 1u32..30, h in 1u32..10, v in version(), c in compression(), mode_ix in 0usize..8, depth_ix in 0usize..3, - ) { - let mode = testgen::MODES[mode_ix]; - let depths = testgen::mode_depths(mode); - let d = depths[depth_ix % depths.len()]; - let f = testgen::merged_only(v, mode, d, c, w, h); - let b = f.to_bytes().unwrap(); - let p = PsdFile::from_bytes(&b).unwrap(); - prop_assert_eq!(&p, &f); - prop_assert_eq!(p.to_bytes().unwrap(), b); - } - - #[test] - fn builder_random_layers_roundtrip( - layers in proptest::collection::vec( - (-20i32..20, -20i32..20, 0u32..9, 0u32..9, any::(), 0usize..28, any::(), "[a-zA-Z0-9 äöü😀]{0,12}"), - 0..6, - ), - v in version(), c in compression(), sixteen in any::(), - ) { - let depth = if sixteen { 16 } else { 8 }; - let mut b = PsdBuilder::new(16, 16).version(v).compression(c).depth(depth); - let mut expected = Vec::new(); - for (i, (x, y, w, h, op, bm, vis, name)) in layers.iter().enumerate() { - let n = (*w * *h) as usize; - let px8: Vec = (0..n * 4).map(|k| (k * 31 + i * 7) as u8).collect(); - let pixels = if sixteen { - PixelData::Rgba16(px8.iter().map(|&p| u16::from(p) * 257).collect()) - } else { - PixelData::Rgba8(px8.clone()) - }; - let mut s = LayerSpec::new(name.clone(), *x, *y, *w, *h, pixels); - s.opacity = *op; - s.blend_mode = BlendMode::ALL[*bm]; - s.visible = *vis; - b.push_layer(s); - expected.push((name.clone(), px8, *op, BlendMode::ALL[*bm], *vis)); - } - let bytes = b.to_bytes().unwrap(); - let f = PsdFile::from_bytes(&bytes).unwrap(); - prop_assert_eq!(f.to_bytes().unwrap(), bytes); - prop_assert_eq!(f.layers().len(), expected.len()); - for (l, (name, px, op, bm, vis)) in f.iter_layers().zip(expected) { - prop_assert_eq!(l.name(), name); - prop_assert_eq!(l.rgba8().unwrap().data, px); - prop_assert_eq!(l.record.opacity, op); - prop_assert_eq!(l.record.blend_mode, bm); - prop_assert_eq!(l.record.is_visible(), vis); - } - } - - #[test] - fn tagged_blocks_roundtrip( - blocks in proptest::collection::vec( - (any::<[u8; 4]>(), proptest::collection::vec(any::(), 0..40), any::(), 0usize..4), - 0..8, - ), - v in version(), - ) { - let mut f = testgen::small(v, Compression::Raw); - for (key, data, b64, pad) in blocks { - let mut tb = TaggedBlock::new(key, data); - if b64 { tb.signature = *b"8B64"; } - // Only padding patterns the detector can recover are modeled: - // zeros up to 3 bytes. - tb.padding = match pad { 0 => None, n => Some(vec![0; n - 1]) }; - if tb.padding.as_ref().is_some_and(|p| p.len() == (4 - tb.data.len() % 4) % 4) { - tb.padding = None; - } - f.global_blocks.push(tb); - } - let b = f.to_bytes().unwrap(); - let p = PsdFile::from_bytes(&b).unwrap(); - prop_assert_eq!(&p, &f); - prop_assert_eq!(p.to_bytes().unwrap(), b); - } -} - -// The three standalone swatch/gradient parsers sit outside `PsdFile::from_bytes`, so their -// round-trip invariants and no-panic guarantee get their own properties here. `write` covers only -// what each format can express, so each property states the invariant that actually holds instead -// of claiming byte equality everywhere. - -/// A position or midpoint the writer can actually store: the file keeps 1/4096ths, so generating -/// arbitrary `0.0..=1.0` values would make every property fail on quantisation, not on a bug. -fn unit() -> impl Strategy { - (0.0f32..=1.0).prop_map(|v| (v * 4096.0).round() / 4096.0) -} - -proptest! { - #![proptest_config(ProptestConfig::with_cases(128))] - - #[test] - fn grd_roundtrip_keeps_stops_in_range( - stops in proptest::collection::vec((unit(), unit(), any::()), 0..12), - opacity in proptest::collection::vec((unit(), unit(), unit()), 0..12), - ) { - use photocraft_psd::grd::{self, GrdColor, GrdGradient, GrdStop}; - let g = GrdGradient { - name: "p".into(), - noise: false, - stops: stops.iter().map(|&(l, m, tone)| GrdStop { - location: l, - midpoint: m, - color: GrdColor::Rgb([tone as f32 / 255.0, 0.0, 0.0]), - }).collect(), - opacity: opacity.clone(), - }; - let bytes = grd::write(&[g]); - let parsed = grd::parse(&bytes).unwrap(); - prop_assert_eq!(parsed.len(), 1); - let back = &parsed[0]; - prop_assert_eq!(back.stops.len(), stops.len()); - prop_assert_eq!(back.opacity.len(), opacity.len()); - for (a, b) in back.stops.iter().zip(&stops) { - prop_assert!(a.location.is_finite() && (0.0..=1.0).contains(&a.location)); - prop_assert!((a.location - b.0).abs() < 1.0 / 4096.0); - prop_assert!(a.midpoint.is_finite() && (0.0..=1.0).contains(&a.midpoint)); - prop_assert!((a.midpoint - b.1).abs() < 0.01); - // The colour was written, so it must come back: a parser that zeroed every colour - // would pass a property that only looked at positions. - let GrdColor::Rgb([r, g, bl]) = a.color else { - prop_assert!(false, "colour must survive the round trip"); - return Ok(()); - }; - prop_assert!((r - b.2 as f32 / 255.0).abs() < 1.0 / 255.0, "red channel {r} lost"); - prop_assert!(g.is_finite() && g.abs() < 1.0 / 255.0, "green channel {g} corrupted"); - prop_assert!(bl.is_finite() && bl.abs() < 1.0 / 255.0, "blue channel {bl} corrupted"); - } - for (a, b) in back.opacity.iter().zip(&opacity) { - prop_assert!(a.0.is_finite() && (0.0..=1.0).contains(&a.0)); - prop_assert!((0.0..=1.0).contains(&a.1)); - prop_assert!(a.2.is_finite() && (0.0..=1.0).contains(&a.2)); - prop_assert!((a.0 - b.0).abs() < 1.0 / 4096.0); - prop_assert!((a.1 - b.1).abs() < 0.01); - prop_assert!((a.2 - b.2).abs() < 0.01, "transparency midpoint not preserved"); - } - } - - #[test] - fn ase_roundtrip_is_byte_stable_after_one_write( - swatches in proptest::collection::vec(("[ -~]{0,20}", any::()), 0..10), - ) { - use photocraft_psd::ase::{self, AseColor, AseEntry, AseFile, AseKind, AseSwatch}; - let mk = |(name, tone): &(String, u8)| AseSwatch { - name: name.clone(), - color: AseColor::Rgb([*tone as f32 / 255.0, 0.0, 0.0]), - kind: AseKind::Process, - }; - let file = AseFile { - entries: vec![ - AseEntry::Group { name: "g".into(), swatches: swatches.iter().take(3).map(mk).collect() }, - AseEntry::Group { name: "h".into(), swatches: swatches.iter().skip(3).map(mk).collect() }, - ], - }; - let bytes = ase::write(&file).unwrap(); - let back = ase::parse(&bytes).unwrap(); - prop_assert_eq!(back.swatch_count(), swatches.len()); - prop_assert_eq!(ase::write(&back).unwrap(), bytes, "a second round trip is byte-stable"); - } - - #[test] - fn aco_roundtrip_is_byte_stable( - swatches in proptest::collection::vec(("[ -~]{0,20}", any::(), any::()), 0..10), - ) { - use photocraft_psd::aco::{self, AcoColor, AcoSwatch}; - let s: Vec = swatches.iter().map(|(name, space, v)| AcoSwatch { - name: name.clone(), - color: match space % 3 { - 0 => AcoColor::Rgb([*v, 0, 0]), - 1 => AcoColor::Gray(*v), - _ => AcoColor::Lab { l: *v, a: -12800, b: 12700 }, - }, - }).collect(); - let bytes = aco::write(&s).unwrap(); - let back = aco::parse(&bytes).unwrap(); - prop_assert_eq!(&back.swatches, &s); - prop_assert_eq!(aco::write(&back.swatches).unwrap(), bytes, "a second round trip is byte-stable"); - } - - /// A name longer than the cap is truncated on the way in, so the file stays writable and the - /// bytes settle after one round trip. - #[test] - fn aco_long_names_settle_after_one_truncation(len in 1024usize..4000) { - use photocraft_psd::aco::{self, AcoColor, AcoSwatch}; - let s = vec![AcoSwatch { name: "x".repeat(len), color: AcoColor::Gray(5000) }]; - let once = aco::write(&s).unwrap(); - let back = aco::parse(&once).unwrap(); - prop_assert_eq!(back.swatches[0].name.encode_utf16().count(), aco::MAX_NAME_UNITS); - prop_assert_eq!(aco::write(&back.swatches).unwrap(), once); - } - - /// Pure junk rarely gets past the signature check, so most of each parser is never reached. - /// These damage files that are already valid instead, which is where the interesting inputs - /// live: well-formed structure with one byte corrupted. - #[test] - fn swatch_parsers_never_panic_on_damaged_valid_files( - idx in 0usize..4000, - bit in 0u32..8, - mask in 0u8..=255, - ) { - use photocraft_psd::{aco, ase, grd}; - let grd_bytes = { - use photocraft_psd::grd::{GrdColor, GrdGradient, GrdStop, write}; - write(&[GrdGradient { - name: "d".into(), - noise: false, - stops: vec![GrdStop { location: 0.25, midpoint: 0.5, color: GrdColor::Rgb([1.0, 0.5, 0.0]) }], - opacity: vec![(0.5, 1.0, 0.5)], - }]) - }; - let ase_bytes = { - use photocraft_psd::ase::{write, AseColor, AseEntry, AseFile, AseKind, AseSwatch}; - let sw = || AseSwatch { name: "d".into(), color: AseColor::Rgb([1.0, 0.0, 0.0]), kind: AseKind::Process }; - write(&AseFile { - entries: vec![AseEntry::Group { name: "g".into(), swatches: vec![sw()] }, AseEntry::Swatch(sw())], - }) - .unwrap() - }; - let aco_bytes = aco::write(&[aco::AcoSwatch { name: "d".into(), color: aco::AcoColor::Rgb([1, 2, 3]) }]).unwrap(); - - let damage = |mut b: Vec| -> Vec { - if !b.is_empty() { - let i = idx % b.len(); - b[i] ^= 1 << bit; - b[i] = b[i].wrapping_add(mask); - } - b - }; - let _ = grd::parse(&damage(grd_bytes)); - let _ = ase::parse(&damage(ase_bytes)); - let _ = aco::parse(&damage(aco_bytes)); - } - - /// Names that are not ASCII, so the name-unit cap can land between the two halves of a - /// surrogate pair. The cap counts UTF-16 code units, so that split is the boundary case. - #[test] - fn aco_non_ascii_names_are_capped_and_still_round_trip(len in 600usize..2600) { - use photocraft_psd::aco::{self, AcoColor, AcoSwatch}; - // One leading ASCII character, then two-unit pairs. Without it the cap would always - // fall on a pair boundary and the half-written case this property exists for would - // never happen. - let name = format!("a{}", "\u{1F600}".repeat(len / 2 + 1)); - let s = vec![AcoSwatch { name: name.clone(), color: AcoColor::Gray(5000) }]; - let once = aco::write(&s).unwrap(); - let back = aco::parse(&once).unwrap(); - let got = back.swatches[0].name.encode_utf16().count(); - prop_assert!(got <= aco::MAX_NAME_UNITS, "name came back as {got} units"); - // The cap cuts on a character, so the result is a prefix of the input: whole characters - // only, and never a lone surrogate. - prop_assert!(name.starts_with(&back.swatches[0].name), "the capped name must be a prefix of the input"); - prop_assert_eq!(aco::write(&back.swatches).unwrap(), once); - } - - /// Arbitrary bytes must never panic in any of the three parsers, whatever the length. - #[test] - fn swatch_parsers_never_panic_on_arbitrary_bytes(data in proptest::collection::vec(any::(), 0..400)) { - let _ = photocraft_psd::grd::parse(&data); - let _ = photocraft_psd::ase::parse(&data); - let _ = photocraft_psd::aco::parse(&data); - } -} - -// The five auxiliary binary parsers (image resources, metadata setting, patterns, filter-effects -// caches and the per-block tagged-block views) parse structures that sit outside the whole-file -// `PsdFile::from_bytes` path when read on their own. Their round-trip invariants and no-panic -// guarantee get their own properties here, so normal CI covers them: the matching fuzz targets run -// only on a manual trigger. - -/// A small, valid pattern at a byte-aligned depth, generated the way the unit `pat()` helper builds -/// one: `mode_channels(mode)` colour planes of `width × height × bytes-per-sample`, optional alpha. -fn pattern_strategy() -> impl Strategy { - (prop_oneof![Just(1u32), Just(3u32), Just(4u32)], 1u32..8, 1u32..6, prop_oneof![Just(8u16), Just(16), Just(32)], any::(), any::()).prop_map( - |(mode, width, height, depth, alpha, seed)| { - let bpp = usize::from(depth / 8); - let plane = |s: u64| (0..width as usize * height as usize * bpp).map(|i| (i as u64).wrapping_mul(31).wrapping_add(s) as u8).collect::>(); - let nc = patterns::mode_channels(mode); - patterns::PsdPattern { - mode, - width, - height, - name: "p".into(), - id: "0bd2d3ba-1234-11d4-8f8f-aabbccddeeff".into(), - palette: None, - depth, - channels: (0..nc).map(|c| plane(seed.wrapping_add(c as u64 + 1))).collect(), - alpha: alpha.then(|| plane(seed ^ 0xffff)), - } - }, - ) -} - -proptest! { - #![proptest_config(ProptestConfig::with_cases(128))] - - /// `shmd` is exact: Photoshop keeps each item's even padding inside its stored length, so a - /// built block writes, parses and re-writes byte for byte. - #[test] - fn metadata_shmd_round_trip_is_exact( - items in proptest::collection::vec((any::<[u8; 4]>(), proptest::collection::vec(any::(), 0..40)), 0..8), - ) { - use photocraft_psd::metadata::{MetadataItem, parse_shmd, write_shmd}; - let items: Vec = items.into_iter().map(|(k, d)| MetadataItem::new(k, d)).collect(); - let bytes = write_shmd(&items); - let back = parse_shmd(&bytes).unwrap(); - prop_assert_eq!(&back, &items); - prop_assert_eq!(write_shmd(&back), bytes); - } - - #[test] - fn metadata_shmd_never_panics(data in proptest::collection::vec(any::(), 0..400)) { - let _ = photocraft_psd::metadata::parse_shmd(&data); - } - - /// A built pattern round-trips through both the `Patt`/`Pat2`/`Pat3` block and the `.pat` file, - /// and re-serialising a parsed block is byte-stable. - #[test] - fn pattern_blocks_round_trip(ps in proptest::collection::vec(pattern_strategy(), 0..4)) { - use photocraft_psd::patterns::{parse_pat_file, parse_pattern_block, write_pat_file, write_pattern_block}; - let blk = write_pattern_block(&ps).unwrap(); - prop_assert_eq!(parse_pattern_block(&blk).unwrap(), ps.clone()); - prop_assert_eq!(write_pattern_block(&parse_pattern_block(&blk).unwrap()).unwrap(), blk); - let f = write_pat_file(&ps).unwrap(); - prop_assert_eq!(parse_pat_file(&f).unwrap(), ps); - } - - #[test] - fn pattern_parsers_never_panic(data in proptest::collection::vec(any::(), 0..400)) { - let _ = photocraft_psd::patterns::parse_pattern_block(&data); - let _ = photocraft_psd::patterns::parse_pat_file(&data); - } - - /// A filter-effects cache with one or more items (each with cached slots and an optional mask) - /// re-serialises and re-parses byte for byte, and the cached planes decode. - #[test] - fn filter_effects_round_trip(items in proptest::collection::vec(("[ -~]{0,8}", any::()), 0..4)) { - use photocraft_psd::compression::Compression; - use photocraft_psd::filter_effects::{EffectsPlane, FilterEffects, FilterEffectsItem}; - use photocraft_psd::layer::Rect; - let rect = Rect { top: 1, left: 2, bottom: 5, right: 6 }; - let plane = |v: u8| EffectsPlane::encode(Compression::Rle, &[v; 16], 4, 4, 8).unwrap(); - let items: Vec = items - .iter() - .map(|(id, mask)| { - let mut slots = vec![None; 26]; - slots[0] = Some(plane(10)); - slots[25] = Some(plane(255)); - FilterEffectsItem { id: id.clone(), version: 1, rect, depth: 8, max_channels: 24, slots, mask: mask.then(|| (rect, plane(128))) } - }) - .collect(); - let fx = FilterEffects { version: 3, items }; - let bytes = fx.to_bytes(); - let back = FilterEffects::parse(&bytes).unwrap(); - prop_assert_eq!(&back, &fx); - prop_assert_eq!(back.to_bytes(), bytes); - for it in &back.items { - if let Some((_, m)) = it.decoded_mask() { - prop_assert_eq!(m, vec![128; 16]); - } - } - } - - #[test] - fn filter_effects_never_panics(data in proptest::collection::vec(any::(), 0..400)) { - if let Ok(fx) = photocraft_psd::filter_effects::FilterEffects::parse(&data) { - for it in &fx.items { - let _ = it.decoded_mask(); - } - let _ = fx.to_bytes(); - } - } - - /// Resolution info is 16 fixed-point bytes; a built value survives a byte round trip. - #[test] - fn resolution_info_round_trips(dpi in 1.0f64..=10_000.0) { - use photocraft_psd::resources::ResolutionInfo; - let ri = ResolutionInfo::from_dpi(dpi); - prop_assert_eq!(ResolutionInfo::from_bytes(&ri.to_bytes()).unwrap(), ri); - } - - /// The typed view of an arbitrary resource id over arbitrary data never panics. - #[test] - fn resource_parsed_never_panics(id in any::(), data in proptest::collection::vec(any::(), 0..64)) { - use photocraft_psd::resources::ImageResource; - let r = ImageResource::new(id, data); - let _ = r.parsed(); - let _ = r.xmp_str(); - let _ = photocraft_psd::resources::VersionInfo::from_bytes(&r.data); - } - - /// `parsed` (typed) and `check_structure` (strict inner re-parse) over an arbitrary key and - /// arbitrary data never panic, only ever returning `Ok` / `Err` / `None`. - #[test] - fn tagged_block_views_never_panic(key in any::<[u8; 4]>(), data in proptest::collection::vec(any::(), 0..200)) { - let b = TaggedBlock::new(key, data); - let _ = b.parsed(); - let _ = b.check_structure(); - } - - /// Each well-known typed key over arbitrary data: every typed branch is reached and none panics. - #[test] - fn tagged_known_keys_never_panic(ki in 0usize..13, data in proptest::collection::vec(any::(), 0..64)) { - const KEYS: [&[u8; 4]; 13] = - [b"luni", b"lsct", b"lsdk", b"lyid", b"lnsr", b"clbl", b"infx", b"knko", b"tsly", b"lmgm", b"vmgm", b"lspf", b"lclr"]; - let b = TaggedBlock::new(*KEYS[ki], data); - let _ = b.parsed(); - } -} - -proptest! { - #![proptest_config(ProptestConfig::with_cases(256))] - - /// Pure random bytes rarely get past a header, so this damages a *valid* block one byte at a - /// time -- well-formed structure with one byte corrupted, where the interesting inputs live -- - /// across the metadata, pattern and filter-effects parsers. None may panic. - #[test] - fn aux_parsers_never_panic_on_damaged_valid_blocks(idx in 0usize..4000, bit in 0u32..8, add in 0u8..=255) { - use photocraft_psd::compression::Compression; - use photocraft_psd::filter_effects::{EffectsPlane, FilterEffects, FilterEffectsItem}; - use photocraft_psd::layer::Rect; - use photocraft_psd::metadata::{self, MetadataItem}; - use photocraft_psd::patterns::{self, PsdPattern}; - - let shmd = metadata::write_shmd(&[MetadataItem::new(*b"cmls", vec![1, 2, 3, 4]), MetadataItem::new(*b"cust", vec![9; 6])]); - let pattern = PsdPattern { - mode: 3, - width: 4, - height: 3, - name: "d".into(), - id: "0bd2d3ba".into(), - palette: None, - depth: 8, - channels: vec![vec![7u8; 12], vec![8u8; 12], vec![9u8; 12]], - alpha: None, - }; - let pat_block = patterns::write_pattern_block(std::slice::from_ref(&pattern)).unwrap(); - let pat_file = patterns::write_pat_file(std::slice::from_ref(&pattern)).unwrap(); - let rect = Rect { top: 1, left: 2, bottom: 5, right: 6 }; - let plane = EffectsPlane::encode(Compression::Rle, &[10u8; 16], 4, 4, 8).unwrap(); - let mut slots = vec![None; 26]; - slots[0] = Some(plane.clone()); - slots[25] = Some(plane.clone()); - let fx = FilterEffects { - version: 3, - items: vec![FilterEffectsItem { id: "x".into(), version: 1, rect, depth: 8, max_channels: 24, slots, mask: Some((rect, plane)) }], - } - .to_bytes(); - - let damage = |mut b: Vec| -> Vec { - if !b.is_empty() { - let i = idx % b.len(); - b[i] ^= 1u8 << bit; - b[i] = b[i].wrapping_add(add); - } - b - }; - let _ = metadata::parse_shmd(&damage(shmd)); - let _ = patterns::parse_pattern_block(&damage(pat_block)); - let _ = patterns::parse_pat_file(&damage(pat_file)); - if let Ok(f) = FilterEffects::parse(&damage(fx)) { - for it in &f.items { - let _ = it.decoded_mask(); - } - let _ = f.to_bytes(); - } - } - - /// Every `check_structure` key over arbitrary data: the strict inner re-parse for placed layers, - /// linked files, object effects and filter caches is entered and never panics. - #[test] - fn tagged_structural_keys_never_panic(ki in 0usize..8, data in proptest::collection::vec(any::(), 0..200)) { - const KEYS: [&[u8; 4]; 8] = [b"PlLd", b"SoLd", b"SoLE", b"lnk2", b"lnk3", b"lnkD", b"lfx2", b"FEid"]; - let b = TaggedBlock::new(*KEYS[ki], data); - let _ = b.check_structure(); - } -} diff --git a/crates/psd/tests/it/roundtrip.rs b/crates/psd/tests/it/roundtrip.rs deleted file mode 100644 index 1823900372..0000000000 --- a/crates/psd/tests/it/roundtrip.rs +++ /dev/null @@ -1,333 +0,0 @@ -//! Round-trip and byte-stability tests over generated files. - -use photocraft_psd::testgen::{self, MODES, mode_channels, mode_depths, pattern_plane}; -use photocraft_psd::*; - -fn assert_stable(file: &PsdFile, name: &str) { - let bytes = file.to_bytes().unwrap_or_else(|e| panic!("{name}: write failed: {e}")); - let parsed = PsdFile::from_bytes(&bytes).unwrap_or_else(|e| panic!("{name}: parse failed: {e}")); - assert_eq!(&parsed, file, "{name}: model round trip"); - let again = parsed.to_bytes().unwrap(); - assert_eq!(again, bytes, "{name}: byte stability"); -} - -#[test] -fn all_generated_cases_roundtrip() { - let cases = testgen::all_cases(); - assert!(cases.len() > 100, "only {} cases", cases.len()); - for c in &cases { - assert_stable(&c.file, &c.name); - } -} - -#[test] -fn all_generated_channels_decode() { - for c in testgen::all_cases() { - let f = &c.file; - let merged = f.decode_merged().unwrap_or_else(|e| panic!("{}: {e}", c.name)); - let (w, h) = (f.header.width as usize, f.header.height as usize); - assert_eq!(merged.len(), f.header.row_bytes() * h * usize::from(f.header.channels)); - for (i, layer) in f.layers().iter().enumerate() { - for ch in &layer.channels { - layer.decode_channel(ch.id, f.header.depth, f.header.version).unwrap_or_else(|e| panic!("{} layer {i} ch {}: {e}", c.name, ch.id)); - } - } - let _ = (w, h); - } -} - -#[test] -fn merged_only_decodes_to_pattern() { - for mode in MODES { - for &depth in mode_depths(mode) { - for c in Compression::ALL { - let f = testgen::merged_only(Version::Psd, mode, depth, c, 9, 5); - let dec = f.decode_merged().unwrap(); - let mut expect = Vec::new(); - for ch in 0..mode_channels(mode) { - expect.extend(pattern_plane(9, 5, depth, u32::from(ch))); - } - assert_eq!(dec, expect, "{mode:?} {depth} {c:?}"); - } - } - } -} - -macro_rules! layered_case { - ($name:ident, $ver:expr, $mode:expr, $depth:expr, $comp:expr) => { - #[test] - fn $name() { - let f = testgen::layered($ver, $mode, $depth, $comp); - assert_stable(&f, stringify!($name)); - assert!(f.layers().len() > 30); - let expect_placement = $depth != 8; - assert_eq!(matches!(f.layer_info_placement, LayerInfoPlacement::GlobalBlock { .. }), expect_placement); - } - }; -} - -layered_case!(layered_psd_rgb8_raw, Version::Psd, ColorMode::Rgb, 8, Compression::Raw); -layered_case!(layered_psd_rgb8_rle, Version::Psd, ColorMode::Rgb, 8, Compression::Rle); -layered_case!(layered_psd_rgb8_zip, Version::Psd, ColorMode::Rgb, 8, Compression::Zip); -layered_case!(layered_psd_rgb8_zipp, Version::Psd, ColorMode::Rgb, 8, Compression::ZipPrediction); -layered_case!(layered_psd_rgb16_raw, Version::Psd, ColorMode::Rgb, 16, Compression::Raw); -layered_case!(layered_psd_rgb16_rle, Version::Psd, ColorMode::Rgb, 16, Compression::Rle); -layered_case!(layered_psd_rgb16_zip, Version::Psd, ColorMode::Rgb, 16, Compression::Zip); -layered_case!(layered_psd_rgb16_zipp, Version::Psd, ColorMode::Rgb, 16, Compression::ZipPrediction); -layered_case!(layered_psd_rgb32_raw, Version::Psd, ColorMode::Rgb, 32, Compression::Raw); -layered_case!(layered_psd_rgb32_rle, Version::Psd, ColorMode::Rgb, 32, Compression::Rle); -layered_case!(layered_psd_rgb32_zip, Version::Psd, ColorMode::Rgb, 32, Compression::Zip); -layered_case!(layered_psd_rgb32_zipp, Version::Psd, ColorMode::Rgb, 32, Compression::ZipPrediction); -layered_case!(layered_psb_rgb8_raw, Version::Psb, ColorMode::Rgb, 8, Compression::Raw); -layered_case!(layered_psb_rgb8_rle, Version::Psb, ColorMode::Rgb, 8, Compression::Rle); -layered_case!(layered_psb_rgb8_zip, Version::Psb, ColorMode::Rgb, 8, Compression::Zip); -layered_case!(layered_psb_rgb8_zipp, Version::Psb, ColorMode::Rgb, 8, Compression::ZipPrediction); -layered_case!(layered_psb_rgb16_rle, Version::Psb, ColorMode::Rgb, 16, Compression::Rle); -layered_case!(layered_psb_rgb32_zipp, Version::Psb, ColorMode::Rgb, 32, Compression::ZipPrediction); -layered_case!(layered_psd_gray8_rle, Version::Psd, ColorMode::Grayscale, 8, Compression::Rle); -layered_case!(layered_psd_gray16_zipp, Version::Psd, ColorMode::Grayscale, 16, Compression::ZipPrediction); -layered_case!(layered_psd_gray32_zip, Version::Psd, ColorMode::Grayscale, 32, Compression::Zip); -layered_case!(layered_psd_cmyk8_rle, Version::Psd, ColorMode::Cmyk, 8, Compression::Rle); -layered_case!(layered_psd_cmyk16_raw, Version::Psd, ColorMode::Cmyk, 16, Compression::Raw); -layered_case!(layered_psb_cmyk8_zip, Version::Psb, ColorMode::Cmyk, 8, Compression::Zip); -layered_case!(layered_psd_lab8_rle, Version::Psd, ColorMode::Lab, 8, Compression::Rle); -layered_case!(layered_psb_lab16_zipp, Version::Psb, ColorMode::Lab, 16, Compression::ZipPrediction); - -macro_rules! merged_case { - ($name:ident, $mode:expr, $depth:expr) => { - #[test] - fn $name() { - for v in [Version::Psd, Version::Psb] { - for c in Compression::ALL { - let f = testgen::merged_only(v, $mode, $depth, c, 31, 3); - assert_stable(&f, stringify!($name)); - assert!(f.layer_info.is_none()); - } - } - } - }; -} - -merged_case!(merged_bitmap_1, ColorMode::Bitmap, 1); -merged_case!(merged_gray_8, ColorMode::Grayscale, 8); -merged_case!(merged_gray_16, ColorMode::Grayscale, 16); -merged_case!(merged_gray_32, ColorMode::Grayscale, 32); -merged_case!(merged_indexed_8, ColorMode::Indexed, 8); -merged_case!(merged_rgb_8, ColorMode::Rgb, 8); -merged_case!(merged_rgb_16, ColorMode::Rgb, 16); -merged_case!(merged_rgb_32, ColorMode::Rgb, 32); -merged_case!(merged_cmyk_8, ColorMode::Cmyk, 8); -merged_case!(merged_cmyk_16, ColorMode::Cmyk, 16); -merged_case!(merged_lab_8, ColorMode::Lab, 8); -merged_case!(merged_lab_16, ColorMode::Lab, 16); -merged_case!(merged_multichannel_8, ColorMode::Multichannel, 8); -merged_case!(merged_multichannel_16, ColorMode::Multichannel, 16); -merged_case!(merged_duotone_8, ColorMode::Duotone, 8); - -#[test] -fn psb_uses_long_section_lengths() { - let psd = testgen::small(Version::Psd, Compression::Raw).to_bytes().unwrap(); - let psb = testgen::small(Version::Psb, Compression::Raw).to_bytes().unwrap(); - // Layer & mask length (+4), layer info length (+4), 4 layers × channels × 4. - let f = testgen::small(Version::Psd, Compression::Raw); - let nch: usize = f.layers().iter().map(|l| l.channels.len()).sum(); - assert_eq!(psb.len(), psd.len() + 8 + nch * 4); - assert_eq!(psb[5], 2); -} - -#[test] -fn psb_rle_counts_are_u32() { - let psd = testgen::merged_only(Version::Psd, ColorMode::Grayscale, 8, Compression::Rle, 5, 4); - let psb = testgen::merged_only(Version::Psb, ColorMode::Grayscale, 8, Compression::Rle, 5, 4); - assert_eq!(psb.image_data.data.len(), psd.image_data.data.len() + 4 * 2); -} - -#[test] -fn psb_long_key_global_block() { - let mut f = testgen::small(Version::Psb, Compression::Rle); - f.global_blocks.push(TaggedBlock::new(*b"FMsk", vec![1, 2, 3, 4])); - f.global_blocks.push(TaggedBlock::new(*b"Alph", vec![5, 6])); - let b = f.to_bytes().unwrap(); - let p = PsdFile::from_bytes(&b).unwrap(); - assert_eq!(p, f); - // Reading the PSB bytes while pretending PSD must not silently succeed - // with the same structure. - let mut as_psd = b.clone(); - as_psd[5] = 1; - if let Ok(q) = PsdFile::from_bytes(&as_psd) { - assert_ne!(q, f); - } -} - -#[test] -fn edits_are_reencoded() { - let mut f = testgen::small(Version::Psd, Compression::Rle); - let v = f.header.version; - let layer = &mut f.layers_mut()[0]; - let (w, h) = layer.rect.size().unwrap(); - let new = vec![7u8; w * h]; - let ch = layer.channels.iter_mut().find(|c| c.id == 0).unwrap(); - ch.set_decoded(Compression::ZipPrediction, &new, w, h, 8, v).unwrap(); - let b = f.to_bytes().unwrap(); - let p = PsdFile::from_bytes(&b).unwrap(); - assert_eq!(p.layers()[0].decode_channel(0, 8, v).unwrap(), new); - assert_eq!(p.layers()[0].channel(0).unwrap().compression, Some(Compression::ZipPrediction)); -} - -#[test] -fn renaming_changes_luni_and_roundtrips() { - let mut f = testgen::small(Version::Psd, Compression::Raw); - let l = &mut f.layers_mut()[0]; - *l.block_mut(b"luni").unwrap() = TaggedBlock::unicode_name("Renamed \u{263a}"); - let p = PsdFile::from_bytes(&f.to_bytes().unwrap()).unwrap(); - assert_eq!(p.layers()[0].name(), "Renamed \u{263a}"); -} - -#[test] -fn unknown_blocks_and_resources_pass_through() { - let mut f = testgen::small(Version::Psd, Compression::Rle); - f.resources.push(ImageResource::new(9999, vec![1, 2, 3, 4, 5])); - f.global_blocks.push(TaggedBlock::new(*b"Q3Dx", (0..=255).collect())); - f.layers_mut()[0].blocks.push(TaggedBlock::new(*b"Vid!", vec![0xab; 7])); - f.layers_mut()[0].blend_mode = BlendMode::Unknown(*b"new!"); - assert_stable(&f, "unknown passthrough"); - let p = PsdFile::from_bytes(&f.to_bytes().unwrap()).unwrap(); - assert_eq!(p.layers()[0].blend_mode, BlendMode::Unknown(*b"new!")); - assert_eq!(p.global_block(b"Q3Dx").unwrap().data.len(), 256); -} - -#[test] -fn unknown_color_mode_passthrough() { - let mut f = testgen::merged_only(Version::Psd, ColorMode::Grayscale, 8, Compression::Raw, 2, 2); - f.header.color_mode = ColorMode::Unknown(5); - assert_stable(&f, "unknown mode"); -} - -#[test] -fn unknown_compression_passthrough() { - let mut f = testgen::small(Version::Psd, Compression::Raw); - f.layers_mut()[0].channels[0].compression = Some(Compression::Unknown(9)); - assert_stable(&f, "unknown channel compression"); - assert!(f.layers()[0].decode_channel(f.layers()[0].channels[0].id, 8, Version::Psd).is_err()); -} - -#[test] -fn trailing_section_bytes_preserved() { - let mut f = testgen::small(Version::Psd, Compression::Raw); - f.layer_mask_trailing = vec![0; 6]; - assert_stable(&f, "layer mask trailing"); - f.layers_mut()[1].extra_trailing = vec![0; 5]; - assert_stable(&f, "record trailing"); - f.layer_info.as_mut().unwrap().padding = Some(vec![0, 0, 0, 0]); - f.layers_mut()[1].extra_trailing.clear(); - assert_stable(&f, "layer info padding"); -} - -#[test] -fn global_mask_accessors() { - let f = testgen::layered(Version::Psd, ColorMode::Rgb, 8, Compression::Raw); - let gm = f.global_layer_mask.as_ref().unwrap(); - assert_eq!(gm.overlay_color_space(), Some(0)); - assert_eq!(gm.color_components(), Some([0xffff, 0, 0, 0])); - assert_eq!(gm.opacity(), Some(50)); - assert_eq!(gm.kind(), Some(128)); - assert_eq!(GlobalLayerMask::default().kind(), None); - assert_eq!(GlobalLayerMask::photoshop_default().data, gm.data); -} - -#[test] -fn file_accessors() { - let f = testgen::layered(Version::Psd, ColorMode::Rgb, 8, Compression::Rle); - assert!((f.resolution().unwrap().h_res() - 72.0).abs() < 1e-9); - assert_eq!(f.icc_profile().unwrap().len(), 13); - assert_eq!(f.has_real_merged_data(), Some(true)); - assert!(f.merged_has_alpha()); - assert!(f.validate().is_ok()); - let lfx = f.layers().iter().find_map(|l| l.block(b"lfx2")).expect("lfx2"); - let d = descriptor::VersionedDescriptor::from_bytes(&lfx.data[4..]).unwrap(); - assert_eq!(d, testgen::sample_descriptor()); -} - -#[test] -fn validate_rejects_bad_models() { - let mut f = testgen::merged_only(Version::Psd, ColorMode::Indexed, 8, Compression::Raw, 2, 2); - assert!(f.validate().is_ok()); - f.color_mode_data.pop(); - assert!(f.validate().is_err()); - let mut g = testgen::merged_only(Version::Psd, ColorMode::Rgb, 8, Compression::Raw, 2, 2); - g.header.width = 40_000; - assert!(g.validate().is_err()); - g.header.version = Version::Psb; - assert!(g.validate().is_ok()); -} - -#[test] -fn psd_too_large_for_32bit_errors_on_write() { - let mut f = testgen::small(Version::Psd, Compression::Raw); - // A resource larger than u32 can't be constructed cheaply; instead check - // RLE rows > 65535 bytes are rejected for PSD but allowed for PSB. - let l = photocraft_psd::compression::PlaneLayout { planes: 1, width: 70_000, height: 1, depth: 8, version: Version::Psd }; - let row: Vec = (0..70_000u32).map(|i| (i % 251) as u8).collect(); - assert!(compression::encode_planes(Compression::Rle, &row, &l).is_err()); - let l = compression::PlaneLayout { version: Version::Psb, ..l }; - assert!(compression::encode_planes(Compression::Rle, &row, &l).is_ok()); - f.header.version = Version::Psd; -} - -#[cfg(not(target_arch = "wasm32"))] -#[test] -fn save_and_open() { - let f = testgen::small(Version::Psd, Compression::Rle); - let dir = std::env::temp_dir().join(format!("photocraft-psd-test-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let p = dir.join("x.psd"); - f.save(&p).unwrap(); - let g = PsdFile::open(&p).unwrap().unwrap(); - assert_eq!(g, f); - let _ = std::fs::remove_dir_all(&dir); -} - -/// A density-only mask record written in the spec's order (parameters, then the real-mask -/// fields) used to be read back in Photoshop's order, which moved the density into the real -/// mask's background and dropped it from the parameters (#949). The model must survive the -/// round trip, not only the bytes. -#[test] -fn spec_order_density_only_mask_round_trips_as_a_model() { - let mut f = testgen::small(Version::Psd, Compression::Raw); - let layer = &mut f.layers_mut()[0]; - layer.mask = MaskData::Mask(LayerMask { - rect: Rect::from_xywh(0, 0, 1, 1), - default_color: 255, - flags: LayerMask::FLAG_PARAMETERS, - parameters: Some(MaskParameters { flags: 1, user_density: Some(64), ..Default::default() }), - real: Some(RealMask { flags: 0, background: 0, rect: Rect::from_xywh(0, 0, 1, 1) }), - trailing: vec![], - real_first: false, - }); - // The generated 2 x 2 mask channel is replaced by one that matches the 1 x 1 rectangle. - let mask_channel = layer.channels.iter_mut().find(|c| c.id == -2).expect("the generated layer has a mask channel"); - *mask_channel = ChannelData::encode(-2, Compression::Raw, &[64], 1, 1, 8, Version::Psd).unwrap(); - let b = f.to_bytes().unwrap(); - let g = PsdFile::from_bytes(&b).unwrap(); - assert_eq!(g, f, "model round trip"); - assert_eq!(g.layers()[0].decode_channel(-2, 8, Version::Psd).unwrap(), vec![64]); - assert_eq!(g.to_bytes().unwrap(), b, "byte stability"); -} - -/// Embedded data (smart-object files, and the like) is large next to the headers, so the output -/// is reserved at its exact size up front: no growth by doubling, which on the web is a single -/// unrecoverable multi-gigabyte `realloc` (#10024). -#[test] -fn to_bytes_reserves_the_whole_output_up_front() { - for version in [Version::Psd, Version::Psb] { - let mut f = testgen::small(version, Compression::Rle); - f.global_blocks.push(TaggedBlock::new(*b"Q3Dx", vec![0x5a; 3 << 20])); - f.layers_mut()[0].blocks.push(TaggedBlock::new(*b"Vid!", vec![0xab; 1 << 20])); - let b = f.to_bytes().unwrap(); - assert_eq!(b.capacity(), b.len(), "{version:?}: the output buffer grew past its reserve"); - assert_eq!(b.len() as u64, f.encoded_len().unwrap()); - let p = PsdFile::from_bytes(&b).unwrap(); - assert_eq!(p, f); - assert_eq!(p.to_bytes().unwrap(), b); - } -} diff --git a/crates/psd/tests/it/swatch_malformed.rs b/crates/psd/tests/it/swatch_malformed.rs deleted file mode 100644 index 91a83b4285..0000000000 --- a/crates/psd/tests/it/swatch_malformed.rs +++ /dev/null @@ -1,943 +0,0 @@ -//! `.grd`, `.ase` and `.aco` hostile-input handling. -//! -//! These three parsers are standalone entry points that `PsdFile::from_bytes` never reaches -//! (nothing in `file.rs` calls them), so the `parse.rs` fuzz target cannot exercise them. They are -//! also user-reachable: `grd::parse` backs the gradient-preset import in -//! `engine::preset_import_cmds`, and `ase`/`aco` back swatch import and export. Each module has its -//! own `hostile_input` unit test, but those assert `let _ = parse(...)` on mutations: a panic would -//! abort the test without saying which parser and which byte caused it. -//! -//! What this file adds: -//! 1. every single-bit flip in a valid file, each wrapped in `catch_unwind` so a panic is -//! reported with the exact byte and parser rather than as an opaque abort (§0.8: "parsers and -//! decoders get mutation-fuzz tests run under `catch_unwind`"); -//! 2. the round-trip and cap invariants the fuzz targets assert, checked deterministically so CI -//! covers them even though the `fuzz` job is workflow-dispatch only; -//! 3. inputs sized to *exceed* each advertised cap, because a cap asserted only against a file -//! that fits under it is a tautology — it cannot fail, so it proves nothing. -//! -//! The rule under test throughout: hostile input returns `Err`, never a panic and never an -//! unbounded allocation. - -use photocraft_psd::aco::{AcoColor, AcoSwatch}; -use photocraft_psd::grd::{GrdColor, GrdGradient, GrdStop}; -use photocraft_psd::{PsdError, aco, ase, grd}; -use std::panic::{RefUnwindSafe, UnwindSafe, catch_unwind}; - -fn sample_ase() -> Vec { - use photocraft_psd::ase::{AseColor, AseEntry, AseFile, AseKind, AseSwatch as Sw, write}; - let sw = |name: &str| Sw { name: name.into(), color: AseColor::Rgb([1.0, 0.0, 0.0]), kind: AseKind::Process }; - write(&AseFile { entries: vec![AseEntry::Group { name: "Brand".into(), swatches: vec![sw("Red"), sw("Blue")] }, AseEntry::Swatch(sw("Loose"))] }) - .expect("writing a sample .ase") -} - -fn sample_aco_swatches() -> Vec { - vec![ - AcoSwatch { name: "Pure Red".into(), color: AcoColor::Rgb([65535, 0, 0]) }, - AcoSwatch { name: "Cyan ink".into(), color: AcoColor::Cmyk([65535, 0, 0, 1234]) }, - AcoSwatch { name: "Lab".into(), color: AcoColor::Lab { l: 5321, a: -12800, b: 12700 } }, - AcoSwatch { name: "Gray".into(), color: AcoColor::Gray(5000) }, - AcoSwatch { name: "Book".into(), color: AcoColor::Other { space: 3, values: [1, 2, 3, 4] } }, - ] -} - -fn sample_aco() -> Vec { - aco::write(&sample_aco_swatches()).expect("writing a sample .aco") -} - -fn sample_grd() -> Vec { - use photocraft_psd::grd::write; - write(&[ - GrdGradient { - name: "Sunset".into(), - noise: false, - stops: vec![ - GrdStop { location: 0.0, midpoint: 0.5, color: GrdColor::Rgb([1.0, 0.0, 0.0]) }, - GrdStop { location: 1.0, midpoint: 0.5, color: GrdColor::Background }, - ], - opacity: vec![(0.0, 1.0, 0.5), (1.0, 0.25, 0.5)], - }, - GrdGradient { name: "Noisy".into(), noise: true, stops: Vec::new(), opacity: Vec::new() }, - ]) -} - -/// Flips every single bit of a valid file and requires that the parser returns or fails without -/// panicking. A flipped length or count must never be trusted into an allocation. -fn bitflip_sweep(bytes: &[u8], name: &str, parse: F) -where - F: Fn(&[u8]) -> Result + RefUnwindSafe + UnwindSafe, -{ - for byte in 0..bytes.len() { - for bit in 0..8 { - let mut b = bytes.to_vec(); - b[byte] ^= 1 << bit; - let r = catch_unwind(|| parse(&b)); - assert!(r.is_ok(), "{name}: parse panicked after flipping bit {bit} of byte {byte} of {}", bytes.len()); - } - } -} - -#[test] -fn ase_bitflips_never_panic() { - bitflip_sweep(&sample_ase(), "ase", ase::parse); -} - -#[test] -fn aco_bitflips_never_panic() { - bitflip_sweep(&sample_aco(), "aco", aco::parse); -} - -#[test] -fn grd_bitflips_never_panic() { - bitflip_sweep(&sample_grd(), "grd", grd::parse); -} - -fn assert_finite(values: &[f32], what: &str) { - for v in values { - assert!(v.is_finite(), "{what} component {v} is not finite"); - } -} - -/// Every number a parsed gradient carries must be finite and inside the ranges the module -/// documents, so a caller never divides by or formats a NaN and never reads past a 0..=1 bound. -fn assert_gradient_sane(g: &GrdGradient) { - for s in &g.stops { - assert!(s.location.is_finite() && (0.0..=1.0).contains(&s.location), "stop location {} out of range", s.location); - assert!(s.midpoint.is_finite() && (0.0..=1.0).contains(&s.midpoint), "stop midpoint {} out of range", s.midpoint); - match s.color { - GrdColor::Rgb(v) => assert_finite(&v, "RGB"), - GrdColor::Hsb(v) => assert_finite(&v, "HSB"), - GrdColor::Cmyk(v) => assert_finite(&v, "CMYK"), - GrdColor::Lab(v) => assert_finite(&v, "Lab"), - GrdColor::Gray(v) => assert!(v.is_finite(), "gray {v} is not finite"), - GrdColor::Foreground | GrdColor::Background => {} - } - } - for &(l, o, m) in &g.opacity { - assert!(l.is_finite() && (0.0..=1.0).contains(&l), "transparency location {l} out of range"); - assert!((0.0..=1.0).contains(&o), "opacity {o} outside 0..=1"); - assert!(m.is_finite() && (0.0..=1.0).contains(&m), "transparency midpoint {m} out of range"); - } -} - -/// A gradient file that parses must obey the caps the module advertises and keep every numeric -/// field finite, so callers never divide by or format a NaN. -#[test] -fn grd_parsed_values_respect_caps_and_are_finite() { - // No cap assertion here: the sample has two gradients and two stops, so a cap check would - // hold for any writer and prove nothing. `grd_gradient_count_is_capped` and - // `grd_stop_counts_are_capped` drive inputs over the caps instead. - for g in &grd::parse(&sample_grd()).expect("sample .grd parses") { - assert_gradient_sane(g); - } -} - -/// A file declaring more gradients than [`grd::MAX_GRADIENTS`] must be capped at the cap, not -/// trusted: the cap is the only thing between a file's own list length and the allocation it asks -/// for. The input deliberately exceeds the cap, otherwise the assertion below cannot fail. -#[test] -fn grd_gradient_count_is_capped() { - let over = vec![GrdGradient { name: "g".into(), ..Default::default() }; grd::MAX_GRADIENTS + 1]; - let bytes = grd::write(&over); - // Precondition: the file really must carry more gradients than the cap, or the assertion - // below would hold for the wrong reason (the writer capping rather than the reader). - // Precondition, by size: a file carrying one gradient more than the cap must be larger than - // the same file at the cap. Counting `Grdn` windows would not work — the object nests — and - // would keep passing even if the writer started capping, which is what this must catch. - let at_cap = grd::write(&over[..grd::MAX_GRADIENTS]).len(); - assert!(bytes.len() > at_cap, "the written file is not larger than the capped one: {} vs {at_cap}", bytes.len()); - let f = grd::parse(&bytes).expect("a file over the gradient cap still parses"); - assert_eq!(f.len(), grd::MAX_GRADIENTS, "gradients must be capped at MAX_GRADIENTS, not taken as declared"); -} - -/// Same for the per-gradient stop lists: colour stops and transparency stops are capped -/// separately, so both are driven over their cap. -#[test] -fn grd_stop_counts_are_capped() { - let stop = GrdStop { location: 0.5, midpoint: 0.5, color: GrdColor::Rgb([1.0, 0.0, 0.0]) }; - let over = GrdGradient { name: "wide".into(), noise: false, stops: vec![stop; grd::MAX_STOPS + 1], opacity: vec![(0.5, 1.0, 0.5); grd::MAX_STOPS + 1] }; - let bytes = grd::write(std::slice::from_ref(&over)); - // Precondition by size: a file carrying one stop more than the cap must be larger than the - // same file at the cap. Counting `Lctn` windows would not work — it appears in both the colour - // and the transparency list — and would keep passing even if the writer started capping, - // which is exactly what this must catch. Each list is driven on its own, since they are - // capped independently. - let capped_stops = over.stops[..grd::MAX_STOPS].to_vec(); - let capped_opacity = over.opacity[..grd::MAX_STOPS].to_vec(); - let colour_only_over = GrdGradient { opacity: Vec::new(), ..over.clone() }; - let colour_only_capped = GrdGradient { stops: capped_stops, opacity: Vec::new(), ..over.clone() }; - assert!(grd::write(&[colour_only_over]).len() > grd::write(&[colour_only_capped]).len(), "colour stops over the cap are not larger than at the cap"); - let trans_only_over = GrdGradient { stops: Vec::new(), ..over.clone() }; - let trans_only_capped = GrdGradient { stops: Vec::new(), opacity: capped_opacity, ..over }; - assert!(grd::write(&[trans_only_over]).len() > grd::write(&[trans_only_capped]).len(), "transparency stops over the cap are not larger than at the cap"); - let f = grd::parse(&bytes).expect("a file over the stop cap still parses"); - assert_eq!(f[0].stops.len(), grd::MAX_STOPS, "colour stops must be capped at MAX_STOPS"); - assert_eq!(f[0].opacity.len(), grd::MAX_STOPS, "transparency stops must be capped at MAX_STOPS"); -} - -/// `Opct` is a unit float (`UntF` + a 4-byte unit tag), not a `doub`, so patching it needs a -/// different offset from the RGB components. Every numeric field now passes through `finite()` -/// before any clamp, so this pins that a non-finite value is repaired first — saturated, or for a -/// NaN replaced by the field default — rather than reaching `f32::clamp`, which propagates NaN and -/// would hand it straight back out of the parser. Every non-finite shape is tried at both places. -#[test] -fn grd_non_finite_opacity_and_midpoint_do_not_survive() { - // 3.0e38 is finite as both f64 and f32, so it is not caught by the finiteness check and must - // be brought into range by the saturate-then-clamp path; it belongs here to keep that honest. - let bad = [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, 1e300, 3.0e38]; - // `doub`: key + 4-byte OSType + 8-byte value. `UntF`: key + OSType + 4-byte unit tag + value. - // `Opct` is the interesting one: a unit float at a different offset from the RGB components, so - // both the `doub` and `UntF` shapes are patched. `Mdpn` is written as an integer here, which - // cannot carry a non-finite value at all, so it is skipped rather than pretended to be covered; - // a hostile file storing `Mdpn` as a double is still guarded by `finite()` and by the fuzz - // target's range assertion. - for key in ["Opct", "Rd ", "Grn ", "Bl "] { - for v in bad { - let mut b = sample_grd(); - let at = b.windows(4).position(|w| w == key.as_bytes()).unwrap_or_else(|| panic!("the sample stores {key:?}")); - let skip = match &b[at + 4..at + 8] { - b"doub" => 8usize, - b"UntF" => 12usize, - other => panic!("{key} is stored as {other:?}, which this test cannot patch"), - }; - b[at + skip..at + skip + 8].copy_from_slice(&v.to_be_bytes()); - let f = grd::parse(&b).unwrap_or_else(|e| panic!("{key} = {v} still parses: {e:?}")); - for g in &f { - assert_gradient_sane(g); - } - } - } -} - -/// `num` returns the raw f64 and `finite()` is the single guard every number passes through. This -/// pins that one policy end to end: only a NaN is truly absent and falls back to the documented -/// default (opacity 100 %, midpoint centred); every other shape — including `+/-inf` and a value -/// finite as `f64` but above `f32::MAX` — saturates and then clamps, so a large positive lands at -/// 1.0 and a large negative at 0.0, monotonic, never a snap back to the default. -/// `Mdpn` is written as a `long` by this crate's own writer, so no fixture can smuggle a -/// non-finite value into it and the guard inside `midpoint()` would never be exercised. The file -/// format allows a `double` there, so the descriptor is built by hand with `Mdpn` as a `doub`: -/// otherwise removing that guard changes nothing any test can see. -#[test] -fn grd_midpoint_stored_as_a_double_is_still_sanitised() { - use photocraft_psd::descriptor::{Descriptor, Value, VersionedDescriptor}; - for v in [f64::NAN, f64::INFINITY, 1e300, 3.0e38] { - // Driven through `Trns`, where the reader keeps the midpoint, rather than through `Clrs`, - // where a colour stop also needs a colour this descriptor does not carry. - let trns = Descriptor::new("TrnS").with("Mdpn", Value::Double(v)); - let grad = Descriptor::new("Grdn").with("Trns", Value::List(vec![Value::Descriptor(trns)])); - let outer = Descriptor::new("Grdn").with("Grad", Value::Descriptor(grad)); - let file = Descriptor::new("null").with("GrdL", Value::List(vec![Value::Descriptor(outer)])); - let mut g = b"8BGR".to_vec(); - g.extend_from_slice(&5u16.to_be_bytes()); - g.extend_from_slice(&VersionedDescriptor::new(file).to_bytes()); - - let parsed = grd::parse(&g).unwrap_or_else(|e| panic!("Mdpn = {v} still parses: {e:?}")); - let Some((_, _, m)) = parsed[0].opacity.first() else { panic!("the transparency stop was dropped") }; - // Pin the exact value, not just the range. Only a NaN is truly absent, so only it falls - // back to the centred default; every other shape saturates and then clamps, so a large - // positive lands at 1.0 and a large negative at 0.0 — monotonic, never a snap to 0.5. - let expected = if v.is_nan() { - 0.5 - } else if v > 0.0 { - 1.0 - } else { - 0.0 - }; - assert_eq!(*m, expected, "a midpoint of {v} must be {expected}, got {m}"); - } -} - -/// `.aco` stores CMYK as *ink* amounts in memory and the complement on disk: the file holds 0 for -/// 100 % ink and the reader adds the back. Nothing pinned that, so flipping either side would -/// silently invert every CMYK swatch. -#[test] -fn aco_cmyk_is_stored_inverted_and_reads_back_as_ink() { - let swatches = vec![AcoSwatch { name: "k".into(), color: AcoColor::Cmyk([65535, 0, 0, 1234]) }]; - let bytes = aco::write(&swatches).expect("writing one CMYK swatch"); - - // Version 1 section: version u16, count u16, then a 10-byte record. - let space = u16::from_be_bytes([bytes[4], bytes[5]]); - let stored: Vec = (0..4).map(|i| u16::from_be_bytes([bytes[6 + i * 2], bytes[7 + i * 2]])).collect(); - assert_eq!(space, aco::space::CMYK, "the record must be tagged CMYK"); - assert_eq!(stored, vec![0, u16::MAX, u16::MAX, u16::MAX - 1234], "the file stores the complement of the ink amounts"); - assert_eq!(aco::parse(&bytes).expect("the file parses").swatches, swatches, "the ink amounts must survive the round trip"); -} - -#[test] -fn grd_unrepresentable_numbers_follow_the_documented_policy() { - // The *second* transparency stop carries opacity 0.25; the first is already 1.0, so patching - // it could not tell "fell back to the default" from "the patch had no effect". - let patch = |key: &[u8], v: f64| { - let mut b = sample_grd(); - let at = b.windows(4).rposition(|w| w == key).expect("the sample stores the key"); - let skip = match &b[at + 4..at + 8] { - b"doub" => 8usize, - b"UntF" => 12usize, - other => panic!("stored as {other:?}"), - }; - b[at + skip..at + skip + 8].copy_from_slice(&v.to_be_bytes()); - grd::parse(&b).expect("still parses") - }; - // Opacity. A NaN is the only truly absent value, so it alone falls back to the 100 % default. - // Every other shape saturates then clamps, which is monotonic: a large positive stays opaque, - // a large negative becomes transparent. Nothing snaps the wrong way as the number grows. - for (v, expected) in [ - (f64::NAN, 1.0), // absent -> the field default (opaque) - (f64::INFINITY, 1.0), // saturates to the top of the range - (f64::NEG_INFINITY, 0.0), // saturates to the bottom - (1e300, 1.0), // over f32::MAX -> saturates up - (-1e300, 0.0), // under -f32::MAX -> saturates down - (3.0e38, 1.0), // finite as f32 -> clamps up - (-3.0e38, 0.0), // finite as f32 -> clamps down - ] { - let got = patch(b"Opct", v)[0].opacity[1].1; - assert_eq!(got, expected, "Opct = {v} must read back as {expected}, got {got}"); - } - // A colour channel has its own default (zero) for the absent case, and the same monotonic - // saturate-then-clamp for everything else. Pin the exact number: a value reaching the clamp - // from above would come back as 1.0, which is in range and would slip past a range-only check. - for (v, expected) in [ - (f64::NAN, 0.0), // absent -> the channel default - (f64::INFINITY, 1.0), // saturates up, then clamps to 1.0 - (f64::NEG_INFINITY, 0.0), // saturates down, then clamps to 0.0 - (1e300, 1.0), // over f32::MAX -> saturates up - (-1e300, 0.0), // under -f32::MAX -> saturates down - (3.0e38, 1.0), // finite as f32 -> clamps up - (-3.0e38, 0.0), // finite as f32 -> clamps down - ] { - let f = patch(b"Bl ", v); - let GrdColor::Rgb([_, _, b]) = f[0].stops[0].color else { panic!("expected an RGB stop") }; - assert_eq!(b, expected, "a blue component of {v} must be {expected}, got {b}"); - } -} - -/// A name long enough that the code-unit cap lands *between* the halves of a surrogate pair must -/// not be cut in half: a lone surrogate is ill-formed UTF-16, and writing it would make the file -/// unreadable to anything strict. Capping has to drop the whole character instead, and the file -/// must then survive its own round trip unchanged. -#[test] -fn aco_and_ase_never_split_a_surrogate_pair() { - // "a" plus 600 emoji: 1 + 1200 units, so a 1024-unit cap lands mid-pair. - let name = format!("a{}", "\u{1F600}".repeat(600)); - assert!(name.encode_utf16().count() > ase::MAX_NAME_UNITS, "the name must be longer than the cap"); - - let swatches = vec![aco::AcoSwatch { name: name.clone(), color: aco::AcoColor::Gray(5000) }]; - let once = aco::write(&swatches).expect("writing a long non-ASCII name is capped, not refused"); - let back = aco::parse(&once).expect("the written file parses"); - // The exact expected name, not merely "it is valid UTF-16": the cap must drop the whole - // character that does not fit, leaving "a" plus 511 complete pairs and nothing half-written. - let expected = format!("a{}", "\u{1F600}".repeat(511)); - assert_eq!(back.swatches[0].name, expected, "the capped name must be whole characters only"); - assert_eq!(aco::write(&back.swatches).unwrap(), once, "the file must be stable across a round trip"); - - use photocraft_psd::ase::{AseColor, AseEntry, AseFile, AseKind, AseSwatch, write}; - let file = AseFile { entries: vec![AseEntry::Swatch(AseSwatch { name, color: AseColor::Rgb([1.0, 0.0, 0.0]), kind: AseKind::Process })] }; - let a1 = write(&file).expect("writing a long non-ASCII name is capped, not refused"); - let ab = ase::parse(&a1).expect("the written file parses"); - let ase::AseEntry::Swatch(s) = &ab.entries[0] else { panic!("expected a loose swatch") }; - assert_eq!(s.name, expected, "the capped name must be whole characters only"); - assert_eq!(write(&ab).unwrap(), a1, "the file must be stable across a round trip"); -} - -/// Locations, midpoints and opacity are documented as 0..=1. A hostile file stores raw integers, -/// so the values are driven well outside that range and must come back clamped. -#[test] -fn grd_out_of_range_numbers_are_clamped() { - let g = GrdGradient { - name: "out of range".into(), - noise: false, - stops: vec![ - GrdStop { location: 2.0, midpoint: 5.0, color: GrdColor::Rgb([1.0, 0.0, 0.0]) }, - GrdStop { location: -3.0, midpoint: -1.0, color: GrdColor::Rgb([0.0, 1.0, 0.0]) }, - ], - opacity: vec![(9.0, 5.0, 7.0), (-2.0, -1.0, 42.0)], - }; - let f = grd::parse(&grd::write(&[g])).expect("out-of-range numbers still parse"); - assert_gradient_sane(&f[0]); - // Range membership alone would be a tautology once the values are already in range: assert the - // exact clamped result, so a clamp silently degrading into a pass-through is caught. Positive - // over-range -> 1.0, negative -> 0.0. - assert_eq!(f[0].stops[0].location, 1.0, "location 2.0 must clamp to 1.0"); - assert_eq!(f[0].stops[0].midpoint, 1.0, "midpoint 5.0 must clamp to 1.0"); - assert_eq!(f[0].stops[1].location, 0.0, "location -3.0 must clamp to 0.0"); - assert_eq!(f[0].stops[1].midpoint, 0.0, "midpoint -1.0 must clamp to 0.0"); - assert_eq!(f[0].opacity[0], (1.0, 1.0, 1.0), "all-positive over-range opacity must clamp to 1.0"); - assert_eq!(f[0].opacity[1], (0.0, 0.0, 1.0), "location/opacity clamp to 0.0, midpoint 42.0 to 1.0"); -} - -/// `num` returns the raw f64 unfiltered; `finite()` is the single guard, and a NaN is not the only -/// non-finite f32 it has to handle: a value that is finite as f64 and above `f32::MAX` (1e300) -/// would become an infinity on a bare cast, so `finite()` saturates it. Every numeric field must -/// come back finite, so both doors are checked. -#[test] -fn grd_huge_f64_does_not_overflow_to_infinity() { - for key in ["Rd ", "Grn ", "Bl "] { - let mut b = sample_grd(); - let at = b.windows(4).position(|w| w == key.as_bytes()).unwrap_or_else(|| panic!("the sample stores {key:?}")); - assert_eq!(&b[at + 4..at + 8], b"doub", "{key} is stored as a double"); - b[at + 8..at + 16].copy_from_slice(&1e300f64.to_be_bytes()); - let f = grd::parse(&b).expect("a huge but finite component still parses"); - for g in &f { - assert_gradient_sane(g); - } - } -} - -/// A hostile file carrying a NaN must have it replaced, not handed on: `Rd ` in the sample is a -/// double, so patching those eight bytes is enough to try to smuggle a NaN past the reader. -#[test] -fn grd_non_finite_numbers_do_not_survive_the_reader() { - let mut b = sample_grd(); - let at = b.windows(4).position(|w| w == b"Rd ").expect("the sample stores an RGB component"); - assert_eq!(&b[at + 4..at + 8], b"doub", "an RGB component is stored as a double"); - b[at + 8..at + 16].copy_from_slice(&f64::NAN.to_be_bytes()); - let f = grd::parse(&b).expect("a NaN component still parses"); - assert!(!f.is_empty()); - for g in &f { - assert_gradient_sane(g); - } -} - -/// An `.ase` that parses must stay within the swatch cap and re-serialize to the same number of -/// colours, so an import never silently drops or invents entries. -#[test] -fn ase_round_trip_preserves_swatch_count() { - use photocraft_psd::ase::write; - let f = ase::parse(&sample_ase()).expect("sample .ase parses"); - // No cap assertion: three swatches cannot exceed a cap of 100 000. `ase_swatch_cap_is_enforced_ - // on_hostile_input` drives a file that does. - let n = f.swatch_count(); - let out = write(&f).expect("re-writing a parsed .ase"); - let back = ase::parse(&out).expect("the rewritten .ase parses"); - assert_eq!(back.swatch_count(), n, "swatch count changed across a round trip"); -} - -/// A file with more colour blocks than [`ase::MAX_SWATCHES`] must be refused. Each block here is -/// the smallest legal colour block, so the file is small but the declared count is over the cap. -#[test] -fn ase_swatch_cap_is_enforced_on_hostile_input() { - let mut body = Vec::new(); - body.extend_from_slice(&0u16.to_be_bytes()); // name length 0 - body.extend_from_slice(b"Gray"); - body.extend_from_slice(&0f32.to_be_bytes()); // gray component - body.extend_from_slice(&2u16.to_be_bytes()); // colour type: process - let mut block = Vec::new(); - block.extend_from_slice(&1u16.to_be_bytes()); // block type: colour entry - block.extend_from_slice(&(body.len() as u32).to_be_bytes()); - block.extend_from_slice(&body); - - let total = ase::MAX_SWATCHES + 1; - let mut f = b"ASEF".to_vec(); - f.extend_from_slice(&1u16.to_be_bytes()); // major - f.extend_from_slice(&0u16.to_be_bytes()); // minor - f.extend_from_slice(&(total as u32).to_be_bytes()); - for _ in 0..total { - f.extend_from_slice(&block); - } - assert!(matches!(ase::parse(&f), Err(PsdError::LimitExceeded(_))), "a file over MAX_SWATCHES must be refused, not silently truncated"); - // The same file one block under the cap is accepted, so the cap is what refuses it. - f.truncate(f.len() - block.len()); - let trimmed = total - 1; - // signature (4) + major (2) + minor (2) puts the block count at offset 8. - f[8..12].copy_from_slice(&(trimmed as u32).to_be_bytes()); - assert!(ase::parse(&f).is_ok(), "a file exactly at MAX_SWATCHES must still parse"); -} - -/// A file with more group starts than [`ase::MAX_GROUPS`] must be refused. Each block here is an -/// empty group start, six bytes long. -#[test] -fn ase_group_cap_is_enforced_on_hostile_input() { - let mut block = Vec::new(); - block.extend_from_slice(&0xC001u16.to_be_bytes()); // block type: group start - block.extend_from_slice(&0u32.to_be_bytes()); // empty body: no name - - let total = ase::MAX_GROUPS + 1; - let mut f = b"ASEF".to_vec(); - f.extend_from_slice(&1u16.to_be_bytes()); // major - f.extend_from_slice(&0u16.to_be_bytes()); // minor - f.extend_from_slice(&(total as u32).to_be_bytes()); - for _ in 0..total { - f.extend_from_slice(&block); - } - assert!(matches!(ase::parse(&f), Err(PsdError::LimitExceeded(_))), "a file over MAX_GROUPS must be refused"); - // With one block fewer the file is exactly at the cap and parses. - f.truncate(f.len() - block.len()); - let trimmed = total - 1; - // signature (4) + major (2) + minor (2) puts the block count at offset 8. - f[8..12].copy_from_slice(&(trimmed as u32).to_be_bytes()); - let parsed = ase::parse(&f).expect("a file exactly at MAX_GROUPS must still parse"); - assert_eq!(parsed.entries.len(), ase::MAX_GROUPS, "every group up to the cap is kept"); -} - -/// A name longer than [`ase::MAX_NAME_UNITS`] is truncated on read rather than refused: the file -/// is legal, only the name is long, and `read_name` is the only thing standing between the declared -/// length and the string it builds. -#[test] -fn ase_over_long_name_is_truncated_on_read() { - let units = ase::MAX_NAME_UNITS + 976; // 2000 - let mut body = Vec::new(); - body.extend_from_slice(&(units as u16).to_be_bytes()); // name length, no trailing null here - for _ in 0..units { - body.extend_from_slice(&(b'x' as u16).to_be_bytes()); - } - body.extend_from_slice(b"Gray"); - body.extend_from_slice(&0f32.to_be_bytes()); - body.extend_from_slice(&2u16.to_be_bytes()); - - let mut f = b"ASEF".to_vec(); - f.extend_from_slice(&1u16.to_be_bytes()); - f.extend_from_slice(&0u16.to_be_bytes()); - f.extend_from_slice(&1u32.to_be_bytes()); // one block - f.extend_from_slice(&1u16.to_be_bytes()); // colour entry - f.extend_from_slice(&(body.len() as u32).to_be_bytes()); - f.extend_from_slice(&body); - - let parsed = ase::parse(&f).expect("an over-long name is truncated, not refused"); - let ase::AseEntry::Swatch(s) = &parsed.entries[0] else { panic!("expected a loose swatch") }; - assert_eq!(s.name.encode_utf16().count(), ase::MAX_NAME_UNITS, "name must be capped at MAX_NAME_UNITS"); -} - -/// An `.aco` that parses must re-serialize to the same swatch count, with no name over the cap. -#[test] -fn aco_round_trip_preserves_swatch_count() { - use photocraft_psd::aco::write; - // No cap assertion here. `aco::MAX_SWATCHES` is `u16::MAX` against a `u16` on-disk count, so it - // is unfalsifiable by construction, and the sample names are far shorter than the name cap; - // `aco_over_long_name_is_truncated_on_read` is where that cap is actually driven. - let f = aco::parse(&sample_aco()).expect("sample .aco parses"); - let out = write(&f.swatches).expect("re-writing parsed swatches"); - let back = aco::parse(&out).expect("the rewritten .aco parses"); - assert_eq!(back.swatches.len(), f.swatches.len(), "swatch count changed across a round trip"); -} - -/// A version 2 section whose name declares more units than [`aco::MAX_NAME_UNITS`] must come back -/// truncated. The count is written by hand because `write` will not emit one. -#[test] -fn aco_over_long_name_is_truncated_on_read() { - let units = 2000usize; - let mut f = Vec::new(); - f.extend_from_slice(&2u16.to_be_bytes()); // version 2 section - f.extend_from_slice(&1u16.to_be_bytes()); // one swatch - f.extend_from_slice(&0u16.to_be_bytes()); // colour space: RGB - for v in [1u16, 2, 3, 0] { - f.extend_from_slice(&v.to_be_bytes()); // the four stored values - } - // The count includes the terminating null, as `write` does and as the format requires. - f.extend_from_slice(&((units + 1) as u32).to_be_bytes()); - for _ in 0..units { - f.extend_from_slice(&(b'x' as u16).to_be_bytes()); - } - f.extend_from_slice(&0u16.to_be_bytes()); // terminating null - let parsed = aco::parse(&f).expect("an over-long name is truncated, not refused"); - assert_eq!(parsed.swatches[0].name.encode_utf16().count(), aco::MAX_NAME_UNITS, "name must be capped at MAX_NAME_UNITS"); -} - -/// `write` must cap too, or a long name from a caller is written whole. The declared length in -/// the version 2 record counts the terminating null, so it must read back as MAX_NAME_UNITS + 1. -#[test] -fn aco_write_caps_the_name() { - let swatches = vec![AcoSwatch { name: "x".repeat(5000), color: AcoColor::Gray(5000) }]; - let bytes = aco::write(&swatches).expect("an over-long name is capped on write, not refused"); - // Names go out as UTF-16 big-endian, so 'x' is the byte pair 00 78. - let run = [0u8, b'x']; - let mut needle = Vec::new(); - for _ in 0..8 { - needle.extend_from_slice(&run); - } - let at = bytes.windows(needle.len()).position(|w| w == needle.as_slice()).expect("the name is written into the file"); - let declared = u32::from_be_bytes([bytes[at - 4], bytes[at - 3], bytes[at - 2], bytes[at - 1]]); - assert_eq!(declared as usize, aco::MAX_NAME_UNITS + 1, "write must declare at most MAX_NAME_UNITS units plus the null"); - let back = aco::parse(&bytes).expect("the written file parses"); - assert_eq!(back.swatches[0].name.encode_utf16().count(), aco::MAX_NAME_UNITS); -} - -/// `write` must cap an over-long name too, and it must count the terminating null in the u16 -/// length field it writes. Without the cap that field would wrap, but only once the name passes -/// 65 535 units — so the case that proves it uses 70 000, not merely "long". -#[test] -fn ase_write_caps_the_name() { - use photocraft_psd::ase::{AseColor, AseEntry, AseFile, AseKind, AseSwatch, write}; - let sw = AseSwatch { name: "x".repeat(70_000), color: AseColor::Rgb([1.0, 0.0, 0.0]), kind: AseKind::Process }; - let bytes = write(&AseFile { entries: vec![AseEntry::Swatch(sw)] }).expect("an over-long name is capped, not refused"); - let run = [0u8, b'x']; - let mut needle = Vec::new(); - for _ in 0..8 { - needle.extend_from_slice(&run); - } - let at = bytes.windows(needle.len()).position(|w| w == needle.as_slice()).expect("the name is written into the file"); - let declared = u16::from_be_bytes([bytes[at - 2], bytes[at - 1]]) as usize; - assert_eq!(declared, ase::MAX_NAME_UNITS + 1, "write must declare at most MAX_NAME_UNITS units plus the null"); - let back = ase::parse(&bytes).expect("the written file parses"); - let ase::AseEntry::Swatch(s) = &back.entries[0] else { panic!("expected a loose swatch") }; - assert_eq!(s.name.encode_utf16().count(), ase::MAX_NAME_UNITS); -} - -/// Truncating a `.grd` anywhere must be an error: unlike `.aco` there is no optional second -/// section, so a prefix can never be a complete file. -#[test] -fn truncate_grd_is_always_an_error() { - let b = sample_grd(); - for cut in 0..b.len() { - let r = catch_unwind(|| grd::parse(&b[..cut])); - assert!(r.is_ok(), "grd: parse panicked on a {cut}/{}-byte prefix", b.len()); - assert!(r.unwrap().is_err(), "grd: truncation at {cut}/{} parsed successfully", b.len()); - } - assert!(grd::parse(&b).is_ok(), "grd: the full file must parse"); -} - -/// Truncating an `.ase` anywhere must be an error: the block count is fixed up front, so a prefix -/// cannot contain the number of blocks the header claims. -#[test] -fn truncate_ase_is_always_an_error() { - let b = sample_ase(); - for cut in 0..b.len() { - let r = catch_unwind(|| ase::parse(&b[..cut])); - assert!(r.is_ok(), "ase: parse panicked on a {cut}/{}-byte prefix", b.len()); - assert!(r.unwrap().is_err(), "ase: truncation at {cut}/{} parsed successfully", b.len()); - } - assert!(ase::parse(&b).is_ok(), "ase: the full file must parse"); -} - -/// Truncating an `.aco` inside its first section must be an error. A cut that lands exactly after a -/// complete first section is *not* an error — the reader stops there, having read the version 1 -/// section, and ignores whatever follows — so the sweep stops at that boundary rather than -/// claiming every prefix fails. The boundary is derived from the sample so adding a swatch -/// cannot silently weaken the sweep. -#[test] -fn truncate_aco_inside_first_section_is_an_error() { - let b = sample_aco(); - // version u16 + count u16, then one 10-byte colour record per swatch. - let first_section = 4 + sample_aco_swatches().len() * 10; - assert!(first_section <= b.len(), "the sample must contain a whole first section"); - for cut in 0..first_section { - let r = catch_unwind(|| aco::parse(&b[..cut])); - assert!(r.is_ok(), "aco: parse panicked on a {cut}/{first_section}-byte prefix"); - assert!(r.unwrap().is_err(), "aco: truncation inside the first section at {cut} parsed successfully"); - } - match catch_unwind(|| aco::parse(&b[..first_section])) { - Ok(Ok(_)) => {} - Ok(Err(e)) => panic!("aco: a complete first section must parse, got {e:?}"), - Err(_) => panic!("aco: a complete first section panicked"), - } - assert!(aco::parse(&b).is_ok(), "aco: the full file must parse"); -} - -/// Structural `.ase` cases the block reader has to survive: a group end with no start, a group -/// started inside another, a block length that disagrees with what the body consumes, and an -/// unknown colour model whose components are cut short. Each is a clean error or a clean parse, -/// never a panic and never a read past the block. -#[test] -fn ase_structurally_hostile_blocks() { - use photocraft_psd::ase::{AseColor, AseEntry}; - let header = |count: u32| { - let mut f = b"ASEF".to_vec(); - f.extend_from_slice(&1u16.to_be_bytes()); - f.extend_from_slice(&0u16.to_be_bytes()); - f.extend_from_slice(&count.to_be_bytes()); - f - }; - let block = |kind: u16, body: &[u8]| { - let mut b = kind.to_be_bytes().to_vec(); - b.extend_from_slice(&(body.len() as u32).to_be_bytes()); - b.extend_from_slice(body); - b - }; - let group_start = |name: &str| { - // The name is a u16 code-unit count followed by the units themselves, not raw bytes. - let units: Vec = name.encode_utf16().collect(); - let mut b = (units.len() as u16).to_be_bytes().to_vec(); - for u in units { - b.extend_from_slice(&u.to_be_bytes()); - } - block(0xC001, &b) - }; - let group_end = || block(0xC002, &[]); - let colour = |model: &[u8], floats: &[u8]| { - let mut b = 0u16.to_be_bytes().to_vec(); - b.extend_from_slice(model); - b.extend_from_slice(floats); - b.extend_from_slice(&2u16.to_be_bytes()); - block(0x0001, &b) - }; - - // A group end with no start: documented as ignored, so this parses and yields nothing. - let mut f = header(1); - f.extend_from_slice(&group_end()); - let parsed = ase::parse(&f).expect("an unbalanced group end must parse"); - assert!(parsed.entries.is_empty(), "a stray group end must not invent an entry or an empty group"); - - // A group started inside another: the outer one closes, both groups are emitted. - let mut f = header(3); - f.extend_from_slice(&group_start("outer")); - f.extend_from_slice(&group_start("inner")); - f.extend_from_slice(&colour(b"Gray", &1f32.to_be_bytes())); - let parsed = ase::parse(&f).expect("a nested group start must parse"); - assert_eq!(parsed.swatch_count(), 1, "the colour must land in exactly one group"); - // The message above is only true if the colour is in the *inner* group; check it. - let inner_holds_it = parsed.entries.iter().any(|e| matches!(e, AseEntry::Group { name, swatches } if name == "inner" && swatches.len() == 1)); - assert!(inner_holds_it, "the colour must land in the inner group, not the outer one"); - let names: Vec<&str> = parsed - .entries - .iter() - .filter_map(|e| match e { - AseEntry::Group { name, .. } => Some(name.as_str()), - AseEntry::Swatch(_) => None, - }) - .collect(); - assert_eq!(names, vec!["outer", "inner"], "both groups must be closed and emitted"); - - // A block length shorter than the body it describes: the colour's floats are cut off. - // Gray carries exactly one component, so declaring the block one byte shorter leaves that - // component half-read. It must be refused, not guessed at. - let mut short = 0u16.to_be_bytes().to_vec(); - short.extend_from_slice(b"Gray"); - short.extend_from_slice(&0f32.to_be_bytes()); - short.extend_from_slice(&2u16.to_be_bytes()); // colour type: process - let mut f = header(1); - let mut b = 0x0001u16.to_be_bytes().to_vec(); - b.extend_from_slice(&((short.len() - 4) as u32).to_be_bytes()); - b.extend_from_slice(&short); - f.extend_from_slice(&b); - let r = catch_unwind(|| ase::parse(&f)); - assert!(r.is_ok(), "a truncated colour component must not panic"); - assert!(r.unwrap().is_err(), "a colour component cut short must be refused, not guessed at"); - - // A block that ends after the components, with no colour type at all, is *accepted*: some - // writers omit it and the reader defaults it to process. Pinning that, because it is the one - // place this parser is deliberately lenient. - let mut f = header(1); - let mut b = 0x0001u16.to_be_bytes().to_vec(); - b.extend_from_slice(&(short.len().saturating_sub(2) as u32).to_be_bytes()); - b.extend_from_slice(&short[..short.len() - 2]); - f.extend_from_slice(&b); - let parsed = ase::parse(&f).expect("a colour block with no colour type is accepted by design"); - assert_eq!(parsed.swatch_count(), 1, "it must still yield exactly one swatch"); - - // An unknown colour model keeps whatever bytes follow it verbatim. - // The model tag is four bytes; give it four, then a known payload, so "kept verbatim" can be - // checked rather than merely asserted. - let mut f = header(1); - let payload = [1u8, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]; - f.extend_from_slice(&colour(b"XYZ ", &payload)); - let parsed = ase::parse(&f).expect("an unknown colour model is kept verbatim"); - assert_eq!(parsed.swatch_count(), 1, "an unknown model still yields one swatch"); - let AseEntry::Swatch(sw) = &parsed.entries[0] else { panic!("expected a loose swatch") }; - let AseColor::Other { model, data } = &sw.color else { panic!("an unknown model must be kept as Other, got {:?}", sw.color) }; - assert_eq!(model, b"XYZ ", "the model tag must be preserved verbatim"); - assert_eq!(data.as_slice(), &payload, "the component bytes must be preserved verbatim"); -} - -/// A `.aco` whose version 1 and version 2 sections disagree about the number of swatches is the -/// realistic hostile shape: Photoshop writes both, and a corrupted length lands in one of them. -/// The version 2 names must still line up with the version 1 colours they belong to. -#[test] -fn aco_mismatched_section_counts_do_not_panic() { - // version 1 with two colours and no names, then a version 2 section claiming five. - let mut f = vec![0u8, 1, 0, 2]; - for _ in 0..2 { - f.extend_from_slice(&[0, 0, 0, 1, 0, 2, 0, 3, 0, 0]); - } - f.extend_from_slice(&[0, 2, 0, 5]); - for _ in 0..5 { - f.extend_from_slice(&[0, 0, 0, 1, 0, 2, 0, 3, 0, 0]); - // An .aco record has no colour-type field, so a one-character name is exactly two units: - // two u16 values, not one. - f.extend_from_slice(&2u32.to_be_bytes()); - f.extend_from_slice(&[0, 78, 0, 0]); // 'N' and its null - } - let r = catch_unwind(|| aco::parse(&f)); - assert!(r.is_ok(), "mismatched section counts must not panic"); - let parsed = r.unwrap().expect("the version 2 section is the one with names"); - assert_eq!(parsed.swatches.len(), 5, "the named section is authoritative when present"); - for sw in &parsed.swatches { - assert_eq!(sw.name, "N", "each version 2 record must keep its name beside its own colour"); - } -} - -/// Deep descriptor nesting is the one hostile shape that aborts rather than returning: a stack -/// overflow is not a panic, so `catch_unwind` cannot catch it and it would take the process down. -/// `descriptor` bounds it at `MAX_DEPTH` and its own unit test drives `Descriptor::from_bytes` -/// directly; this drives the same limit through `grd::parse`, which is the path a user's gradient -/// preset actually takes, and asserts the specific error rather than merely surviving. -/// -/// The previous version of this test built `b"null"` repeated 2000 times, which is not a descriptor -/// at all — no item counts, no `Objc` tags — so the parser rejected it on the first field and -/// nothing ever recursed. It passed for the wrong reason. -#[test] -fn grd_deeply_nested_descriptor_hits_the_depth_limit() { - use photocraft_psd::descriptor::{Descriptor, MAX_DEPTH, Value, VersionedDescriptor}; - let mut d = Descriptor::new("null"); - for _ in 0..(MAX_DEPTH + 5) { - d = Descriptor::new("null").with("in ", Value::Descriptor(d)); - } - let mut g = b"8BGR".to_vec(); - g.extend_from_slice(&5u16.to_be_bytes()); - g.extend_from_slice(&VersionedDescriptor::new(d).to_bytes()); - // This is the real assertion: nesting past the limit is refused, not recursed into. - assert!(matches!(grd::parse(&g), Err(PsdError::LimitExceeded(_))), "nesting past MAX_DEPTH must be refused through the .grd path, not recursed into"); - - // The limit discriminates rather than being a blanket refusal: a shallow nesting is accepted - // (rejected later, for holding no gradients — a different error), while a deep one is a depth - // refusal. Each wrapper adds more than one unit of depth, so the exact crossover is an - // implementation detail; what matters is that normal files pass and runaway ones are stopped. - let build = |levels: usize| { - let mut d = Descriptor::new("null"); - for _ in 0..levels { - d = Descriptor::new("null").with("in ", Value::Descriptor(d)); - } - let mut g = b"8BGR".to_vec(); - g.extend_from_slice(&5u16.to_be_bytes()); - g.extend_from_slice(&VersionedDescriptor::new(d).to_bytes()); - g - }; - assert!(!matches!(grd::parse(&build(4)), Err(PsdError::LimitExceeded(_))), "a shallow descriptor must not be refused for depth"); - assert!( - matches!(grd::parse(&build(MAX_DEPTH + 1)), Err(PsdError::LimitExceeded(_))), - "a descriptor past the depth limit must be a depth refusal, not an abort" - ); -} - -/// Every colour model's components must come back finite and inside their documented range, not -/// just RGB. The writer only emits RGB and foreground/background stops, so each model is built by -/// hand with an out-of-range or non-representable value in every component, then driven through -/// `grd::parse`. Without this, the clamps on HSB, CMYK, Gray and the L* channel — and the finite -/// guard on the Lab a*/b* channels — have no test and can be removed unnoticed. -#[test] -fn grd_every_colour_model_is_clamped_and_finite() { - use photocraft_psd::descriptor::{Descriptor, Value, VersionedDescriptor}; - - // Build a one-stop gradient whose colour is `class_id` with the given (key, value) components. - let build = |class_id: &str, comps: &[(&str, f64)]| -> Vec { - let mut colour = Descriptor::new(class_id); - for (k, v) in comps { - colour = colour.with(k, Value::Double(*v)); - } - let stop = Descriptor::new("Clrt") - .with("Clr ", Value::Descriptor(colour)) - .with("Type", Value::Enumerated { type_id: photocraft_psd::descriptor::Id::new("Clry"), value: photocraft_psd::descriptor::Id::new("UsrS") }) - .with("Lctn", Value::Integer(0)) - .with("Mdpn", Value::Integer(50)); - let grad = Descriptor::new("Grdn").with("Clrs", Value::List(vec![Value::Descriptor(stop)])); - let outer = Descriptor::new("Grdn").with("Grad", Value::Descriptor(grad)); - let file = Descriptor::new("null").with("GrdL", Value::List(vec![Value::Descriptor(outer)])); - let mut g = b"8BGR".to_vec(); - g.extend_from_slice(&5u16.to_be_bytes()); - g.extend_from_slice(&VersionedDescriptor::new(file).to_bytes()); - g - }; - - let hostile = [1e300, 3.0e38, -3.0e38, f64::NAN, f64::INFINITY, f64::NEG_INFINITY]; - for &x in &hostile { - // The exact boundary each clamp must land on, so a weaker clamp (e.g. a*/b* to -100..=100 - // instead of -128..=127, or a unit range to 0..=0.5) is caught, not just an out-of-range - // value. A NaN becomes 0.0 (the finite guard's fallback for `f`), a positive saturates to - // the top of the range, a negative to the bottom. - let unit = if x.is_nan() || x < 0.0 { 0.0f32 } else { 1.0 }; - let lab_l = if x.is_nan() || x < 0.0 { 0.0f32 } else { 100.0 }; - let lab_ab = if x.is_nan() { - 0.0f32 - } else if x > 0.0 { - 127.0 - } else { - -128.0 - }; - // RGB components are 0..=255 before the /255; the result must land in 0..=1. - let f = grd::parse(&build("RGBC", &[("Rd ", x), ("Grn ", x), ("Bl ", x)])).expect("RGB parses"); - let GrdColor::Rgb(rgb) = f[0].stops[0].color else { panic!("expected an RGB stop") }; - for v in rgb { - assert_eq!(v, unit, "RGB component for input {x} must saturate to {unit}"); - } - // HSB: hue 0..=360, saturation and brightness 0..=1. - let f = grd::parse(&build("HSBC", &[("H ", x), ("Strt", x), ("Brgh", x)])).expect("HSB parses"); - if let GrdColor::Hsb([h, sat, br]) = f[0].stops[0].color { - assert!(h.is_finite() && (0.0..=360.0).contains(&h), "hue {h} out of range for input {x}"); - assert_eq!(sat, unit, "saturation for input {x} must saturate to {unit}"); - assert_eq!(br, unit, "brightness for input {x} must saturate to {unit}"); - } else { - panic!("expected an HSB stop"); - } - // CMYK: every ink 0..=1. - let f = grd::parse(&build("CMYC", &[("Cyn ", x), ("Mgnt", x), ("Ylw ", x), ("Blck", x)])).expect("CMYK parses"); - if let GrdColor::Cmyk(c) = f[0].stops[0].color { - for v in c { - assert_eq!(v, unit, "CMYK ink for input {x} must saturate to {unit}"); - } - } else { - panic!("expected a CMYK stop"); - } - // Lab: L* 0..=100, a*/b* bounded to the CIELAB gamut -128..=127. - let f = grd::parse(&build("LbCl", &[("Lmnc", x), ("A ", x), ("B ", x)])).expect("Lab parses"); - if let GrdColor::Lab([l, a, b]) = f[0].stops[0].color { - assert_eq!(l, lab_l, "L* for input {x} must saturate to {lab_l}"); - assert_eq!(a, lab_ab, "a* for input {x} must saturate to the gamut edge {lab_ab}"); - assert_eq!(b, lab_ab, "b* for input {x} must saturate to the gamut edge {lab_ab}"); - } else { - panic!("expected a Lab stop"); - } - // Gray: 0..=1. - let f = grd::parse(&build("Grsc", &[("Gry ", x)])).expect("Gray parses"); - if let GrdColor::Gray(g) = f[0].stops[0].color { - assert_eq!(g, unit, "gray for input {x} must saturate to {unit}"); - } else { - panic!("expected a Gray stop"); - } - } -} - -/// Hue is circular, so an out-of-range hue must *wrap*, not clamp: 370 degrees is 10, not 360, and -/// -10 is 350, not 0. Clamping would hand back a different colour; this pins the wrap so a -/// regression to `clamp` is caught (both produce in-range values, so a range check alone would not). -#[test] -fn grd_hue_wraps_rather_than_clamping() { - use photocraft_psd::descriptor::{Descriptor, Id, Value, VersionedDescriptor}; - let build = |hue: f64| { - let colour = Descriptor::new("HSBC").with("H ", Value::Double(hue)).with("Strt", Value::Double(50.0)).with("Brgh", Value::Double(50.0)); - let stop = Descriptor::new("Clrt") - .with("Clr ", Value::Descriptor(colour)) - .with("Type", Value::Enumerated { type_id: Id::new("Clry"), value: Id::new("UsrS") }) - .with("Lctn", Value::Integer(0)) - .with("Mdpn", Value::Integer(50)); - let grad = Descriptor::new("Grdn").with("Clrs", Value::List(vec![Value::Descriptor(stop)])); - let outer = Descriptor::new("Grdn").with("Grad", Value::Descriptor(grad)); - let file = Descriptor::new("null").with("GrdL", Value::List(vec![Value::Descriptor(outer)])); - let mut g = b"8BGR".to_vec(); - g.extend_from_slice(&5u16.to_be_bytes()); - g.extend_from_slice(&VersionedDescriptor::new(file).to_bytes()); - g - }; - let hue_of = |bytes: &[u8]| -> f32 { - let f = grd::parse(bytes).expect("parses"); - let GrdColor::Hsb([h, _, _]) = f[0].stops[0].color else { panic!("expected an HSB stop") }; - h - }; - assert!((hue_of(&build(370.0)) - 10.0).abs() < 0.01, "370 degrees must wrap to 10, not clamp to 360"); - assert!((hue_of(&build(-10.0)) - 350.0).abs() < 0.01, "-10 degrees must wrap to 350, not clamp to 0"); - // And a value already in range is unchanged. - assert!((hue_of(&build(123.0)) - 123.0).abs() < 0.01, "an in-range hue must be unchanged"); - // Exactly 360 is in range and legal, so it is kept, not wrapped to 0 (a different position for - // anything that interpolates in HSB). Unconditional `rem_euclid(360)` would map it to 0, so this - // pins the in-range guard that keeps the boundary intact. - assert_eq!(hue_of(&build(360.0)), 360.0, "an in-range hue of exactly 360 must be preserved, not wrapped to 0"); - assert_eq!(hue_of(&build(0.0)), 0.0, "a hue of 0 must stay 0"); -} - -/// A signature-only or empty file must be a clean error, never a panic and never an -/// out-of-bounds read. -#[test] -fn empty_and_signature_only_inputs_error() { - fn no_panic_then_err(r: Result, Box>, what: &str) { - match r { - Ok(Ok(_)) => panic!("{what}: an empty file must be a clean error, not parse"), - Ok(Err(_)) => {} - Err(_) => panic!("{what}: parse panicked on an empty file"), - } - } - no_panic_then_err(catch_unwind(|| grd::parse(b"")), "grd"); - no_panic_then_err(catch_unwind(|| ase::parse(b"")), "ase"); - no_panic_then_err(catch_unwind(|| aco::parse(b"")), "aco"); - assert!(matches!(grd::parse(b"8BGR"), Err(PsdError::UnexpectedEof { .. }))); - assert!(matches!(grd::parse(b"8BG"), Err(PsdError::UnexpectedEof { .. }))); - assert!(ase::parse(b"ASEF").is_err()); - assert!(aco::parse(b"").is_err()); -} diff --git a/crates/psd/tests/it/tree.rs b/crates/psd/tests/it/tree.rs deleted file mode 100644 index ef448651fc..0000000000 --- a/crates/psd/tests/it/tree.rs +++ /dev/null @@ -1,249 +0,0 @@ -//! Layer tree reconstruction. - -use photocraft_psd::testgen; -use photocraft_psd::*; - -fn rec(name: &str, kind: Option) -> LayerRecord { - let mut r = LayerRecord { name: name.as_bytes().to_vec(), ..Default::default() }; - if let Some(k) = kind { - r.blocks.push(TaggedBlock::section_divider(k, Some(BlendMode::PassThrough), None)); - } - r -} - -fn file_with(records: Vec) -> PsdFile { - let mut f = testgen::merged_only(Version::Psd, ColorMode::Rgb, 8, Compression::Raw, 1, 1); - f.layer_info = Some(LayerInfo { layers: records, ..Default::default() }); - f -} - -fn names(f: &PsdFile, nodes: &[LayerNode]) -> String { - nodes - .iter() - .map(|n| match n { - LayerNode::Layer { index } => f.layers()[*index].name(), - LayerNode::Group { index, children, .. } => { - format!("{}[{}]", f.layers()[*index].name(), names(f, children)) - } - }) - .collect::>() - .join(",") -} - -const END: Option = Some(SectionType::BoundingDivider); -const OPEN: Option = Some(SectionType::OpenFolder); -const CLOSED: Option = Some(SectionType::ClosedFolder); - -#[test] -fn flat_layers() { - let f = file_with(vec![rec("a", None), rec("b", None), rec("c", None)]); - let t = f.layer_tree(); - assert_eq!(names(&f, &t), "a,b,c"); - assert!(t.iter().all(|n| !n.is_group())); -} - -#[test] -fn empty_file_has_empty_tree() { - let f = testgen::merged_only(Version::Psd, ColorMode::Rgb, 8, Compression::Raw, 1, 1); - assert!(f.layer_tree().is_empty()); -} - -#[test] -fn single_group() { - let f = file_with(vec![rec("bg", None), rec("", END), rec("x", None), rec("y", None), rec("G", OPEN)]); - let t = f.layer_tree(); - assert_eq!(names(&f, &t), "bg,G[x,y]"); - match &t[1] { - LayerNode::Group { index, divider, children } => { - assert_eq!(*index, 4); - assert_eq!(*divider, Some(1)); - assert_eq!(children.len(), 2); - } - _ => panic!(), - } -} - -#[test] -fn closed_group() { - let f = file_with(vec![rec("", END), rec("x", None), rec("G", CLOSED)]); - assert_eq!(names(&f, &f.layer_tree()), "G[x]"); -} - -#[test] -fn nested_groups() { - let f = file_with(vec![ - rec("", END), - rec("a", None), - rec("", END), - rec("b", None), - rec("Inner", CLOSED), - rec("c", None), - rec("Outer", OPEN), - rec("top", None), - ]); - let t = f.layer_tree(); - assert_eq!(names(&f, &t), "Outer[a,Inner[b],c],top"); - assert_eq!(t[0].count(), 5); -} - -#[test] -fn empty_group() { - let f = file_with(vec![rec("", END), rec("G", OPEN)]); - let t = f.layer_tree(); - assert_eq!(names(&f, &t), "G[]"); - assert!(t[0].children().is_empty()); -} - -#[test] -fn sibling_groups() { - let f = file_with(vec![rec("", END), rec("a", None), rec("G1", OPEN), rec("", END), rec("b", None), rec("G2", OPEN)]); - assert_eq!(names(&f, &f.layer_tree()), "G1[a],G2[b]"); -} - -#[test] -fn deeply_nested() { - let mut v = Vec::new(); - for _ in 0..50 { - v.push(rec("", END)); - } - v.push(rec("leaf", None)); - for i in 0..50 { - v.push(rec(&format!("g{i}"), OPEN)); - } - let f = file_with(v); - let t = f.layer_tree(); - assert_eq!(t.len(), 1); - assert_eq!(t[0].count(), 51); - let mut n = &t[0]; - let mut depth = 1; - while let Some(c) = n.children().first() { - n = c; - depth += 1; - } - assert_eq!(depth, 51); -} - -/// #2940: the capped tree keeps a group at the cap but not its children, so its depth is bounded -/// whatever the file nests. -#[test] -fn layer_tree_max_depth_drops_deeper_children() { - let mut v = vec![rec("", END), rec("", END), rec("", END), rec("x", None)]; - v.extend([rec("g2", OPEN), rec("g1", OPEN), rec("g0", OPEN), rec("top", None)]); - let f = file_with(v); - assert_eq!(names(&f, &f.layer_tree()), "g0[g1[g2[x]]],top"); - assert_eq!(names(&f, &f.layer_tree_max_depth(usize::MAX)), "g0[g1[g2[x]]],top"); - assert_eq!(names(&f, &f.layer_tree_max_depth(2)), "g0[g1[g2[]]],top"); - assert_eq!(names(&f, &f.layer_tree_max_depth(1)), "g0[g1[]],top"); - assert_eq!(names(&f, &f.layer_tree_max_depth(0)), "g0[],top"); -} - -fn depth(nodes: &[LayerNode]) -> usize { - // Iterative so a deep tree cannot overflow the test's stack. - let mut max = 0; - let mut todo: Vec<(&LayerNode, usize)> = nodes.iter().map(|n| (n, 1)).collect(); - while let Some((n, d)) = todo.pop() { - max = max.max(d); - todo.extend(n.children().iter().map(|c| (c, d + 1))); - } - max -} - -/// #5758: layers under a divider that is never closed are hoisted to the enclosing level, as -/// `layer_tree` does, also when the cap is shallower than the unclosed divider. -#[test] -fn layer_tree_max_depth_hoists_unclosed_divider_children() { - let f = file_with(vec![rec("a", None), rec("", END), rec("b", None), rec("c", None)]); - assert_eq!(names(&f, &f.layer_tree()), "a,b,c"); - assert_eq!(names(&f, &f.layer_tree_max_depth(1)), "a,b,c"); - assert_eq!(names(&f, &f.layer_tree_max_depth(0)), "a,b,c"); -} - -/// #5758: a closed group inside unclosed dividers sits at the depth it ends up at after hoisting, -/// so the cap keeps its node and drops only its deeper children. -#[test] -fn layer_tree_max_depth_keeps_closed_group_inside_unclosed_dividers() { - let mut v = vec![rec("", END), rec("", END)]; - v.extend([rec("", END), rec("", END), rec("x", None), rec("g1", OPEN), rec("g0", OPEN)]); - let f = file_with(v); - assert_eq!(names(&f, &f.layer_tree()), "g0[g1[x]]"); - assert_eq!(names(&f, &f.layer_tree_max_depth(1)), "g0[g1[]]"); - assert_eq!(names(&f, &f.layer_tree_max_depth(0)), "g0[]"); -} - -/// #5758: thousands of unclosed dividers around a closed chain stay bounded by the cap and keep -/// every layer the full tree has. -#[test] -fn layer_tree_max_depth_bounds_deep_unclosed_chain() { - let mut v = Vec::new(); - for i in 0..16_000 { - v.push(rec("", END)); - v.push(rec(&format!("l{i}"), None)); - } - v.extend([rec("", END), rec("", END), rec("", END), rec("x", None)]); - v.extend([rec("g2", OPEN), rec("g1", OPEN), rec("g0", OPEN)]); - let f = file_with(v); - let full = f.layer_tree(); - assert_eq!(full.len(), 16_001); - assert_eq!(depth(&full), 4); - let capped = f.layer_tree_max_depth(1); - assert!(depth(&capped) <= 2); - assert_eq!(capped.len(), 16_001); - assert_eq!(names(&f, &capped[16_000..]), "g0[g1[]]"); - assert_eq!(names(&f, &capped[..2]), "l0,l1"); -} -#[test] -fn folder_without_divider_is_empty_group() { - let f = file_with(vec![rec("a", None), rec("G", OPEN), rec("b", None)]); - assert_eq!(names(&f, &f.layer_tree()), "a,G[],b"); -} - -#[test] -fn unclosed_divider_hoists_children() { - let f = file_with(vec![rec("a", None), rec("", END), rec("b", None), rec("c", None)]); - assert_eq!(names(&f, &f.layer_tree()), "a,b,c"); -} - -#[test] -fn lsdk_is_honored() { - let mut end = rec("", None); - let mut b = TaggedBlock::section_divider(SectionType::BoundingDivider, None, None); - b.key = *b"lsdk"; - end.blocks.push(b); - let f = file_with(vec![end, rec("x", None), rec("G", OPEN)]); - assert_eq!(names(&f, &f.layer_tree()), "G[x]"); -} - -#[test] -fn other_section_type_is_layer() { - let f = file_with(vec![rec("a", Some(SectionType::Other)), rec("b", Some(SectionType::Unknown(9)))]); - assert_eq!(names(&f, &f.layer_tree()), "a,b"); -} - -#[test] -fn generated_layered_tree() { - let f = testgen::layered(Version::Psd, ColorMode::Rgb, 8, Compression::Rle); - let t = f.layer_tree(); - let outer = t.iter().find(|n| n.is_group()).expect("group"); - assert_eq!(f.layers()[outer.index()].name(), "Outer"); - assert_eq!(outer.children().len(), 2); - let inner = &outer.children()[1]; - assert!(inner.is_group()); - assert_eq!(f.layers()[inner.index()].name(), "Inner"); - assert_eq!(f.layers()[inner.index()].section_type(), SectionType::ClosedFolder); - assert_eq!(f.layers()[inner.children()[0].index()].name(), "Grüße"); -} - -#[test] -fn builder_groups_tree() { - let px = |n: usize| PixelData::Rgba8(vec![255; n * 4]); - let mut b = PsdBuilder::new(2, 2); - b.push_layer(LayerSpec::new("bg", 0, 0, 2, 2, px(4))); - b.begin_group(GroupSpec::new("A")); - b.push_layer(LayerSpec::new("a1", 0, 0, 1, 1, px(1))); - b.begin_group(GroupSpec { open: false, ..GroupSpec::new("B") }); - b.push_layer(LayerSpec::new("b1", 0, 0, 1, 1, px(1))); - b.end_group().unwrap(); - b.end_group().unwrap(); - let f = PsdFile::from_bytes(&b.to_bytes().unwrap()).unwrap(); - assert_eq!(names(&f, &f.layer_tree()), "bg,A[a1,B[b1]]"); -} diff --git a/crates/text/Cargo.toml b/crates/text/Cargo.toml index c4ef6d69f0..bf77cdbeb7 100644 --- a/crates/text/Cargo.toml +++ b/crates/text/Cargo.toml @@ -14,7 +14,7 @@ photocraft-geom = { workspace = true } photocraft-color = { workspace = true } photocraft-raster = { workspace = true } photocraft-doc = { workspace = true } -photocraft-psd = { workspace = true } +craft-psd = { workspace = true } # No `system` feature: fontique's system backend links fontconfig on Linux. We scan font # directories ourselves (native) and always register the bundled fonts (native + web). parley = { version = "0.11", default-features = false, features = ["std"] } diff --git a/crates/text/src/engine_data.rs b/crates/text/src/engine_data.rs deleted file mode 100644 index 4acd08ac50..0000000000 --- a/crates/text/src/engine_data.rs +++ /dev/null @@ -1,456 +0,0 @@ -//! Photoshop's `EngineData`: the PostScript-like text engine document stored in `TySh`. -//! -//! Syntax (from observation of real files and public notes): dictionaries `<< /Key value … >>`, -//! arrays `[ … ]`, strings `( … )` holding UTF-16BE with a `FE FF` byte-order mark and `\` -//! escapes, numbers (`12`, `-1.5`, `.5`), `true`/`false`, and `/Name` values. - -use std::fmt::Write as _; - -#[derive(Clone, Debug, PartialEq)] -pub enum Value { - Dict(Vec<(String, Value)>), - Array(Vec), - String(String), - /// A string the file stored as plain bytes, without the `FE FF` mark (one char per byte). - /// Kept apart from [`Value::String`] so it is written back as the same bytes: Photoshop - /// rejects a `Txt2` whose raw strings come back as UTF-16 (#9091). - RawString(String), - Int(i64), - Real(f64), - Bool(bool), - Name(String), -} - -impl Value { - pub fn get(&self, key: &str) -> Option<&Value> { - match self { - Value::Dict(items) => items.iter().find(|(k, _)| k == key).map(|(_, v)| v), - _ => None, - } - } - /// Follows a path of dictionary keys. - pub fn path(&self, keys: &[&str]) -> Option<&Value> { - keys.iter().try_fold(self, |v, k| v.get(k)) - } - pub fn get_mut(&mut self, key: &str) -> Option<&mut Value> { - match self { - Value::Dict(items) => items.iter_mut().find(|(k, _)| k == key).map(|(_, v)| v), - _ => None, - } - } - /// Sets (or appends) a dictionary entry. - pub fn set(&mut self, key: &str, v: Value) { - if let Value::Dict(items) = self { - match items.iter_mut().find(|(k, _)| k == key) { - Some(e) => e.1 = v, - None => items.push((key.to_string(), v)), - } - } - } - pub fn as_f64(&self) -> Option { - match self { - Value::Int(i) => Some(*i as f64), - // `inf`, `nan` and `1e999` parse as reals but are never a valid style number. - Value::Real(r) if r.is_finite() => Some(*r), - _ => None, - } - } - pub fn as_i64(&self) -> Option { - match self { - Value::Int(i) => Some(*i), - Value::Real(r) => Some(*r as i64), - _ => None, - } - } - pub fn as_bool(&self) -> Option { - match self { - Value::Bool(b) => Some(*b), - Value::Int(i) => Some(*i != 0), - _ => None, - } - } - pub fn as_str(&self) -> Option<&str> { - match self { - Value::String(s) | Value::RawString(s) | Value::Name(s) => Some(s), - _ => None, - } - } - pub fn as_array(&self) -> Option<&[Value]> { - match self { - Value::Array(a) => Some(a), - _ => None, - } - } - pub fn dict() -> Value { - Value::Dict(Vec::new()) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ParseError(pub String); - -impl std::fmt::Display for ParseError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "EngineData: {}", self.0) - } -} -impl std::error::Error for ParseError {} - -const MAX_DEPTH: usize = 64; - -struct Parser<'a> { - b: &'a [u8], - i: usize, -} - -impl Parser<'_> { - fn ws(&mut self) { - while self.i < self.b.len() && matches!(self.b[self.i], b' ' | b'\t' | b'\r' | b'\n' | 0) { - self.i += 1; - } - } - fn err(&self, m: &str) -> Result { - Err(ParseError(format!("{m} at byte {}", self.i))) - } - fn value(&mut self, depth: usize) -> Result { - if depth > MAX_DEPTH { - return self.err("nesting too deep"); - } - self.ws(); - let b = self.b; - match b.get(self.i) { - None => self.err("unexpected end"), - Some(b'<') if b.get(self.i + 1) == Some(&b'<') => { - self.i += 2; - let mut items = Vec::new(); - loop { - self.ws(); - match b.get(self.i) { - Some(b'>') if b.get(self.i + 1) == Some(&b'>') => { - self.i += 2; - return Ok(Value::Dict(items)); - } - Some(b'/') => { - let k = self.name(); - let v = self.value(depth + 1)?; - items.push((k, v)); - } - None => return self.err("unterminated dictionary"), - _ => return self.err("expected key"), - } - } - } - Some(b'[') => { - self.i += 1; - let mut items = Vec::new(); - loop { - self.ws(); - match b.get(self.i) { - Some(b']') => { - self.i += 1; - return Ok(Value::Array(items)); - } - None => return self.err("unterminated array"), - _ => items.push(self.value(depth + 1)?), - } - } - } - Some(b'(') => { - self.i += 1; - let mut raw = Vec::new(); - loop { - match b.get(self.i) { - None => return self.err("unterminated string"), - Some(b'\\') => { - if let Some(&c) = b.get(self.i + 1) { - raw.push(c); - } - self.i += 2; - } - Some(b')') => { - self.i += 1; - break; - } - Some(&c) => { - raw.push(c); - self.i += 1; - } - } - } - Ok(decode_string(&raw)) - } - Some(b'/') => Ok(Value::Name(self.name())), - Some(_) => { - let start = self.i; - while self.i < b.len() && !matches!(b[self.i], b' ' | b'\t' | b'\r' | b'\n' | b'[' | b']' | b'<' | b'>' | b'(' | b'/') { - self.i += 1; - } - let tok = std::str::from_utf8(&b[start..self.i]).unwrap_or(""); - match tok { - "true" => Ok(Value::Bool(true)), - "false" => Ok(Value::Bool(false)), - "" => { - self.i += 1; - self.err("unexpected byte") - } - t if t.contains('.') || t.contains('e') || t.contains('E') => t.parse().map(Value::Real).or_else(|_| self.err("bad number")), - t => t.parse().map(Value::Int).or_else(|_| t.parse().map(Value::Real).or_else(|_| self.err("bad token"))), - } - } - } - } - fn name(&mut self) -> String { - self.i += 1; // '/' - let start = self.i; - while self.i < self.b.len() && !matches!(self.b[self.i], b' ' | b'\t' | b'\r' | b'\n' | b'[' | b']' | b'<' | b'>' | b'(' | b'/') { - self.i += 1; - } - String::from_utf8_lossy(&self.b[start..self.i]).into_owned() - } -} - -fn decode_string(raw: &[u8]) -> Value { - if raw.len() >= 2 && raw[0] == 0xFE && raw[1] == 0xFF { - let u: Vec = raw[2..].as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]])).collect(); - Value::String(String::from_utf16_lossy(&u)) - } else { - Value::RawString(raw.iter().map(|&c| c as char).collect()) - } -} - -/// Parses an EngineData document (the top-level dictionary). -pub fn parse(data: &[u8]) -> Result { - let mut p = Parser { b: data, i: 0 }; - p.value(0) -} - -/// Serializes in Photoshop's layout (tab-indented, one key per line). -pub fn write(v: &Value) -> Vec { - let mut out = b"\n\n".to_vec(); - write_value(v, 0, &mut out); - out.push(b'\n'); - out -} - -/// Serializes `/key value` pairs without an enclosing dictionary: the layout of the document's -/// `Txt2` block (see [`crate::psd::build_txt2`]), which `parse_txt2` wraps in `<< >>` to parse. -pub fn write_bare(items: &[(String, Value)]) -> Vec { - let mut out = b"\n\n".to_vec(); - for (k, v) in items { - out.push(b'/'); - out.extend_from_slice(k.as_bytes()); - if is_container(v) { - out.push(b'\n'); - indent(&mut out, 1); - } else { - out.push(b' '); - } - write_value(v, 1, &mut out); - out.push(b'\n'); - } - out -} - -fn indent(out: &mut Vec, n: usize) { - out.extend(std::iter::repeat_n(b'\t', n)); -} - -fn is_container(v: &Value) -> bool { - matches!(v, Value::Dict(_)) || matches!(v, Value::Array(a) if a.iter().any(|x| matches!(x, Value::Dict(_) | Value::Array(_)))) -} - -fn write_value(v: &Value, depth: usize, out: &mut Vec) { - match v { - Value::Dict(items) => { - out.extend_from_slice(b"<<\n"); - for (k, v) in items { - indent(out, depth + 1); - out.push(b'/'); - out.extend_from_slice(k.as_bytes()); - if is_container(v) { - out.push(b'\n'); - indent(out, depth + 1); - } else { - out.push(b' '); - } - write_value(v, depth + 1, out); - out.push(b'\n'); - } - indent(out, depth); - out.extend_from_slice(b">>"); - } - Value::Array(items) if is_container(v) => { - out.extend_from_slice(b"[\n"); - for x in items { - indent(out, depth); - write_value(x, depth, out); - out.push(b'\n'); - } - indent(out, depth); - out.push(b']'); - } - Value::Array(items) => { - out.push(b'['); - for x in items { - out.push(b' '); - write_value(x, depth, out); - } - out.extend_from_slice(b" ]"); - } - Value::RawString(s) if s.chars().all(|c| u32::from(c) <= 0xFF) => { - out.push(b'('); - for c in s.chars() { - let c = u32::from(c) as u8; - if matches!(c, b'(' | b')' | b'\\') { - out.push(b'\\'); - } - out.push(c); - } - out.push(b')'); - } - Value::String(s) | Value::RawString(s) => { - out.push(b'('); - let mut raw = vec![0xFE, 0xFF]; - for u in s.encode_utf16() { - raw.extend_from_slice(&u.to_be_bytes()); - } - for c in raw { - if matches!(c, b'(' | b')' | b'\\') { - out.push(b'\\'); - } - out.push(c); - } - out.push(b')'); - } - Value::Int(i) => { - let _ = write!(Wrap(out), "{i}"); - } - Value::Real(r) => out.extend_from_slice(format_real(*r).as_bytes()), - Value::Bool(b) => out.extend_from_slice(if *b { b"true" } else { b"false" }), - Value::Name(n) => { - out.push(b'/'); - out.extend_from_slice(n.as_bytes()); - } - } -} - -struct Wrap<'a>(&'a mut Vec); -impl std::fmt::Write for Wrap<'_> { - fn write_str(&mut self, s: &str) -> std::fmt::Result { - self.0.extend_from_slice(s.as_bytes()); - Ok(()) - } -} - -/// Photoshop style: `1.0`, `.5`, `-.25`. -fn format_real(r: f64) -> String { - if !r.is_finite() { - return "0.0".into(); - } - if r.fract() == 0.0 && r.abs() < 1e15 { - return format!("{r:.1}"); - } - let mut s = format!("{r:.5}"); - while s.ends_with('0') { - s.pop(); - } - if matches!(s.as_str(), "0." | "-0.") { - return "0.0".into(); - } - if s.ends_with('.') { - s.push('0'); - } - if let Some(rest) = s.strip_prefix("0.") { - s = format!(".{rest}"); - } else if let Some(rest) = s.strip_prefix("-0.") { - s = format!("-.{rest}"); - } - s -} - -#[cfg(test)] -mod tests { - use super::*; - - const SAMPLE: &[u8] = b"\n\n<<\n\t/EngineDict\n\t<<\n\t\t/Editor\n\t\t<<\n\t\t\t/Text (\xFE\xFF\x00H\x00i\x00\\)\x00\r)\n\t\t>>\n\t\t/Nums [ 1.0 .5 -2 0 ]\n\t\t/Flag true\n\t\t/Kind /Roman\n\t\t/Runs [\n\t\t<<\n\t\t\t/A 1\n\t\t>>\n\t\t]\n\t>>\n>>"; - - #[test] - fn format_real_never_ends_with_bare_dot() { - assert_eq!(format_real(5.000001), "5.0"); - assert_eq!(format_real(12.000004), "12.0"); - assert_eq!(format_real(-5.000001), "-5.0"); - assert_eq!(format_real(1e20), "100000000000000000000.0"); - assert_eq!(format_real(0.5), ".5"); - assert_eq!(format_real(-0.25), "-.25"); - assert_eq!(format_real(1e-9), "0.0"); - assert_eq!(format_real(3.0), "3.0"); - } - - #[test] - fn parses_sample() { - let v = parse(SAMPLE).unwrap(); - assert_eq!(v.path(&["EngineDict", "Editor", "Text"]).and_then(Value::as_str), Some("Hi)\r")); - let nums: Vec = v.path(&["EngineDict", "Nums"]).unwrap().as_array().unwrap().iter().filter_map(Value::as_f64).collect(); - assert_eq!(nums, [1.0, 0.5, -2.0, 0.0]); - assert_eq!(v.path(&["EngineDict", "Flag"]).and_then(Value::as_bool), Some(true)); - assert_eq!(v.path(&["EngineDict", "Kind"]), Some(&Value::Name("Roman".into()))); - assert_eq!(v.path(&["EngineDict", "Runs"]).unwrap().as_array().unwrap()[0].get("A").and_then(Value::as_i64), Some(1)); - } - - #[test] - fn raw_strings_are_written_back_as_the_same_bytes() { - let v = parse(b"<< /2 (0100\\)) /3 (\xFE\xFF\x00h) >>").unwrap(); - assert_eq!(v.get("2"), Some(&Value::RawString("0100)".into()))); - assert_eq!(v.get("2").and_then(Value::as_str), Some("0100)")); - assert_eq!(v.get("3"), Some(&Value::String("h".into()))); - let bytes = write(&v); - assert!(bytes.windows(8).any(|w| w == b"(0100\\))"), "{:?}", String::from_utf8_lossy(&bytes)); - assert_eq!(parse(&bytes).unwrap(), v); - } - - #[test] - fn write_parse_roundtrip() { - let v = parse(SAMPLE).unwrap(); - let bytes = write(&v); - assert_eq!(parse(&bytes).unwrap(), v); - // Stable: writing twice gives identical bytes. - assert_eq!(write(&parse(&bytes).unwrap()), bytes); - } - - #[test] - fn reals_format_like_photoshop() { - assert_eq!(format_real(1.0), "1.0"); - assert_eq!(format_real(0.5), ".5"); - assert_eq!(format_real(-0.25), "-.25"); - assert_eq!(format_real(12.125), "12.125"); - } - - #[test] - fn tiny_reals_round_trip_as_numbers() { - for value in [0.000004, -0.000004] { - assert_eq!(format_real(value), "0.0"); - let source = Value::Dict(vec![( - "EngineDict".into(), - Value::Dict(vec![("StyleRun".into(), Value::Array(vec![Value::Dict(vec![("BaselineShift".into(), Value::Real(value))])]))]), - )]); - let encoded = write(&source); - let parsed = parse(&encoded).expect("formatted tiny real should parse"); - let baseline_shift = parsed - .path(&["EngineDict", "StyleRun"]) - .and_then(Value::as_array) - .and_then(|runs| runs.first()) - .and_then(|run| run.get("BaselineShift")) - .and_then(Value::as_f64); - assert_eq!(baseline_shift, Some(0.0)); - } - } - - #[test] - fn malformed_input_never_panics() { - for cut in 0..SAMPLE.len() { - let _ = parse(&SAMPLE[..cut]); - } - assert!(parse(b"<< /A").is_err()); - assert!(parse(&[b'['; 200]).is_err()); - } -} diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 9764be6aa5..813ffdde3f 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -22,7 +22,8 @@ pub mod cjk; mod color_glyphs; pub mod craft_fonts; mod embolden; -pub mod engine_data; +// The EngineData syntax of type layers is PSD format code: it lives in craft-psd. +pub use craft_psd::engine_data; pub mod font_class; pub mod fonts; pub mod glyphs; diff --git a/crates/text/src/psd.rs b/crates/text/src/psd.rs index 16d63c68b8..a9a5cd6973 100644 --- a/crates/text/src/psd.rs +++ b/crates/text/src/psd.rs @@ -9,13 +9,13 @@ //! Writing starts from the layer's original `TySh` (when present) and patches only the text, runs, //! paragraphs, fonts, shape and transform, so Photoshop-only settings survive an edit. +use craft_psd::descriptor::{Descriptor, Id, UnicodeString, Value as D, VersionedDescriptor}; use photocraft_color::{Color, ColorMode}; use photocraft_doc::TextLayer; use photocraft_doc::text::{ AntiAlias, Caps, CharStyle, FontBaseline, Kerning, Orientation, ParagraphRun, ParagraphStyle, TextAlign, TextRun, TextShape, TextWarp, }; use photocraft_geom::{Affine, Point}; -use photocraft_psd::descriptor::{Descriptor, Id, UnicodeString, Value as D, VersionedDescriptor}; use crate::engine_data::{self as ed, Value as E}; use crate::fonts::guess_from_postscript; diff --git a/crates/text/src/psd_styles.rs b/crates/text/src/psd_styles.rs index b28949a22f..4002aedbbd 100644 --- a/crates/text/src/psd_styles.rs +++ b/crates/text/src/psd_styles.rs @@ -245,7 +245,7 @@ pub fn write_style_sheets(tysh: &[u8], layer: &TextLayer, styles: &TextStyles, d let raw = crate::engine_data::write(&e); { let item = t.text.items.iter_mut().find(|(k, _)| k.is("EngineData"))?; - item.1 = photocraft_psd::descriptor::Value::RawData(raw) + item.1 = craft_psd::descriptor::Value::RawData(raw) } Some(write_tysh(&t)) } diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 881938091a..33fda2f45a 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -1016,7 +1016,7 @@ fn txt2_with_styles(text: &str, styles: &[(usize, String)]) -> Vec { fn with_text_index(tysh: &[u8], index: i32) -> Vec { let mut t = crate::psd::parse_tysh(tysh).unwrap(); t.text.items.retain(|(k, _)| !k.is("TextIndex")); - t.text.items.push((photocraft_psd::descriptor::Id::new("TextIndex"), photocraft_psd::descriptor::Value::Integer(index))); + t.text.items.push((craft_psd::descriptor::Id::new("TextIndex"), craft_psd::descriptor::Value::Integer(index))); crate::psd::write_tysh(&t) } @@ -1784,7 +1784,7 @@ fn psd_text_fill_is_enabled_after_editing_a_legacy_export() { remove_fill_flags(&mut data); for (key, value) in &mut legacy.text.items { if key.as_bytes() == b"EngineData" { - *value = photocraft_psd::descriptor::Value::RawData(crate::engine_data::write(&data)); + *value = craft_psd::descriptor::Value::RawData(crate::engine_data::write(&data)); } } t.psd_raw = Some(crate::psd::write_tysh(&legacy).into()); @@ -1877,7 +1877,7 @@ fn psd_engine_data_keeps_only_oldstyle_among_numeral_tags() { /// (the ink box, as Photoshop writes it) both around `anchor` in a remote local space, and a /// `transform` that cancels the remote origin. fn legacy_tysh(source: &TextLayer, transform: Affine, anchor: [f64; 2], engine: &mut TextEngine) -> Vec { - use photocraft_psd::descriptor::{Descriptor, Id, Value as D}; + use craft_psd::descriptor::{Descriptor, Id, Value as D}; let layout = engine.layout(source, 72.0); let logical = layout.bounds().unwrap().map(f64::from); let ink = crate::render::ink_bounds(&layout).unwrap(); @@ -1907,7 +1907,7 @@ fn assert_transform_near(got: Affine, want: Affine, tolerance: f64, what: &str) /// fixed for left, centered and right text through import and a TySh round trip. #[test] fn legacy_tysh_point_origin_imports_and_round_trips_on_canvas() { - use photocraft_psd::descriptor::Value as D; + use craft_psd::descriptor::Value as D; let original = Affine { m: [4.1667, 0.0, 0.0, 4.1667, -32375.0, -32887.5] }; // The file's layout origin (the anchor on the first baseline) in its local space. @@ -2578,7 +2578,7 @@ fn txt2_frame_places_point_text_at_its_anchor() { /// `tysh` with its text-space `bounds` set to `r` (Photoshop's, where it drew the text). fn with_bounds(tysh: &[u8], r: [f64; 4]) -> Vec { - use photocraft_psd::descriptor::{Descriptor, Id, Value}; + use craft_psd::descriptor::{Descriptor, Id, Value}; let mut t = crate::psd::parse_tysh(tysh).unwrap(); let mut d = Descriptor::default(); for (k, v) in ["Left", "Top ", "Rght", "Btom"].iter().zip(r) { @@ -2891,7 +2891,7 @@ fn font_metadata_survives_tysh_and_named_style_rewrites() { } for (key, value) in &mut tysh.text.items { if key.as_bytes() == b"EngineData" { - *value = photocraft_psd::descriptor::Value::RawData(crate::engine_data::write(&data)); + *value = craft_psd::descriptor::Value::RawData(crate::engine_data::write(&data)); } } let raw = crate::psd::write_tysh(&tysh); diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 10b1838ead..d0dd5977bc 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -33,7 +33,7 @@ photocraft-format = { workspace = true } # Decoding the brand mark's PNG (`brand.rs`); already linked through photocraft-io. photocraft-codecs = { workspace = true } # Reading embedded thumbnail resources (1036/1033) for recent files in Home screen. -photocraft-psd = { workspace = true } +craft-psd = { workspace = true } egui = { workspace = true } egui_extras = { version = "0.37", default-features = false, features = ["svg"] } eframe = { workspace = true, features = ["wgpu", "accesskit", "clipboard"] } @@ -76,7 +76,7 @@ egui_kittest = { workspace = true, features = ["wgpu", "snapshot", "eframe"] } photocraft-testkit = { workspace = true } # examples/perf_scenarios.rs (a 500-brush .abr for the #210 import row) and large_file_bench.rs # (the generated 2 GB PSD and 4.4 GB PSB of P20, `fixtures`). -photocraft-psd = { workspace = true, features = ["testgen"] } +craft-psd = { workspace = true, features = ["testgen"] } # Isolated, self-cleaning folders for filesystem tests (the LUT folder, #6558). tempfile = "3" diff --git a/crates/ui-egui/examples/large_file_bench.rs b/crates/ui-egui/examples/large_file_bench.rs index fcf1228b26..fbdcd1642e 100644 --- a/crates/ui-egui/examples/large_file_bench.rs +++ b/crates/ui-egui/examples/large_file_bench.rs @@ -9,7 +9,7 @@ //! cargo run --release -p photocraft-ui-egui --example large_file_bench -- [--reps 3] [--only psd|psb] [--cache DIR] [--clean] [--json out.json] //! ``` //! -//! The fixtures (`photocraft_psd::fixtures`, about 6.4 GB together) are generated once into +//! The fixtures (`craft_psd::fixtures`, about 6.4 GB together) are generated once into //! `--cache` (default: `$PHOTOCRAFT_FIXTURE_DIR`, else `fixtures/` in the target directory next //! to this binary) and kept for the next run; `--clean` removes them afterwards. Each row also //! records the peak resident memory of its opens. Needs about 30 GB of memory for the PSB. @@ -17,8 +17,8 @@ use std::path::PathBuf; use std::time::{Duration, Instant}; +use craft_psd::fixtures::{PSB_4GB, PSD_2GB, Streamed}; use photocraft_engine::Session; -use photocraft_psd::fixtures::{PSB_4GB, PSD_2GB, Streamed}; use photocraft_testkit::perf::{RssSampler, row}; use serde_json::json; diff --git a/crates/ui-egui/examples/perf_scenarios.rs b/crates/ui-egui/examples/perf_scenarios.rs index 1f25292897..33dfb0eda3 100644 --- a/crates/ui-egui/examples/perf_scenarios.rs +++ b/crates/ui-egui/examples/perf_scenarios.rs @@ -1178,7 +1178,7 @@ fn background_job_scenarios(b: &mut Bench, sz: &Sizes) { } let brushes = "import 500 brushes: longest UI-thread step"; if b.wanted(brushes) { - use photocraft_psd::abr::{AbrSample, LegacyBrush, LegacyTip, write_v12}; + use craft_psd::abr::{AbrSample, LegacyBrush, LegacyTip, write_v12}; let list: Vec = (0..500) .map(|i| { let side = 40 + (i % 60) as u32; diff --git a/crates/ui-egui/src/dialogs/adjust_editors/ui_tests.rs b/crates/ui-egui/src/dialogs/adjust_editors/ui_tests.rs index 81b5dba8cb..be8cc729ab 100644 --- a/crates/ui-egui/src/dialogs/adjust_editors/ui_tests.rs +++ b/crates/ui-egui/src/dialogs/adjust_editors/ui_tests.rs @@ -809,13 +809,13 @@ fn import_gradients_from_picker(h: &mut Harness<'_, PhotocraftApp>) { #[test] fn gradient_map_picker_imports_grd_without_editing_the_map() { - use photocraft_psd::grd::{GrdColor, GrdGradient, GrdStop}; + use craft_psd::grd::{GrdColor, GrdGradient, GrdStop}; let mut h = app_harness("gradientMap", "rgb", 16); h.state_mut().session.presets.gradients.truncate(1); let before = layer(&h); let history = h.state().session.active().unwrap().history.past_len(); let tool = h.state().session.presets.gradient.clone(); - let bytes = photocraft_psd::grd::write(&[GrdGradient { + let bytes = craft_psd::grd::write(&[GrdGradient { name: "Imported Teal".into(), stops: vec![ GrdStop { location: 0.0, midpoint: 0.5, color: GrdColor::Rgb([0.0, 0.5, 0.5]) }, @@ -851,10 +851,7 @@ fn gradient_map_picker_import_cancel_and_errors_preserve_state() { None, Some(FileDialogAnswer::Contents("broken.grd".into(), b"invalid".to_vec())), Some(FileDialogAnswer::Contents("old.grd".into(), b"8BGR\0\x03".to_vec())), - Some(FileDialogAnswer::Contents( - "noise.grd".into(), - photocraft_psd::grd::write(&[photocraft_psd::grd::GrdGradient { noise: true, ..Default::default() }]), - )), + Some(FileDialogAnswer::Contents("noise.grd".into(), craft_psd::grd::write(&[craft_psd::grd::GrdGradient { noise: true, ..Default::default() }]))), Some(FileDialogAnswer::Contents("wrong.abr".into(), Vec::new())), Some(FileDialogAnswer::Paths(vec!["/nonexistent/photocraft-gradients.grd".into()])), Some(FileDialogAnswer::Unavailable("dialog unavailable".into())), diff --git a/crates/ui-egui/src/dialogs/preset_group_prompt/tests.rs b/crates/ui-egui/src/dialogs/preset_group_prompt/tests.rs index 2f0839c7be..7a7ace0079 100644 --- a/crates/ui-egui/src/dialogs/preset_group_prompt/tests.rs +++ b/crates/ui-egui/src/dialogs/preset_group_prompt/tests.rs @@ -48,7 +48,7 @@ impl Fixture { fn new(kind: Kind) -> Self { let (name, stem, bytes) = match kind { Kind::Brushes => { - use photocraft_psd::abr::{LegacyBrush, LegacyTip, write_v12}; + use craft_psd::abr::{LegacyBrush, LegacyTip, write_v12}; let brush = |diameter| LegacyBrush { name: String::new(), spacing: 25, @@ -58,7 +58,7 @@ impl Fixture { ("Kit.abr", "Kit", write_v12(2, &[brush(9), brush(17)], true).unwrap()) } Kind::Gradients => { - use photocraft_psd::grd::{GrdColor, GrdGradient, GrdStop, write}; + use craft_psd::grd::{GrdColor, GrdGradient, GrdStop, write}; let gradient = |name: &str, rgb| GrdGradient { name: name.into(), stops: vec![ diff --git a/crates/ui-egui/src/home_recent_ui.rs b/crates/ui-egui/src/home_recent_ui.rs index 0c0a770276..2cd4d5d2b4 100644 --- a/crates/ui-egui/src/home_recent_ui.rs +++ b/crates/ui-egui/src/home_recent_ui.rs @@ -180,14 +180,14 @@ fn read_psd_thumbnail_bounded(path: &Path, file_len: u64) -> Option if res_len > 0 && res_len <= 32 * 1024 * 1024 && (res_len as u64) <= file_len { let mut res_bytes = vec![0u8; res_len as usize]; if file.read_exact(&mut res_bytes).is_ok() - && let Ok((resources, _)) = photocraft_psd::tiff::resources_from_bytes(&res_bytes) + && let Ok((resources, _)) = craft_psd::tiff::resources_from_bytes(&res_bytes) { let decode_opts = photocraft_codecs::DecodeOptions { limits: photocraft_codecs::Limits { max_width: 4096, max_height: 4096, max_pixels: 4096 * 4096, max_alloc: 4096 * 4096 * 4 }, ..Default::default() }; for r in &resources { - if (r.id == photocraft_psd::resources::ids::THUMBNAIL || r.id == photocraft_psd::resources::ids::THUMBNAIL_PS4) + if (r.id == craft_psd::resources::ids::THUMBNAIL || r.id == craft_psd::resources::ids::THUMBNAIL_PS4) && let Some(jfif) = r.data.get(28..) && let Ok(img) = photocraft_codecs::decode_with(jfif, &decode_opts) { @@ -227,7 +227,7 @@ fn read_psd_thumbnail_bounded(path: &Path, file_len: u64) -> Option // Fourth, within budget and safe dimensions: read file and decode merged composite let bytes = std::fs::read(path).ok()?; - if let Ok(file) = photocraft_psd::PsdFile::from_bytes(&bytes) + if let Ok(file) = craft_psd::PsdFile::from_bytes(&bytes) && let Ok(comp) = photocraft_io::merged_composite(&file) { let w = file.header.width; @@ -1244,23 +1244,23 @@ mod tests { #[cfg(not(target_arch = "wasm32"))] #[test] fn recent_thumbnail_skips_composite_for_oversized_psd_without_preview() { - let temp_dir = std::env::temp_dir().join(format!("photocraft_psd_bound_test_{}", std::process::id())); + let temp_dir = std::env::temp_dir().join(format!("craft_psd_bound_test_{}", std::process::id())); let _ = std::fs::create_dir_all(&temp_dir); let file_path = temp_dir.join("oversized.psd"); let path_str = file_path.to_string_lossy().into_owned(); // Create an oversized PSD (3000 x 3000) with no thumbnail resource - let header = photocraft_psd::Header::new(photocraft_psd::Version::Psd, 3000, 3000, 3, 8, photocraft_psd::ColorMode::Rgb); - let file = photocraft_psd::PsdFile { + let header = craft_psd::Header::new(craft_psd::Version::Psd, 3000, 3000, 3, 8, craft_psd::ColorMode::Rgb); + let file = craft_psd::PsdFile { header, color_mode_data: Vec::new(), resources: Vec::new(), layer_info: None, - layer_info_placement: photocraft_psd::LayerInfoPlacement::Section, + layer_info_placement: craft_psd::LayerInfoPlacement::Section, global_layer_mask: None, global_blocks: Vec::new(), layer_mask_trailing: Vec::new(), - image_data: photocraft_psd::ImageData { compression: photocraft_psd::Compression::Raw, data: Vec::new(), stored: None }, + image_data: craft_psd::ImageData { compression: craft_psd::Compression::Raw, data: Vec::new(), stored: None }, write_as_psb: false, }; let mut bytes = file.to_bytes().unwrap(); @@ -1279,23 +1279,23 @@ mod tests { #[cfg(not(target_arch = "wasm32"))] #[test] fn recent_thumbnail_skips_composite_for_high_channel_psd_without_preview() { - let temp_dir = std::env::temp_dir().join(format!("photocraft_psd_channels_test_{}", std::process::id())); + let temp_dir = std::env::temp_dir().join(format!("craft_psd_channels_test_{}", std::process::id())); let _ = std::fs::create_dir_all(&temp_dir); let file_path = temp_dir.join("high_channels.psd"); let path_str = file_path.to_string_lossy().into_owned(); // 2000 x 2000 with 56 channels: decoded uncompressed size = 2000 * 2000 * 56 = 224 MB (> 64 MB cap) - let header = photocraft_psd::Header::new(photocraft_psd::Version::Psd, 2000, 2000, 56, 8, photocraft_psd::ColorMode::Rgb); - let file = photocraft_psd::PsdFile { + let header = craft_psd::Header::new(craft_psd::Version::Psd, 2000, 2000, 56, 8, craft_psd::ColorMode::Rgb); + let file = craft_psd::PsdFile { header, color_mode_data: Vec::new(), resources: Vec::new(), layer_info: None, - layer_info_placement: photocraft_psd::LayerInfoPlacement::Section, + layer_info_placement: craft_psd::LayerInfoPlacement::Section, global_layer_mask: None, global_blocks: Vec::new(), layer_mask_trailing: Vec::new(), - image_data: photocraft_psd::ImageData { compression: photocraft_psd::Compression::Raw, data: Vec::new(), stored: None }, + image_data: craft_psd::ImageData { compression: craft_psd::Compression::Raw, data: Vec::new(), stored: None }, write_as_psb: false, }; let bytes = file.to_bytes().unwrap(); @@ -1310,7 +1310,7 @@ mod tests { #[cfg(not(target_arch = "wasm32"))] #[test] fn recent_thumbnail_extracts_embedded_preview_from_large_psd_without_reading_payload() { - let temp_dir = std::env::temp_dir().join(format!("photocraft_psd_large_preview_test_{}", std::process::id())); + let temp_dir = std::env::temp_dir().join(format!("craft_psd_large_preview_test_{}", std::process::id())); let _ = std::fs::create_dir_all(&temp_dir); let file_path = temp_dir.join("large_payload.psd"); let path_str = file_path.to_string_lossy().into_owned(); @@ -1329,19 +1329,19 @@ mod tests { res_data[4..8].copy_from_slice(&512u32.to_be_bytes()); // width: 512 res_data[8..12].copy_from_slice(&512u32.to_be_bytes()); // height: 512 res_data.extend_from_slice(&jfif); - let resource = photocraft_psd::resources::ImageResource::new(photocraft_psd::resources::ids::THUMBNAIL, res_data); + let resource = craft_psd::resources::ImageResource::new(craft_psd::resources::ids::THUMBNAIL, res_data); - let header = photocraft_psd::Header::new(photocraft_psd::Version::Psd, 400, 400, 3, 8, photocraft_psd::ColorMode::Rgb); - let file = photocraft_psd::PsdFile { + let header = craft_psd::Header::new(craft_psd::Version::Psd, 400, 400, 3, 8, craft_psd::ColorMode::Rgb); + let file = craft_psd::PsdFile { header, color_mode_data: Vec::new(), resources: vec![resource], layer_info: None, - layer_info_placement: photocraft_psd::LayerInfoPlacement::Section, + layer_info_placement: craft_psd::LayerInfoPlacement::Section, global_layer_mask: None, global_blocks: Vec::new(), layer_mask_trailing: Vec::new(), - image_data: photocraft_psd::ImageData { compression: photocraft_psd::Compression::Raw, data: Vec::new(), stored: None }, + image_data: craft_psd::ImageData { compression: craft_psd::Compression::Raw, data: Vec::new(), stored: None }, write_as_psb: false, }; let mut bytes = file.to_bytes().unwrap(); diff --git a/crates/ui-egui/src/panels/actions/tests.rs b/crates/ui-egui/src/panels/actions/tests.rs index 4755a97321..b9ca5b0d8a 100644 --- a/crates/ui-egui/src/panels/actions/tests.rs +++ b/crates/ui-egui/src/panels/actions/tests.rs @@ -2,9 +2,9 @@ use super::*; /// A small valid Photoshop set, generated without external action files. fn action_set_bytes(set: &str, action: &str) -> Vec { - use photocraft_psd::atn::{AtnAction, AtnEvent, AtnFile, AtnItem}; - use photocraft_psd::descriptor::UnicodeString; - photocraft_psd::atn::write(&AtnFile { + use craft_psd::atn::{AtnAction, AtnEvent, AtnFile, AtnItem}; + use craft_psd::descriptor::UnicodeString; + craft_psd::atn::write(&AtnFile { name: UnicodeString::new_nul(set), actions: vec![AtnAction { name: UnicodeString::new_nul(action), @@ -247,9 +247,9 @@ fn bulk_load_keeps_selection_order_and_earlier_warnings_and_errors() { let first = dir.path().join("z-first.atn"); let broken = dir.path().join("broken.atn"); let last = dir.path().join("a-last.atn"); - let mut unsupported = photocraft_psd::atn::parse(&action_set_bytes("Same set", "Old")).unwrap(); - unsupported.actions[0].items[0].event = photocraft_psd::atn::AtnEvent::Code(*b"Xxxx"); - std::fs::write(&first, photocraft_psd::atn::write(&unsupported).unwrap()).unwrap(); + let mut unsupported = craft_psd::atn::parse(&action_set_bytes("Same set", "Old")).unwrap(); + unsupported.actions[0].items[0].event = craft_psd::atn::AtnEvent::Code(*b"Xxxx"); + std::fs::write(&first, craft_psd::atn::write(&unsupported).unwrap()).unwrap(); std::fs::write(&broken, b"not an action file").unwrap(); std::fs::write(&last, action_set_bytes("Same set", "Replacement")).unwrap(); let paths = [first, broken, last].map(|p| p.to_string_lossy().into_owned()).to_vec(); @@ -282,9 +282,9 @@ fn bulk_load_status(app: &mut PhotocraftApp, files: Vec<(String, Vec)>) -> S /// what the panel holds afterwards and says which set was replaced. fn bulk_load_summary_counts_a_replaced_set_once() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); - let mut a = photocraft_psd::atn::parse(&action_set_bytes("Set 1", "A")).unwrap(); - a.actions[0].items[0].event = photocraft_psd::atn::AtnEvent::Code(*b"Xxxx"); - let files = vec![("a.atn".into(), photocraft_psd::atn::write(&a).unwrap()), ("b.atn".into(), action_set_bytes("Set 1", "B"))]; + let mut a = craft_psd::atn::parse(&action_set_bytes("Set 1", "A")).unwrap(); + a.actions[0].items[0].event = craft_psd::atn::AtnEvent::Code(*b"Xxxx"); + let files = vec![("a.atn".into(), craft_psd::atn::write(&a).unwrap()), ("b.atn".into(), action_set_bytes("Set 1", "B"))]; let status = bulk_load_status(&mut app, files); assert_eq!(app.session.actions.list.iter().map(|a| a.name.as_str()).collect::>(), ["B"]); assert!(!app.ui.status_error, "{status}"); @@ -934,8 +934,8 @@ fn recorded_save_never_flattens_layers_over_a_flat_file() { /// Photoshop's "save in directory", `out` itself) and plays it on each PSD in `sources`. /// Returns the played results, whether any PSD changed on disk and the files in `out`. fn play_imported_save_as(dest: &str, in_folder: bool, sources: &[&str]) -> (Vec, bool, Vec) { - use photocraft_psd::atn::{AtnAction, AtnEvent, AtnFile, AtnItem}; - use photocraft_psd::descriptor::{Descriptor, UnicodeString, Value as D}; + use craft_psd::atn::{AtnAction, AtnEvent, AtnFile, AtnItem}; + use craft_psd::descriptor::{Descriptor, UnicodeString, Value as D}; let dir = std::env::temp_dir().join(format!("photocraft-atn-save-as-{dest}-{in_folder}-{}", std::process::id())); let out = dir.join("out"); std::fs::create_dir_all(&out).unwrap(); @@ -977,7 +977,7 @@ fn play_imported_save_as(dest: &str, in_folder: bool, sources: &[&str]) -> (Vec< app.save_as(Some(src.clone())).unwrap(); before.push(std::fs::read(src).unwrap()); } - app.run("actions.load", json!({"data": photocraft_engine::paint::tile::b64_encode(&photocraft_psd::atn::write(&set).unwrap())})).unwrap(); + app.run("actions.load", json!({"data": photocraft_engine::paint::tile::b64_encode(&craft_psd::atn::write(&set).unwrap())})).unwrap(); let mut played = Vec::new(); for src in &sources { app.open_path(src).unwrap(); @@ -2965,8 +2965,8 @@ fn button_mode_plays_the_clicked_action_and_hides_its_steps() { /// document (#7367). #[test] fn save_hook_closing_the_target_stops_an_imported_close_with_save() { - use photocraft_psd::atn::{self, AtnEvent, AtnItem}; - use photocraft_psd::descriptor::{Descriptor, Id, Value as D}; + use craft_psd::atn::{self, AtnEvent, AtnItem}; + use craft_psd::descriptor::{Descriptor, Id, Value as D}; use std::{cell::RefCell, rc::Rc}; // A one-step .atn: Close (`Cls `) with Save: Yes. let seed = Action::new("Seed", vec![("layer.new.layer".into(), json!({}))]); @@ -3033,9 +3033,9 @@ fn save_hook_closing_the_target_stops_an_imported_close_with_save() { /// one in a set a later file replaced (CodeRabbit on #10776). fn bulk_load_summary_counts_unsupported_steps_of_surviving_sets() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); - let mut a = photocraft_psd::atn::parse(&action_set_bytes("Set A", "A")).unwrap(); - a.actions[0].items[0].event = photocraft_psd::atn::AtnEvent::Code(*b"Xxxx"); - let files = vec![("a.atn".into(), photocraft_psd::atn::write(&a).unwrap()), ("b.atn".into(), action_set_bytes("Set B", "B"))]; + let mut a = craft_psd::atn::parse(&action_set_bytes("Set A", "A")).unwrap(); + a.actions[0].items[0].event = craft_psd::atn::AtnEvent::Code(*b"Xxxx"); + let files = vec![("a.atn".into(), craft_psd::atn::write(&a).unwrap()), ("b.atn".into(), action_set_bytes("Set B", "B"))]; let status = bulk_load_status(&mut app, files); assert!(status.contains("1 step has no PhotoCraft equivalent yet"), "{status}"); assert!(!app.ui.status_error, "{status}"); diff --git a/crates/ui-egui/src/panels/swatches_ui.rs b/crates/ui-egui/src/panels/swatches_ui.rs index 09420d4944..6a16751f8f 100644 --- a/crates/ui-egui/src/panels/swatches_ui.rs +++ b/crates/ui-egui/src/panels/swatches_ui.rs @@ -288,7 +288,7 @@ mod tests { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); let mut colors = vec![[10u8, 20, 30]; 256]; colors[0] = [0, 1, 5]; - let bytes = photocraft_psd::act::write(&colors, None, None).unwrap(); + let bytes = craft_psd::act::write(&colors, None, None).unwrap(); let r = import_bytes(&mut app, "Palette.act", &bytes, true).unwrap(); assert_eq!((r["format"].as_str(), r["count"].as_u64()), (Some("act"), Some(256))); } @@ -456,9 +456,7 @@ mod tests { type Written = Arc)>>>; let written: Written = Arc::default(); let w = written.clone(); - let aco = - photocraft_psd::aco::write(&[photocraft_psd::aco::AcoSwatch { name: "Teal".into(), color: photocraft_psd::aco::AcoColor::Rgb([0, 32768, 32768]) }]) - .unwrap(); + let aco = craft_psd::aco::write(&[craft_psd::aco::AcoSwatch { name: "Teal".into(), color: craft_psd::aco::AcoColor::Rgb([0, 32768, 32768]) }]).unwrap(); use crate::file_dialog::FileDialogAnswer; let answers = vec![ Some(FileDialogAnswer::Contents("Ocean.aco".into(), aco.clone())), diff --git a/crates/ui-egui/src/preset_files_ui.rs b/crates/ui-egui/src/preset_files_ui.rs index 5ea7b04615..6ba6aee6ca 100644 --- a/crates/ui-egui/src/preset_files_ui.rs +++ b/crates/ui-egui/src/preset_files_ui.rs @@ -181,7 +181,7 @@ mod tests { /// New Group (Enter) keeps it, Cancel imports nothing, Replace deletes it (#10640). #[test] fn reimporting_a_brush_file_asks_before_touching_its_group() { - use photocraft_psd::abr::{LegacyBrush, LegacyTip, write_v12}; + use craft_psd::abr::{LegacyBrush, LegacyTip, write_v12}; let round = |name: &str| LegacyBrush { name: name.into(), spacing: 25, @@ -240,7 +240,7 @@ mod tests { /// Import as New Group adds "Fire (2)", Replace swaps "Fire" alone (#10640). #[test] fn reimporting_a_gradient_file_asks_before_touching_its_group() { - use photocraft_psd::grd::{GrdColor, GrdGradient, GrdStop, write}; + use craft_psd::grd::{GrdColor, GrdGradient, GrdStop, write}; let stop = |location: f32, color| GrdStop { location, midpoint: 0.5, color }; let ember = GrdGradient { name: "Ember".into(), stops: vec![stop(0.0, GrdColor::Foreground), stop(1.0, GrdColor::Rgb([1.0, 0.0, 0.0]))], ..Default::default() }; diff --git a/crates/ui-egui/src/psd_open_tests.rs b/crates/ui-egui/src/psd_open_tests.rs index d09d91e9d5..5c7cc44950 100644 --- a/crates/ui-egui/src/psd_open_tests.rs +++ b/crates/ui-egui/src/psd_open_tests.rs @@ -161,9 +161,9 @@ fn cancelling_one_damaged_open_does_not_open_the_next_damaged_file_without_conse fn damage_confirmation_precedes_the_profile_question() { let mut app = app(); app.run("edit.colorSettings", json!({"askOnMismatch": true, "policyRgb": "preserve"})).unwrap(); - let (mut file, _) = photocraft_psd::PsdFile::from_bytes_and_merged(&recovery_psd::make_psd(true), |_| false).unwrap(); - file.resources.push(photocraft_psd::ImageResource::new( - photocraft_psd::resources::ids::ICC_PROFILE, + let (mut file, _) = craft_psd::PsdFile::from_bytes_and_merged(&recovery_psd::make_psd(true), |_| false).unwrap(); + file.resources.push(craft_psd::ImageResource::new( + craft_psd::resources::ids::ICC_PROFILE, photocraft_cms::Builtin::from_id("adobe-rgb-compat").unwrap().profile().to_bytes().to_vec(), )); app.open_bytes("profile.psd", &file.to_bytes().unwrap()).unwrap(); diff --git a/docs/architecture.md b/docs/architecture.md index 603fd95f69..ce0547a379 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -124,7 +124,7 @@ photocraft/ │ ├─ gpu/ photocraft-gpu wgpu backend for the DrawOp plan + GPU kernels (WGSL), residency cache, memory budget │ ├─ viewport/ photocraft-viewport camera (zoom/pan/rotate), visible tiles/mips, renders a doc into a wgpu texture; toolkit-agnostic │ │ ── I/O ── -│ ├─ psd/ photocraft-psd PSD/PSB read + write; its OWN format-level model; depends on nothing in this workspace +│ ├─ psd/ craft-psd PSD/PSB read + write; its OWN format-level model; depends on nothing in this workspace │ ├─ adobe-assets/ photocraft-adobe-assets .abr .asl .aco/.ase .grd .pat .csh .atn .cube/.3dl, ACR .xmp presets (standalone, like psd) │ ├─ codecs/ photocraft-codecs flat raster decode/encode at format-supported depths; AVIF encode only (feature `avif`), HEIF/HEIC decode only (feature `heif`); no JPEG XL │ ├─ affinity/ photocraft-affinity bounded native Affinity reader: archive, object stream, layers, vectors, text, pixels (standalone; read only) @@ -158,7 +158,7 @@ photocraft/ **Crate granularity:** start with the crates above. Split `algo` into `-adjust`, `-filters`, `-select`, `-inpaint` and `-warp` once any module passes about 10k lines, or once compile times hurt. Its internal module boundaries should already follow those lines. -**Naming:** directories are short and packages are prefixed `photocraft-`. `photocraft-psd` gets a neutral, publishable name (such as `psd-rw`) if we release it to crates.io. +**Naming:** directories are short and packages are prefixed `photocraft-`. `craft-psd` gets a neutral, publishable name (such as `psd-rw`) if we release it to crates.io. --- @@ -179,7 +179,7 @@ photocraft/ 1. A crate may depend only on crates in **lower** layers. No cycles and no sideways dependencies, except where listed. 2. **Nothing below L6 may depend on any UI toolkit, winit, or a `platform` implementation.** Platform services reach the engine through traits defined in `engine` (or in `platform`'s trait-only core), and are injected at startup. -3. **`photocraft-psd` depends on no workspace crate.** The doc ⇄ PSD mapping lives in `io`. This keeps the PSD crate publishable and reusable by other projects. The same holds for every standalone crate; `STANDALONE_EXCEPTIONS` in `xtask/src/layers.rs` is empty since HEIF decoding moved to craft-libs' `craft-heif`. +3. **`craft-psd` depends on no workspace crate.** The doc ⇄ PSD mapping lives in `io`. This keeps the PSD crate publishable and reusable by other projects. The same holds for every standalone crate; `STANDALONE_EXCEPTIONS` in `xtask/src/layers.rs` is empty since HEIF decoding moved to craft-libs' `craft-heif`. 4. **wasm gate:** every crate in L0–L5 (except feature-gated native backends) must build for `wasm32-unknown-unknown`. CI runs `cargo build -p photocraft-engine --target wasm32-unknown-unknown --no-default-features --features web`. 5. `gpu` is optional for `engine`. Engine features are `gpu` (default on) and `cpu-only`, and `cpu-only` builds are what the headless CLI and CI tests use. 6. **C dependencies** (optional LibRaw) only behind features, only in `codecs`, `raw` or `io`, and never on by default for the web target. @@ -359,7 +359,7 @@ Both backends consume the same plan. This is the only place that encodes Photosh --- -## 8. `photocraft-psd`: standalone PSD/PSB crate +## 8. `craft-psd`: standalone PSD/PSB crate **Scope:** faithful, format-level read and write of PSD (v1) and PSB (v2, 64-bit lengths). It has no dependency on our document model. diff --git a/docs/atn.md b/docs/atn.md index 4ff8f5eaec..f848083c71 100644 --- a/docs/atn.md +++ b/docs/atn.md @@ -3,7 +3,7 @@ PhotoCraft loads and saves action sets in the reference application's `.atn` format: Load Actions and Save Actions in the Actions panel menu, `actions.load` / `actions.save` for agents, and `photocraft batch --actions set.atn` on the command line. Reading and writing the file lives in -`photocraft-psd` (`crates/psd/src/atn.rs`, version 16 sets, exact round trip); the mapping between the +`craft-psd` (`crates/psd/src/atn.rs`, version 16 sets, exact round trip); the mapping between the application's events and PhotoCraft commands lives in `photocraft-engine` (`crates/engine/src/cmd/atn_cmds/`, table `EVENTS` in `events.rs`). diff --git a/docs/development.md b/docs/development.md index d95484af7d..27bf3981cf 100644 --- a/docs/development.md +++ b/docs/development.md @@ -772,19 +772,19 @@ so a following `ui.inspect` observes their effect. ## Photoshop Actions (.atn) -`photocraft-psd` reads Photoshop Actions files (`atn`, versions 12 and 16) and writes version 16 +`craft-psd` reads Photoshop Actions files (`atn`, versions 12 and 16) and writes version 16 (Photoshop 6 and later); `photocraft-engine`'s `atn_cmds` maps their events onto commands (`actions.load`, `actions.save`; the Actions panel menu's Load/Save Actions…; `photocraft-cli batch --actions set.atn`). To see what a set holds (event ids, panel names, the parameter keys Photoshop recorded) when adding an event to the table: ```sh -cargo run -p photocraft-psd --example atn_dump -- "Default Actions.atn" -cargo run -p photocraft-psd --example atn_roundtrip -- "Default Actions.atn" # parse, write, diff +cargo run -p craft-psd --example atn_dump -- "Default Actions.atn" +cargo run -p craft-psd --example atn_roundtrip -- "Default Actions.atn" # parse, write, diff ``` The real-file oracles are `corpus/atn/` (16 sets from seven public repositories, see *Test -corpora*): `photocraft-psd`'s corpus test parses every file and rewrites it byte for byte; +corpora*): `craft-psd`'s corpus test parses every file and rewrites it byte for byte; `photocraft-engine`'s loads, saves and reloads every set and caps the steps that still have no PhotoCraft equivalent. @@ -809,7 +809,7 @@ recorded rectangle/preset are retained for saving, without resizing the target. `crates/psd/tests/it/atn_contract.rs` checks the parser's public API against small, independently encoded version-16 fixtures (the version-12 ones are read by `atn_v12::`) in `crates/psd/tests/fixtures/atn/` (provenance and checksums in -its README): `cargo test -p photocraft-psd --test it atn_contract::`. +its README): `cargo test -p craft-psd --test it atn_contract::`. Recorded layer indexes and ordinals resolve against the full stack, including collapsed children and hidden group-end positions. Indexed moves use the stack before the move. Recorded document indexes are one-based opening order; a native numeric `document` parameter remains a zero-based @@ -885,7 +885,7 @@ physical RAM (excluding swap) and, where measurable, system commit headroom: - These budgets apply to an import's **aggregate peak estimate**, not to one allocation: a peak may sum many layer, mask, merged and workspace buffers. Each individual buffer must still fit the target's layout limit (`isize::MAX`) and the decoder's own per-buffer allowance - (`photocraft-psd`'s `MAX_DECODED_BYTES`, `photocraft-codecs`' `Limits::max_alloc`), which is + (`craft-psd`'s `MAX_DECODED_BYTES`, `photocraft-codecs`' `Limits::max_alloc`), which is enforced by fallible allocation or an explicit cap where that buffer is created. A 64-bit native process has no measurable aggregate address-space cap, so admission relies on explicit approval plus those per-buffer checks; 32-bit native uses a 2 GiB project cap @@ -1051,7 +1051,7 @@ scripts/fetch-corpus.sh # the same as cargo xtask corpus --all **Opt-in locally, strict when opted in, always in CI.** -- The corpus tests sit behind the `corpus` cargo feature of `photocraft-psd`, `photocraft-codecs`, +- The corpus tests sit behind the `corpus` cargo feature of `craft-psd`, `photocraft-codecs`, `photocraft-io` and `photocraft-engine`, so plain `cargo test` neither compiles nor needs them. The HEIF ones also need the `heif` feature of `photocraft-codecs`/`photocraft-io` (test-corpus turns it on). diff --git a/docs/scorecard.md b/docs/scorecard.md index b7fb8babd4..f9ea921262 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -12,7 +12,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | [Workspace and UI](#workspace-and-ui) | 15 | 6 | 2 | settings that do nothing: 8 of 199 (target 0) | | [Type](#type) | 5 | 5 | 0 | | | [Automation](#automation) | 5 | 4 | 2 | 664 of 664 menu items live (100.0%) | -| [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 29 of 29 crates; panic_hunt present | +| [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 28 of 28 crates; panic_hunt present | | [Distribution](#distribution) | 5 | 5 | 1 | | | [Open bugs](#open-bugs) | 31 | 1 | 0 | | @@ -277,7 +277,7 @@ Menu wiring (`docs/parity-checklist.md`): 664 of 664 menu items live (100.0%). T ## Reliability -Never-crash attribute on 29 of 29 crate roots. +Never-crash attribute on 28 of 28 crate roots. | Id | Target | Status | Issue | Note | |---|---|---|---|---| diff --git a/perf/budgets.toml b/perf/budgets.toml index 0d3d665e2d..7176d1daa8 100644 --- a/perf/budgets.toml +++ b/perf/budgets.toml @@ -303,7 +303,7 @@ bench = "perf_scenarios" row = "open 20 MP layered PSD (decode + first composite)" budget_ms = 3000 -# P20/P63: generated fixtures (`photocraft_psd::fixtures`, #5107); the row checks a pixel, so a +# P20/P63: generated fixtures (`craft_psd::fixtures`, #5107); the row checks a pixel, so a # blank document fails it. No time budget yet: the target is the behaviour (progress, editable). [[scenario]] id = "P20" diff --git a/xtask/src/corpus.rs b/xtask/src/corpus.rs index 866c354c29..23df1eb898 100644 --- a/xtask/src/corpus.rs +++ b/xtask/src/corpus.rs @@ -15,7 +15,7 @@ use crate::pinned::{PinnedCorpus, USER_AGENT}; use crate::{cargo, root, run}; /// Crates with corpus tests (behind their `corpus` feature). -pub const CORPUS_CRATES: &[&str] = &["photocraft-psd", "photocraft-codecs", "photocraft-io", "photocraft-engine"]; +pub const CORPUS_CRATES: &[&str] = &["photocraft-codecs", "photocraft-io", "photocraft-engine"]; /// Opt-in corpus crates: fetched and tested only with `test-corpus --pixls`. pub const PIXLS_CRATES: &[&str] = &["photocraft-raw"]; @@ -25,7 +25,6 @@ const HEIF_CRATES: &[&str] = &["photocraft-codecs", "photocraft-io"]; /// Paths whose changes make `test-corpus --changed` run (the file-format and rendering crates, /// and the engine's `.atn` import and export). const CRITICAL: &[&str] = &[ - "crates/psd/", "crates/io/", "crates/codecs/", "crates/heif/", diff --git a/xtask/src/corpus_pins.rs b/xtask/src/corpus_pins.rs index 263cb926f3..76253d8497 100644 --- a/xtask/src/corpus_pins.rs +++ b/xtask/src/corpus_pins.rs @@ -268,7 +268,7 @@ pub const AFFINITY: PinnedCorpus = PinnedCorpus { }, }; /// Photoshop Actions files (`.atn`) from public repositories under permissive licences, the -/// real-file oracles of `photocraft-psd::atn` and the engine's `.atn` import (2026-10-12). +/// real-file oracles of `craft-psd::atn` and the engine's `.atn` import (2026-10-12). /// https://github.com/sbaril/Photoshop-Scripts (MIT): `Actions/`. pub const SBARIL_COMMIT: &str = "622493c9c617c9d2ff38500e8e512c0ad421eed9"; /// https://github.com/bjango/Bjango-Actions (BSD, `Help/License.md`): three sets at the root. diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index bf1a6f2b88..a01447431f 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -37,7 +37,6 @@ pub const TABLE: &[(&str, Class)] = &[ ("cms", Class::Layer(0)), ("color", Class::Layer(0)), ("raster", Class::Layer(0)), - ("psd", Class::Standalone), ("codecs", Class::Standalone), // Optional HEIF/HEIC decoder (heic-rs), used only by `codecs` behind its `heif` feature. // IPP client for CUPS printing (pure Rust, no dependencies), used by `engine` on Unix. @@ -303,7 +302,7 @@ mod tests { #[test] fn self_dev_dependency_ignored() { - assert!(check(&[c("photocraft-psd", &[("photocraft-psd", Dev, true)])]).is_empty()); + assert!(check(&[c("photocraft-codecs", &[("photocraft-codecs", Dev, true)])]).is_empty()); } #[test] @@ -314,7 +313,7 @@ mod tests { #[test] fn standalone_crates_have_no_workspace_deps() { - for s in ["photocraft-psd", "photocraft-codecs", "photocraft-adobe-assets"] { + for s in ["photocraft-codecs", "photocraft-adobe-assets"] { let v = check(&[c(s, &[("photocraft-geom", Normal, true)])]); assert!(matches!(v[..], [Violation::StandaloneHasWorkspaceDep { .. }]), "{s}"); assert!(check(&[c(s, &[("image", Normal, false)])]).is_empty()); @@ -324,7 +323,7 @@ mod tests { #[test] fn standalone_crates_have_no_exceptions() { // Standalone crates may not use each other, in either direction. - for (from, to) in [("photocraft-codecs", "photocraft-psd"), ("photocraft-psd", "photocraft-codecs")] { + for (from, to) in [("photocraft-codecs", "photocraft-adobe-assets"), ("photocraft-adobe-assets", "photocraft-codecs")] { let v = check(&[c(from, &[(to, Normal, true)])]); assert!(matches!(v[..], [Violation::StandaloneHasWorkspaceDep { .. }]), "{from} -> {to}"); } @@ -335,7 +334,7 @@ mod tests { #[test] fn standalone_is_usable_from_higher_layers() { - assert!(check(&[c("photocraft-io", &[("photocraft-psd", Normal, true)])]).is_empty()); + assert!(check(&[c("photocraft-io", &[("photocraft-codecs", Normal, true)])]).is_empty()); let v = check(&[c("photocraft-geom", &[("photocraft-codecs", Normal, true)])]); assert!(matches!(v[..], [Violation::Upward { from: 0, to: 0, .. }])); } From 4b1650773823cbeb2969e666c46d15695958302f Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Thu, 12 Nov 2026 14:30:25 +0530 Subject: [PATCH 29/31] Custom Shape: an operation adds the shape to the active shape layer shape.create with addTo keeps a component's joined subpaths (holes) joined, so a custom shape's hole doesn't become a component of its own. --- crates/engine/src/cmd/vector_cmds/shape.rs | 5 ++++- crates/engine/src/cmd/vector_cmds/tests.rs | 17 +++++++++++++++++ .../tools/vector_ui/ellipse_path_menus_tests.rs | 16 ++++++++++++++++ crates/ui-egui/src/tools/vector_ui/shapes.rs | 14 ++++++++++++++ 4 files changed, 51 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/vector_cmds/shape.rs b/crates/engine/src/cmd/vector_cmds/shape.rs index 8b7055d7e2..6f4ee75a40 100644 --- a/crates/engine/src/cmd/vector_cmds/shape.rs +++ b/crates/engine/src/cmd/vector_cmds/shape.rs @@ -454,7 +454,10 @@ pub(super) fn shape_create(s: &mut Session, p: &Value) -> Result { let id = LayerId(target); with_shape(s, id, "Add Shape", |sh, _| { for mut sp in path.subpaths { - sp.op = op; + // A joined subpath (a hole) belongs to the component before it, which takes `op`. + if sp.op != PathOp::Join { + sp.op = op; + } sh.path.subpaths.push(sp); } sh.live = None; diff --git a/crates/engine/src/cmd/vector_cmds/tests.rs b/crates/engine/src/cmd/vector_cmds/tests.rs index 93d220a1de..3cd399d096 100644 --- a/crates/engine/src/cmd/vector_cmds/tests.rs +++ b/crates/engine/src/cmd/vector_cmds/tests.rs @@ -312,6 +312,23 @@ fn reapplying_unchanged_live_rectangle_keeps_nonuniformly_scaled_corners() { } } +/// #13442 review: a component added with `addTo` takes `op` on its first subpath; its joined +/// subpaths (holes) stay joined instead of turning into components of their own. +#[test] +fn shape_add_to_keeps_joined_subpaths() { + use photocraft_doc::vector::{Path, PathOp, Subpath}; + let mut s = session(100, 100, 8); + let id = s.execute("shape.create", json!({"kind": "rect", "rect": [0, 0, 40, 40]})).unwrap()["layer"].as_u64().unwrap(); + let ring = Path::new(vec![ + Subpath::polygon(&[(50.0, 50.0), (90.0, 50.0), (90.0, 90.0), (50.0, 90.0)]), + Subpath::polygon(&[(60.0, 60.0), (60.0, 80.0), (80.0, 80.0), (80.0, 60.0)]).with_op(PathOp::Join), + ]); + let path = crate::vector_cmds::path_json(&ring); + s.execute("shape.create", json!({"kind": "path", "path": path, "addTo": id, "op": "subtract"})).unwrap(); + let ops: Vec = shape(&s, id).path.subpaths.iter().map(|sp| sp.op).collect(); + assert_eq!(ops, [PathOp::Combine, PathOp::Subtract, PathOp::Join]); +} + #[test] fn shape_add_to_with_path_ops() { let mut s = session(100, 100, 8); diff --git a/crates/ui-egui/src/tools/vector_ui/ellipse_path_menus_tests.rs b/crates/ui-egui/src/tools/vector_ui/ellipse_path_menus_tests.rs index c6000b3512..472d902d80 100644 --- a/crates/ui-egui/src/tools/vector_ui/ellipse_path_menus_tests.rs +++ b/crates/ui-egui/src/tools/vector_ui/ellipse_path_menus_tests.rs @@ -156,3 +156,19 @@ fn options_bar_shows_the_three_buttons_and_greys_the_entries_without_a_shape() { assert!(h.get_by_label(label).accesskit_node().is_disabled(), "{label}"); } } + +/// #13442 review: the Custom Shape tool also adds to the active shape layer, as one component +/// whose hole stays joined to it. +#[test] +fn a_custom_shape_with_an_operation_joins_the_active_shape_layer() { + let mut app = app(); + drag(&mut app, [50.0, 50.0], [200.0, 200.0]); + let layers = layer_count(&app); + app.ui.tool = Tool::CustomShape; + app.ui.presets_ui.custom_shape = "Ring".into(); + app.ui.tool_options.shape_path_op = "subtract".into(); + drag(&mut app, [100.0, 100.0], [250.0, 250.0]); + assert_eq!(layer_count(&app), layers, "no new layer"); + let ops: Vec = active_shape(&app).path.subpaths.iter().map(|s| s.op).collect(); + assert_eq!(ops, [PathOp::Combine, PathOp::Subtract, PathOp::Join], "the ellipse, the ring and its hole"); +} diff --git a/crates/ui-egui/src/tools/vector_ui/shapes.rs b/crates/ui-egui/src/tools/vector_ui/shapes.rs index dd1fab09d9..df08567d54 100644 --- a/crates/ui-egui/src/tools/vector_ui/shapes.rs +++ b/crates/ui-egui/src/tools/vector_ui/shapes.rs @@ -173,6 +173,20 @@ pub fn finish_shape(app: &mut PhotocraftApp, tool: Tool, start: [f64; 2], end: [ } let Some(mut p) = shape_geometry(&app.ui.tool_options, tool, start, end, mods) else { return }; if tool == Tool::CustomShape { + // With an operation other than New Layer the shape joins the active shape layer as one + // component, its holes joined to it as in Path mode. + if let Some(op) = drag_path_op(app) + && let Some(id) = super::options::selected_shape(&app.session) + && let Some(outline) = shape_outline(app, tool, start, end, mods) + { + let outline = as_one_component(&outline, outline.fill_rule).unwrap_or(outline); + let path = photocraft_engine::vector_cmds::path_json(&outline); + if let Err(e) = app.run("shape.create", json!({"kind": "path", "path": path, "addTo": id.0, "op": op})) { + app.ui.status = e; + app.ui.status_error = true; + } + return; + } // ⇧ keeps the shape's proportions (the rect is already squared). if let Ok(rect) = serde_json::from_value(p["rect"].take()) { let (fill, stroke) = (fill_param(app), stroke_param(app)); From da6f09ce700fe467e70a3e6a0b7722ab02dbbe6a Mon Sep 17 00:00:00 2001 From: ljkx <177671075+ljkx@users.noreply.github.com> Date: Thu, 12 Nov 2026 11:03:28 +0100 Subject: [PATCH 30/31] docs/atn.md: Fit Image leaves a fitting document unchanged only with limit (Don't Enlarge) --- docs/atn.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/atn.md b/docs/atn.md index ac89110ad9..222728c580 100644 --- a/docs/atn.md +++ b/docs/atn.md @@ -334,7 +334,8 @@ run. This covers the parent event, not complete recordings with generated helper Fit Image is the script event `3caa3434-cb67-11d1-bc43-0060b0a13dc4` that follows the Image Size step it runs, which stays a step of its own. `Wdth` and `Hght` (pixel unit floats above 0) and `limit` (Don't Enlarge) play as -`file.automate.fitImage`, which leaves a document that already fits unchanged. +`file.automate.fitImage`. With `limit` true it leaves a document that already fits +unchanged; with `limit` false it enlarges a smaller document to fit the box. The descriptor's class is `cjsp` with an empty class name (measured, in two UI languages). `Msge` is the script's own text, not a setting: the reference plays the step the same with the recorded text, an empty one, unrelated text or the key From 23a0772776db29efac79883dd71d12cff05e6e50 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Thu, 12 Nov 2026 15:39:41 +0530 Subject: [PATCH 31/31] Custom Shape: refuse an operation for a shape that can't be one component Its parts would all take the operation and change the shape. --- .../vector_ui/ellipse_path_menus_tests.rs | 24 +++++++++++++++++++ crates/ui-egui/src/tools/vector_ui/shapes.rs | 12 +++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/tools/vector_ui/ellipse_path_menus_tests.rs b/crates/ui-egui/src/tools/vector_ui/ellipse_path_menus_tests.rs index 472d902d80..11b3d5c49a 100644 --- a/crates/ui-egui/src/tools/vector_ui/ellipse_path_menus_tests.rs +++ b/crates/ui-egui/src/tools/vector_ui/ellipse_path_menus_tests.rs @@ -172,3 +172,27 @@ fn a_custom_shape_with_an_operation_joins_the_active_shape_layer() { let ops: Vec = active_shape(&app).path.subpaths.iter().map(|s| s.op).collect(); assert_eq!(ops, [PathOp::Combine, PathOp::Subtract, PathOp::Join], "the ellipse, the ring and its hole"); } + +/// #13442 review: a custom shape that can't be made one component (its parts exclude each other) +/// is not added with an operation, which would change its parts' operations: the layer stays as +/// it was and the status says why. +#[test] +fn a_custom_shape_that_is_not_one_component_is_refused() { + use photocraft_doc::vector::{Path, Subpath}; + let mut app = app(); + drag(&mut app, [50.0, 50.0], [200.0, 200.0]); + let before = active_shape(&app).path; + let xor = Path::new(vec![ + Subpath::polygon(&[(0.0, 0.0), (60.0, 0.0), (60.0, 60.0), (0.0, 60.0)]), + Subpath::polygon(&[(40.0, 40.0), (100.0, 40.0), (100.0, 100.0), (40.0, 100.0)]).with_op(PathOp::Exclude), + ]); + let r = app.run("shape.presets.new", json!({"name": "Xor", "path": photocraft_engine::vector_cmds::path_json(&xor)})).unwrap(); + app.ui.tool = Tool::CustomShape; + app.ui.presets_ui.custom_shape = r["name"].as_str().unwrap().into(); + app.ui.tool_options.shape_path_op = "combine".into(); + let layers = layer_count(&app); + drag(&mut app, [100.0, 100.0], [250.0, 250.0]); + assert_eq!(layer_count(&app), layers); + assert_eq!(active_shape(&app).path, before, "the layer is unchanged"); + assert!(app.ui.status_error, "{}", app.ui.status); +} diff --git a/crates/ui-egui/src/tools/vector_ui/shapes.rs b/crates/ui-egui/src/tools/vector_ui/shapes.rs index df08567d54..4bf280dce0 100644 --- a/crates/ui-egui/src/tools/vector_ui/shapes.rs +++ b/crates/ui-egui/src/tools/vector_ui/shapes.rs @@ -179,7 +179,17 @@ pub fn finish_shape(app: &mut PhotocraftApp, tool: Tool, start: [f64; 2], end: [ && let Some(id) = super::options::selected_shape(&app.session) && let Some(outline) = shape_outline(app, tool, start, end, mods) { - let outline = as_one_component(&outline, outline.fill_rule).unwrap_or(outline); + // `shape.create` gives every component the operation, so a shape of several components + // that can't be made one (its parts intersect or exclude) would change: refuse it. + let outline = match as_one_component(&outline, outline.fill_rule) { + Some(one) => one, + None if outline.subpaths.len() <= 1 => outline, + None => { + app.ui.status = tl!("This custom shape can't be added to a shape layer with a path operation: choose New Layer").into(); + app.ui.status_error = true; + return; + } + }; let path = photocraft_engine::vector_cmds::path_json(&outline); if let Err(e) = app.run("shape.create", json!({"kind": "path", "path": path, "addTo": id.0, "op": op})) { app.ui.status = e;