Merge remote-tracking branch 'upstream/main' into fix/delete-anchor-shown-only-12769

This commit is contained in:
Trevor Brown committed 2026-11-12 07:33:54 -05:00
commit d6359dd43d
339 files changed
+3416 -25992

No files matched your search

+1 -1
View File
@@ -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
Generated
+18 -19
View File
@@ -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",
+3 -2
View File
@@ -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"
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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).
+2 -2
View File
@@ -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}))]);
+1 -1
View File
@@ -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 }
+2 -2
View File
@@ -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);
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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).
+2 -2
View File
@@ -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
@@ -31,7 +31,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 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
+1 -1
View File
@@ -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
+3
View File
@@ -329,6 +329,9 @@ sync_github_name = true
[people.Luis-0508]
sync_github_name = true
[people.ashokram-si]
real_name = "Ashok Ram"
[people.Kaedo17]
sync_github_name = true
+1 -1
View File
@@ -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).
+1 -1
View File
@@ -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"] }
+2 -2
View File
@@ -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 };
+1 -1
View File
@@ -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<u8> {
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());
+2 -2
View File
@@ -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));
+2 -2
View File
@@ -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<Vec<u8>>,
/// 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<Vec<u8>>,
/// 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
+1 -1
View File
@@ -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"
+134
View File
@@ -641,6 +641,16 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
hdr_toning
),
CommandSpec {
id: "image.adjustments.blackWhite.auto",
label: "Black & White Auto",
menu: &[],
shortcut: None,
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,
},
CommandSpec {
id: "image.adjustments.colorLookup.list",
label: "List Color Lookup Looks",
@@ -654,5 +664,129 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
/// 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::<f32>() / 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
}
/// 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;
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), 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<Value> {
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 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();
// 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()
}
(_, 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()))
}
#[cfg(test)]
mod tests;
+131
View File
@@ -1062,3 +1062,134 @@ 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<f32> = 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<f32> = 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<f32> = 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:?}");
}
/// #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<f32> {
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());
}
+7 -3
View File
@@ -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;
@@ -36,6 +36,7 @@ mod menu_item;
mod steps_artboard;
mod steps_artboard_section;
mod steps_automate;
mod steps_batch;
mod steps_calculations;
mod steps_channels;
@@ -189,6 +190,9 @@ mod layer_props_tests;
#[cfg(test)]
mod layer_list_tests;
#[cfg(test)]
mod automate_tests;
#[cfg(test)]
mod stylize_tests;
@@ -0,0 +1,249 @@
//! #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::*;
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("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 })
.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<AtnItem>) -> (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<AtnItem> {
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 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),
(other_class, 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 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]
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)),
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())]);
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 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 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
/// 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());
// 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}");
}
}
+9
View File
@@ -1207,6 +1207,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",
@@ -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<Descriptor>) -> AtnItem {
@@ -0,0 +1,121 @@
//! File › Automate › Fit Image, recorded as the script event `3caa3434-cb67-11d1-bc43-0060b0a13dc4`
//! 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};
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 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";
/// 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<f64> {
match v {
D::UnitFloat { unit, value } if *unit == PX && value.is_finite() && *value > 0.0 => Some(*value),
_ => None,
}
}
/// 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<Fit> {
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::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 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 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.
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<Fit> {
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 })
}
/// 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 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<Descriptor> {
let fit = native(p)?;
Some(match source(p) {
Some((d, recorded)) if recorded == fit => d,
Some((d, _)) => with_settings(d, &fit),
None => canonical(&fit),
})
}
+3 -2
View File
@@ -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.
@@ -882,6 +882,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})),
(
+1 -1
View File
@@ -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<String> {
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}`")))?;
+1 -1
View File
@@ -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;
+4 -4
View File
@@ -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),
+15 -15
View File
@@ -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<u8>, 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<Value> {
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<u8> {
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();
+1 -1
View File
@@ -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};
+18 -18
View File
@@ -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<String>, Option<u64>), 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<u16>) -> std::result::Result<Vec<u8>, 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<Swatch> {
@@ -576,7 +576,7 @@ pub fn write_file(groups: &[Group<Swatch>], 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<Swatch>], 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<Swatch>], 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<Value> {
// `.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"))?,
};
+12 -12
View File
@@ -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<AcoSwatch> = (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<String> = 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"]));
+4 -1
View File
@@ -454,7 +454,10 @@ pub(super) fn shape_create(s: &mut Session, p: &Value) -> Result<Value> {
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;
@@ -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<PathOp> = 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);
+5 -5
View File
@@ -512,7 +512,7 @@ pub fn contour_from_param(v: &Value) -> Option<Contour> {
/// 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<Vec<Value>> {
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<Vec<u8>> {
/// An `.shc` file holding every contour of `list` (Save Contours…).
pub fn contour_to_shc_all(list: &[&Contour]) -> Result<Vec<u8>> {
let file: Vec<photocraft_psd::shc::ShcContour> = list
let file: Vec<craft_psd::shc::ShcContour> = list
.iter()
.map(|contour| {
let (name, points) = match contour {
@@ -543,12 +543,12 @@ pub fn contour_to_shc_all(list: &[&Contour]) -> Result<Vec<u8>> {
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).
+3 -3
View File
@@ -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<u8> {
use photocraft_psd::descriptor::{Descriptor, UnicodeString, Value};
use craft_psd::descriptor::{Descriptor, UnicodeString, Value};
let descs: Vec<Descriptor> = presets.iter().map(|(n, _)| Descriptor::new("brushPreset").with("Nm ", Value::Text(UnicodeString::new_nul(n)))).collect();
let folders: Vec<Vec<String>> = 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]
+2 -2
View File
@@ -38,8 +38,8 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
false,
move |ctx| {
ctx.check()?;
let (bytes, stem) = crate::preset_import_cmds::file_bytes_limited(&params, 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(&params, 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"));
}
+1 -1
View File
@@ -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;
@@ -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.
@@ -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] = [
+6 -6
View File
@@ -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"),
}]),
+1 -1
View File
@@ -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};
@@ -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<Descriptor>) -> Vec<u8> {
+8 -8
View File
@@ -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");
}
@@ -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";
+2 -2
View File
@@ -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 {
@@ -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 {
+2 -2
View File
@@ -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";
@@ -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};
@@ -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";
+2 -2
View File
@@ -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<u8> {
@@ -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 {
+4 -4
View File
@@ -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";
+3 -3
View File
@@ -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;
@@ -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";
@@ -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 {
@@ -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.
+1 -1
View File
@@ -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";
+5 -5
View File
@@ -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);
+2 -2
View File
@@ -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 {
+4 -4
View File
@@ -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";
+1 -1
View File
@@ -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.
+4 -4
View File
@@ -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";
+2 -2
View File
@@ -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})),
+3 -3
View File
@@ -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;
@@ -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];
+1 -1
View File
@@ -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;
@@ -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())
}
+1 -1
View File
@@ -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();
+16 -16
View File
@@ -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<String> {
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<String> = 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<u8> {
/// version-2 items.
fn cs2_layout(doc: &Document) -> Vec<u8> {
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();
+2 -2
View File
@@ -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;
+4 -4
View File
@@ -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";
+4 -4
View File
@@ -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##"<svg xmlns="http://www.w3.org/2000/svg" width="10" height="8"><path d="M1 1 C2 0 8 0 9 1 L9 7 L1 7 Z" fill="#ff0000" stroke="#0000ff" stroke-width="1"/></svg>"##;
+2 -2
View File
@@ -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 }
+2 -2
View File
@@ -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 {
+2 -2
View File
@@ -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 -- <file.psb>...`
//!
//! 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) {
+3 -3
View File
@@ -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");
+17 -17
View File
@@ -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<String>) {
fn walk(d: &craft_psd::descriptor::Descriptor, depth: usize, out: &mut Vec<String>) {
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<f64> = (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<String> = 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::<String>()
@@ -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);
+4 -4
View File
@@ -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;
+2 -2
View File
@@ -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);
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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};
+6 -6
View File
@@ -274,8 +274,8 @@ fn identity_look() -> Vec<u8> {
/// A `clrL` block as Photoshop writes one for a loaded LUT file.
fn clrl_block(format: &str, bytes: &[u8], name: &str) -> Vec<u8> {
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<u8> {
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());
+3 -3
View File
@@ -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<u8> {
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);
+5 -5
View File
@@ -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<Vec<u8>, 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]
+2 -2
View File
@@ -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).
+2 -2
View File
@@ -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<u8> {
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<u8> {
/// 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<u8> {
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")
+5 -5
View File
@@ -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 })
+2 -6
View File
@@ -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<Descriptor, String> {
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 ") });
}
}
}
+1 -1
View File
@@ -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),
+2 -2
View File
@@ -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};
+2 -2
View File
@@ -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};
+3 -3
View File
@@ -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<J> {
// 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<u8>) -> 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 {
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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,
Loaded 100 of 339 files, more files were not shown because too many files have changed in this diff. Show more