Merge upstream/main into revive/4330-jpeg-options

Main added DDS saving (#5993): DDS Options, and FormatChoices carrying the
Targa and DDS answers through every save path (this branch's SaveChoices
carried Targa and JPEG). Resolved by keeping main's name and making it
the one struct for all three answers.

- lib.rs: FormatChoices { tga_bits, dds, jpeg } with from_params (strict),
  from_step (lenient JPEG read of a recorded step), apply and record;
  main's separate block is folded in. Both prompts (dds_options and
  jpeg_options) are kept everywhere: save_to asks Targa, then DDS, then
  JPEG Options; save_options_open covers all four. save_in_place takes
  main's (path, choices) signature and records the plain-Save step with
  plain_save_step for JPEG plus the Targa/DDS answers (save_in_place_with
  folded into it). journal_as_plain_save relabels a parked Targa, DDS or
  JPEG prompt and keeps tgaBits/dds*/quality/jpegFormat when dropping the
  path. replay_save reads choices with from_step.
- SAVE_OPTIONS_OPEN names all four prompts; DDS Options uses it too.
- targa_options_ui: main's plain-Save step (no path) through this
  branch's write_answered_save. JPEG Options gets main's Enter-on-a-
  focused-Cancel fix.
- view_cmds Save a Copy asks Targa, DDS, then JPEG Options; write_copy
  takes FormatChoices.
- control.rs: both app.save tests (JPEG and DDS) kept.
- engine file_cmds: SaveOpts keeps jpeg_format and dds; param docs list
  both jpegFormat and ddsCompression/ddsMipmaps. CLI: both --jpeg-format
  and --dds-compression/--dds-mipmaps. codecs re-exports JpegFormat and
  DdsCompression/DdsOptions.
- docs/book: both entries kept (control-protocol app.save lists both).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Higgs32584
2026-10-20 20:43:26 -04:00
87 changed files with 3532 additions and 400 deletions
+6
View File
@@ -0,0 +1,6 @@
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
reviews:
auto_review:
# Dependabot bumps are checked by CI; skip CodeRabbit reviews on them.
ignore_usernames:
- "dependabot[bot]"
Generated
+33 -31
View File
@@ -4630,7 +4630,7 @@ dependencies = [
[[package]]
name = "photocraft"
version = "0.10.0"
version = "0.11.1"
dependencies = [
"arboard",
"async-io",
@@ -4675,7 +4675,7 @@ dependencies = [
[[package]]
name = "photocraft-affinity"
version = "0.10.0"
version = "0.11.1"
dependencies = [
"crc32fast",
"flate2",
@@ -4685,7 +4685,7 @@ dependencies = [
[[package]]
name = "photocraft-algo"
version = "0.10.0"
version = "0.11.1"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4698,7 +4698,7 @@ dependencies = [
[[package]]
name = "photocraft-automation"
version = "0.10.0"
version = "0.11.1"
dependencies = [
"base64 0.22.1",
"cap-std",
@@ -4721,7 +4721,7 @@ dependencies = [
[[package]]
name = "photocraft-cli"
version = "0.10.0"
version = "0.11.1"
dependencies = [
"photocraft-automation",
"photocraft-codecs",
@@ -4735,7 +4735,7 @@ dependencies = [
[[package]]
name = "photocraft-cms"
version = "0.10.0"
version = "0.11.1"
dependencies = [
"moxcms",
"rayon",
@@ -4743,7 +4743,7 @@ dependencies = [
[[package]]
name = "photocraft-codecs"
version = "0.10.0"
version = "0.11.1"
dependencies = [
"exr",
"flate2",
@@ -4764,7 +4764,7 @@ dependencies = [
[[package]]
name = "photocraft-color"
version = "0.10.0"
version = "0.11.1"
dependencies = [
"photocraft-cms",
"serde",
@@ -4772,7 +4772,7 @@ dependencies = [
[[package]]
name = "photocraft-compose"
version = "0.10.0"
version = "0.11.1"
dependencies = [
"criterion",
"photocraft-color",
@@ -4787,7 +4787,7 @@ dependencies = [
[[package]]
name = "photocraft-doc"
version = "0.10.0"
version = "0.11.1"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4798,7 +4798,7 @@ dependencies = [
[[package]]
name = "photocraft-engine"
version = "0.10.0"
version = "0.11.1"
dependencies = [
"blake3",
"craft-print",
@@ -4835,7 +4835,7 @@ dependencies = [
[[package]]
name = "photocraft-format"
version = "0.10.0"
version = "0.11.1"
dependencies = [
"blake3",
"crc32fast",
@@ -4858,14 +4858,14 @@ dependencies = [
[[package]]
name = "photocraft-geom"
version = "0.10.0"
version = "0.11.1"
dependencies = [
"serde",
]
[[package]]
name = "photocraft-gpu"
version = "0.10.0"
version = "0.11.1"
dependencies = [
"half",
"log",
@@ -4882,14 +4882,14 @@ dependencies = [
[[package]]
name = "photocraft-heif"
version = "0.10.0"
version = "0.11.1"
dependencies = [
"heic-rs",
]
[[package]]
name = "photocraft-io"
version = "0.10.0"
version = "0.11.1"
dependencies = [
"blake3",
"craft-docpath",
@@ -4919,18 +4919,18 @@ dependencies = [
[[package]]
name = "photocraft-ipp"
version = "0.10.0"
version = "0.11.1"
[[package]]
name = "photocraft-ops"
version = "0.10.0"
version = "0.11.1"
dependencies = [
"photocraft-doc",
]
[[package]]
name = "photocraft-paint"
version = "0.10.0"
version = "0.11.1"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4941,7 +4941,7 @@ dependencies = [
[[package]]
name = "photocraft-plugins"
version = "0.10.0"
version = "0.11.1"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4956,7 +4956,7 @@ dependencies = [
[[package]]
name = "photocraft-psd"
version = "0.10.0"
version = "0.11.1"
dependencies = [
"flate2",
"photocraft-psd",
@@ -4967,17 +4967,18 @@ dependencies = [
[[package]]
name = "photocraft-raster"
version = "0.10.0"
version = "0.11.1"
dependencies = [
"photocraft-color",
"photocraft-geom",
"proptest",
"rayon",
"serde",
]
[[package]]
name = "photocraft-raw"
version = "0.10.0"
version = "0.11.1"
dependencies = [
"flate2",
"image",
@@ -4992,7 +4993,7 @@ dependencies = [
[[package]]
name = "photocraft-tablet"
version = "0.10.0"
version = "0.11.1"
dependencies = [
"block2 0.6.2",
"objc2 0.6.4",
@@ -5004,7 +5005,7 @@ dependencies = [
[[package]]
name = "photocraft-testkit"
version = "0.10.0"
version = "0.11.1"
dependencies = [
"memory-stats",
"photocraft-color",
@@ -5015,7 +5016,7 @@ dependencies = [
[[package]]
name = "photocraft-text"
version = "0.10.0"
version = "0.11.1"
dependencies = [
"flate2",
"parley",
@@ -5024,12 +5025,13 @@ dependencies = [
"photocraft-geom",
"photocraft-psd",
"photocraft-raster",
"rayon",
"skrifa",
]
[[package]]
name = "photocraft-ui-egui"
version = "0.10.0"
version = "0.11.1"
dependencies = [
"base64 0.22.1",
"craft-print",
@@ -5071,7 +5073,7 @@ dependencies = [
[[package]]
name = "photocraft-vector"
version = "0.10.0"
version = "0.11.1"
dependencies = [
"photocraft-color",
"photocraft-doc",
@@ -5082,7 +5084,7 @@ dependencies = [
[[package]]
name = "photocraft-web"
version = "0.10.0"
version = "0.11.1"
dependencies = [
"eframe",
"egui",
@@ -5104,7 +5106,7 @@ dependencies = [
[[package]]
name = "photocraft-web-boot"
version = "0.10.0"
version = "0.11.1"
dependencies = [
"flate2",
"js-sys",
@@ -8691,7 +8693,7 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
[[package]]
name = "xtask"
version = "0.10.0"
version = "0.11.1"
dependencies = [
"serde",
"serde_json",
+1 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/photocraft", "apps/photocraft-cli", "xtask"]
[workspace.package]
version = "0.10.0"
version = "0.11.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
+3
View File
@@ -20,6 +20,9 @@
<img alt="macOS · Windows · Linux · FreeBSD · Web" src="https://img.shields.io/badge/macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20FreeBSD%20%C2%B7%20Web-native-2f7bf5?style=flat-square">
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20%2F%20Apache--2.0-3a3a3a?style=flat-square">
<img alt="Status: early alpha" src="https://img.shields.io/badge/status-early%20alpha-d69e2e?style=flat-square">
<br>
<a href="https://sonarcloud.io/project/overview?id=storytold_photocraft"><img alt="Scanned by SonarCloud" src="https://img.shields.io/badge/Scanned_by-SonarCloud-4b9fd5?style=flat-square&logo=sonarqubecloud&logoColor=white"></a>
<a href="https://coderabbit.ai"><img alt="Reviewed by CodeRabbit" src="https://img.shields.io/coderabbit/prs/github/storytold/photocraft?style=flat-square&label=Reviewed%20by%20CodeRabbit&logo=coderabbit"></a>
</p>
<p align="center">
+20 -8
View File
@@ -15,22 +15,24 @@ pub const USAGE: &str = "\
photocraft-cli: headless PhotoCraft
USAGE:
photocraft-cli convert <in> <out> [--format <ext>] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--jpeg-format <baseline|optimized|progressive>]
photocraft-cli convert <in> <out> [--format <ext>] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression <none|bc1|bc2|bc3>] [--dds-mipmaps] [--jpeg-format <baseline|optimized|progressive>]
Convert between formats (.pcraft, .psd, .png, .jpg, .tif, .webp, .exr, …).
TIFF output is flat unless --tiff-layers keeps the layers (Photoshop layer data).
--quality sets the JPEG or WebP quality; a WebP written with a quality is lossy, without one lossless.
--tga-bits sets a TGA's bits per pixel: 32 writes the alpha channel (or else the transparency) as
its alpha, 24 none. Without it: 32 with an alpha channel or transparency, else 24.
--dpx-bits sets a DPX's bits per sample. Without it: 8 for an 8-bit document, else 16.
--dds-compression sets a DDS texture's compression: none (uncompressed, the default), bc1
(DXT1: alpha on or off), bc2 (DXT3) or bc3 (DXT5). --dds-mipmaps also writes the full mip chain.
--jpeg-format picks a JPEG's scans, as JPEG Options does: baseline (the default), optimized
(Huffman tables fitted to the image; RGB is written 4:2:0 and keeps the standard tables, so
only Gray and CMYK shrink) or progressive.
photocraft-cli info <file> [--compact]
Print the document as JSON (size, mode, depth, layer tree).
photocraft-cli run (<file> | --new <json>) --cmd <id> [--params <json>] [--cmd …] [--out <file>] [--format <ext>] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--jpeg-format <baseline|optimized|progressive>]
photocraft-cli run (<file> | --new <json>) --cmd <id> [--params <json>] [--cmd …] [--out <file>] [--format <ext>] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression <none|bc1|bc2|bc3>] [--dds-mipmaps] [--jpeg-format <baseline|optimized|progressive>]
Open a file, run engine commands in order, save the result. Each --params
applies to the preceding --cmd. Prints each command's JSON result.
photocraft-cli batch --actions <actions.json|set.atn> [--action <name>] --in <dir> --out <dir> [--format <ext>] [--quality <1-100>] [--in-place] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--jpeg-format <baseline|optimized|progressive>]
photocraft-cli batch --actions <actions.json|set.atn> [--action <name>] --in <dir> --out <dir> [--format <ext>] [--quality <1-100>] [--in-place] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression <none|bc1|bc2|bc3>] [--dds-mipmaps] [--jpeg-format <baseline|optimized|progressive>]
Apply an action list to every image in a directory. A Photoshop Actions file (.atn) is a
set: its action --action names plays, as below. JSON steps are [id, params] pairs,
{\"command\": id, \"params\": {…}} objects or bare ids, as a recorded action or droplet stores them
@@ -72,18 +74,23 @@ struct Subcommand {
}
const SUBCOMMANDS: &[Subcommand] = &[
Subcommand { name: "convert", values: &["--format", "--quality", "--tga-bits", "--dpx-bits", "--jpeg-format"], bare: &["--tiff-layers"], run: convert },
Subcommand {
name: "convert",
values: &["--format", "--quality", "--tga-bits", "--dpx-bits", "--dds-compression", "--jpeg-format"],
bare: &["--tiff-layers", "--dds-mipmaps"],
run: convert,
},
Subcommand { name: "info", values: &[], bare: &["--compact"], run: |a, out, _| info(a, out) },
Subcommand {
name: "run",
values: &["--new", "--cmd", "--params", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits", "--jpeg-format"],
bare: &["--tiff-layers"],
values: &["--new", "--cmd", "--params", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits", "--dds-compression", "--jpeg-format"],
bare: &["--tiff-layers", "--dds-mipmaps"],
run: run_cmds,
},
Subcommand {
name: "batch",
values: &["--actions", "--action", "--in", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits", "--jpeg-format"],
bare: &["--in-place", "--tiff-layers"],
values: &["--actions", "--action", "--in", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits", "--dds-compression", "--jpeg-format"],
bare: &["--in-place", "--tiff-layers", "--dds-mipmaps"],
run: batch,
},
Subcommand { name: "droplet", values: &["--out"], bare: &[], run: droplet },
@@ -226,6 +233,11 @@ fn export_opts(a: &Args) -> Result<ExportOptions, String> {
o.encode.jpeg_format =
photocraft_codecs::JpegFormat::parse(f).ok_or_else(|| format!("bad --jpeg-format `{f}`: expected baseline, optimized or progressive"))?;
}
if let Some(c) = a.get("--dds-compression") {
o.encode.dds.compression =
photocraft_io::DdsCompression::from_name(c).ok_or_else(|| format!("bad --dds-compression `{c}`: expected none, bc1, bc2 or bc3"))?;
}
o.encode.dds.mipmaps = a.has("--dds-mipmaps");
Ok(o)
}
+33
View File
@@ -494,6 +494,39 @@ fn dpx_converts_both_ways_with_the_bits_asked_for() {
}
}
/// DDS converts both ways; `--dds-compression` picks the FourCC (bytes 84–87; none writes
/// uncompressed pixels) and `--dds-mipmaps` the mip count (bytes 28–31). A compression that
/// isn't none, bc1, bc2 or bc3 is refused and nothing is written (#1327).
#[test]
fn dds_converts_both_ways_with_the_compression_asked_for() {
let d = tmp("dds");
let a = d.join("a.png");
write_png(&a, 8, 4, 3);
for (args, fourcc, mips) in [
(&[][..], [0; 4], 1),
(&["--dds-compression", "bc1"][..], *b"DXT1", 1),
(&["--dds-compression", "DXT3", "--dds-mipmaps"][..], *b"DXT3", 4),
(&["--dds-compression", "bc3"][..], *b"DXT5", 1),
(&["--dds-mipmaps"][..], [0; 4], 4),
] {
let out = d.join("a.dds");
ok(bin().arg("convert").arg(&a).arg(&out).args(args));
let bytes = std::fs::read(&out).unwrap();
let mip_count = u32::from_le_bytes(bytes[28..32].try_into().unwrap());
assert_eq!((&bytes[..4], &bytes[84..88], mip_count), (&b"DDS "[..], &fourcc[..], mips), "{args:?}");
let back = d.join("back.png");
ok(bin().arg("convert").arg(&out).arg(&back));
assert!(back.exists(), "{args:?}");
}
for bad in ["bc7", "dxt2", ""] {
let out = d.join("bad.dds");
let o = bin().arg("convert").arg(&a).arg(&out).args(["--dds-compression", bad]).output().unwrap();
assert_eq!(o.status.code(), Some(1), "--dds-compression {bad}");
assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("bad --dds-compression `{bad}`")), "--dds-compression {bad}");
assert!(!out.exists(), "--dds-compression {bad} wrote a file");
}
}
/// `--tga-bits` picks a TGA's bits per pixel (byte 16 of its header); anything but 24 or 32 is
/// refused and nothing is written.
#[test]
+3 -1
View File
@@ -99,6 +99,8 @@ const SAVE_FILTERS: &[(&str, &[&str])] = &[
("OpenEXR", &["exr"]),
("FITS", &["fits", "fit"]),
("DPX", &["dpx"]),
// Game and 3D textures; DDS Options asks for the compression and mipmaps (#1327).
("DDS", &["dds"]),
];
/// Non-document files the shell saves (Swatches panel exports): offered alone, so the dialog
@@ -903,7 +905,7 @@ mod tests {
assert!(format.caps().write, "{name}: .{ext} can't be written");
}
}
for ext in ["ico", "gif", "bmp"] {
for ext in ["ico", "gif", "bmp", "dds"] {
assert!(save_filters("Untitled-1.psd").iter().any(|(_, exts)| exts.iter().any(|e| e == ext)), ".{ext} is offered");
}
assert_eq!(save_filters("icon.ico")[0].0, "ICO", "an .ico name leads with its own type");
+1
View File
@@ -20,6 +20,7 @@ Every route that writes a document to a flat format (Save As, the CLI, MCP `doc_
- 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: 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 and the file-size preview 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
the document's CMYK ICC profile (relative colorimetric with black point compensation) and embed
+4
View File
@@ -181,3 +181,7 @@ sync_github_name = true
[people.00xJS]
sync_github_name = true
[people.guillehr2]
real_name = "Guillem Hermida"
sync_github_name = true
+1
View File
@@ -114,6 +114,7 @@ impl Headless {
let mut opts =
photocraft_io::ExportOptions { tiff_layers: p.get("tiffLayers").and_then(Value::as_bool).unwrap_or(false), tga_bits, ..Default::default() };
opts.encode.dpx_bits = photocraft_engine::file_cmds::dpx_bits_param(&p, "doc.save").map_err(|e| bad(e.to_string()))?;
opts.encode.dds = photocraft_engine::file_cmds::dds_param(&p, "doc.save").map_err(|e| bad(e.to_string()))?.unwrap_or_default();
if let Some(q) = p.get("quality").and_then(Value::as_u64) {
opts.encode.jpeg_quality = q.clamp(1, 100) as u8;
opts.encode.webp_quality = q.clamp(1, 100) as u8;
+20 -1
View File
@@ -170,6 +170,13 @@ pub struct SaveParams {
/// DPX: 8, 10, 12 or 16 bits per sample. Unset: 8 for an 8-bit document, else 16.
#[serde(default, rename = "dpxBits")]
pub dpx_bits: Option<u8>,
/// DDS: "none" (uncompressed, the default), "bc1" (DXT1: alpha on or off), "bc2" (DXT3) or
/// "bc3" (DXT5).
#[serde(default, rename = "ddsCompression")]
pub dds_compression: Option<String>,
/// DDS: also write the full mip chain, each level a box filter of the one above.
#[serde(default, rename = "ddsMipmaps")]
pub dds_mipmaps: bool,
#[serde(default)]
pub index: Option<usize>,
}
@@ -1001,10 +1008,15 @@ impl PhotocraftMcp {
Some((_, Some(bits))) => Some(bits),
Some((b, None)) => return Ok(fail(format!("`dpxBits` must be 8, 10, 12 or 16, got {b}"))),
};
let dds_compression = match p.dds_compression.as_deref().map(|c| (c, photocraft_io::DdsCompression::from_name(c))) {
None => None,
Some((_, Some(c))) => Some(c),
Some((c, None)) => return Ok(fail(format!("`ddsCompression` must be none, bc1, bc2 or bc3, got {c:?}"))),
};
if let Some(b) = self.bridge_client() {
// The bridge forwards to the running app's `app.save`, which takes only an optional
// path (without one it writes back to the document's own layered file, as headless
// does) and the Targa bits, so the other options are headless-only. Saying so beats
// does), the Targa bits and the DDS options, so the other options are headless-only. Saying so beats
// saving with defaults while the caller believes their quality or format was applied.
let unsupported: Vec<&str> = [
p.format.is_some().then_some("format"),
@@ -1026,12 +1038,19 @@ impl PhotocraftMcp {
if let Some(bits) = tga_bits {
params["tgaBits"] = json!(bits.bits());
}
if let Some(c) = dds_compression {
params["ddsCompression"] = json!(c.name());
}
if p.dds_mipmaps {
params["ddsMipmaps"] = json!(true);
}
return to_result(b.call("app.save", params).await);
}
let Some(r) = self
.headless_op(move |h| {
let mut opts = photocraft_io::ExportOptions { tiff_layers: p.tiff_layers, tga_bits, ..Default::default() };
opts.encode.dpx_bits = dpx_bits;
opts.encode.dds = photocraft_io::DdsOptions { compression: dds_compression.unwrap_or_default(), mipmaps: p.dds_mipmaps };
if let Some(q) = p.quality {
opts.encode.jpeg_quality = q.clamp(1, 100);
opts.encode.webp_quality = q.clamp(1, 100);
+1 -1
View File
@@ -591,7 +591,7 @@ mod tests {
/// #7095: replacing a file through the automation path keeps the old file's extended
/// attributes, the same as a save through `atomic_write` does.
#[cfg(unix)]
#[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))]
#[test]
fn write_keeps_the_replaced_files_extended_attributes() {
#[cfg(target_os = "macos")]
+39
View File
@@ -415,6 +415,45 @@ async fn headless_doc_save_writes_targa_bits() {
cleanup(&dir);
}
/// `ddsCompression` and `ddsMipmaps` reach the app's `app.save` through the bridge; an unknown
/// compression is refused before anything is sent.
#[tokio::test(flavor = "multi_thread")]
async fn bridge_doc_save_forwards_dds_options() {
let (addr, app) = fake_app().await;
let client = connect(PhotocraftMcp::bridge(&addr, CONTROL_TOKEN).unwrap()).await;
let saved = json_of(&call(&client, "doc_save", json!({"path": "out.dds", "ddsCompression": "DXT5", "ddsMipmaps": true})).await);
assert_eq!(saved, json!({"saved": {"path": "out.dds", "ddsCompression": "bc3", "ddsMipmaps": true}}));
let r = call(&client, "doc_save", json!({"path": "out.dds", "ddsCompression": "bc7"})).await;
assert_eq!(r.is_error, Some(true), "{}", text(&r));
assert!(text(&r).contains("ddsCompression"), "{}", text(&r));
client.cancel().await.unwrap();
app.abort();
}
/// Headless `doc_save`/`doc_export` write the DDS compression (the FourCC, bytes 84–87) and mip
/// count (bytes 28–31) asked for; unset, an uncompressed texture without mipmaps.
#[tokio::test(flavor = "multi_thread")]
async fn headless_doc_save_writes_dds_options() {
let dir = tmp("dds-options");
let client = connect(headless_in(&dir)).await;
call(&client, "doc_new", json!({"width": 16, "height": 8, "background": "white"})).await;
for (tool, params, fourcc, mips) in [
("doc_export", json!({"path": "plain.dds"}), [0; 4], 1),
("doc_export", json!({"path": "bc1.dds", "ddsCompression": "bc1"}), *b"DXT1", 1),
("doc_save", json!({"path": "bc3.dds", "ddsCompression": "bc3", "ddsMipmaps": true}), *b"DXT5", 5),
] {
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!((&bytes[84..88], u32::from_le_bytes(bytes[28..32].try_into().unwrap())), (&fourcc[..], mips), "{params}");
}
let r = call(&client, "doc_export", json!({"path": "bad.dds", "ddsCompression": "bc7"})).await;
assert_eq!(r.is_error, Some(true), "{}", text(&r));
assert!(!dir.join("bad.dds").exists());
client.cancel().await.unwrap();
cleanup(&dir);
}
#[tokio::test(flavor = "multi_thread")]
async fn bridge_response_budget_drops_connection_without_retrying_the_operation() {
use photocraft_automation::{BridgeClient, budgets::MAX_RESPONSE_BYTES};
+10 -2
View File
@@ -40,7 +40,7 @@ let out = encode(&img, Format::Tiff, &EncodeOptions::default())?;
| BMP | yes | yes | U8 | RGB, RGBA | yes | no | no | no | no | no | no | `image` |
| TGA | yes | yes | U8 | Gray, GrayA, RGB, RGBA | yes | no | no | no | no | no | no | `image` (read) / built in (RLE write, one scanline per packet) |
| ICO | yes | yes | U8 | RGBA (at most 256×256) | yes | no | no | no | no | no | no | `image` |
| DDS | yes | **no** | U8 | Gray, GrayA, RGB, RGBA | yes | no | no | no | no | no | n/a | built in |
| DDS | yes | yes | U8 | Gray, GrayA, RGB, RGBA | yes | no | no | no | no | no | yes (BC1–BC3, when `EncodeOptions::dds` asks for one) | built in |
| Netpbm (PBM/PGM/PPM/PAM/PFM) | yes | yes | U8, U16, F32 (float: PFM gray/RGB only) | all six (PAM `CMYK`/`CMYK_ALPHA`) | yes (PAM) | no | no | no | no | no | no | built in |
| QOI | yes | yes | U8 | RGB, RGBA | yes | no | no | no | no | no | no | `image` |
| OpenEXR | yes | yes | F16, F32 | Y, YA, RGB, RGBA | yes | no | no | no | no | no | no | `exr` |
@@ -73,7 +73,15 @@ embedded table (and a PNG's transparent entry) so `photocraft-io` can open them
(8, 16, 24 or 32 bits, RGB or luminance, with or without alpha) or BC1/BC2/BC3 (DXT1/DXT3/DXT5),
with the legacy header or the DX10 one. Everything opens as U8; the other mip levels are
ignored. Cubemaps, texture arrays, volume textures, premultiplied DXT2/DXT4 and other formats
(BC4–BC7, float) are refused with `CodecError::Unsupported`. Writing is not provided.
(BC4–BC7, float) are refused with `CodecError::Unsupported`. Writing (`EncodeOptions::dds`) makes
a 2D texture with the legacy header: uncompressed by default (BGRA, BGRX, or luminance with or
without alpha, so it reads back exactly), or BC1/BC2/BC3. The block encoder is our own, from
Microsoft's public block-compression documentation: RGB565 endpoints fitted along each block's
principal axis and refined by least squares, indices picked against the palette the reader
decodes; BC1 turns texels under half opacity into its transparent black, and BC3 picks
whichever alpha mode fits. `mipmaps` adds the full chain down to 1×1, each level a box filter
of the one above (the last column and row of an odd-sized level average three pixels, so none
is dropped). Cubemaps, arrays, BC4–BC7 and sRGB (DX10-only) formats aren't written.
* **FITS.** The image in the primary HDU, or the first `IMAGE` extension, is read; `NAXIS3 = 3`
opens as RGB and any other plane count opens the first plane with `DecodeWarning::MorePages`.
+276 -35
View File
@@ -1,13 +1,24 @@
//! DirectDraw Surface (DDS) reader: the top mip level of a 2D texture, either uncompressed
//! (8 to 32 bits per pixel described by channel masks, RGB or luminance) or block-compressed
//! BC1/BC2/BC3 (DXT1/DXT3/DXT5), with or without the DX10 header. Read-only. Cubemaps,
//! texture arrays and volume textures are refused rather than flattened to one face or slice.
//! DirectDraw Surface (DDS) textures, after Microsoft's public DDS documentation ("DDS",
//! "DDS_HEADER", "DDS_PIXELFORMAT", "DDS_HEADER_DXT10").
//!
//! Reading: the top mip level of a 2D texture, either uncompressed (8 to 32 bits per pixel
//! described by channel masks, RGB or luminance) or block-compressed BC1/BC2/BC3
//! (DXT1/DXT3/DXT5), with or without the DX10 header. Cubemaps, texture arrays and volume
//! textures are refused rather than flattened to one face or slice.
//!
//! Writing: a 2D texture, uncompressed (BGRA or BGRX, luminance with or without alpha) or
//! BC1/BC2/BC3, optionally with its full mip chain. The legacy header describes all of these,
//! so no DX10 header is written; formats only DXGI can name (BC4–BC7, sRGB) aren't written.
use std::borrow::Cow;
use super::dds_bc::{self, Block, Mask};
use crate::SampleType;
use crate::error::CodecError;
use crate::fidelity::Plan;
use crate::format::Format;
use crate::image::{ChannelLayout, Image};
use crate::options::Limits;
use crate::options::{DdsCompression, EncodeOptions, Limits};
/// `DDS ` plus the 124-byte DDS_HEADER.
const HEADER: usize = 128;
@@ -224,27 +235,12 @@ pub(crate) fn decode(b: &[u8], limits: &Limits) -> Result<Image, CodecError> {
Image::from_u8(width, height, layout, pixels)
}
fn rgb565(c: u16) -> [u8; 3] {
let scale = |v: u16, max: u16| ((u32::from(v) * 255 + u32::from(max) / 2) / u32::from(max)) as u8;
[scale(c >> 11, 31), scale((c >> 5) & 63, 63), scale(c & 31, 31)]
}
/// A BC1 colour block. BC2 and BC3 colour blocks always have four colours; only BC1's
/// `c0 <= c1` order selects three colours and transparent black.
fn bc1(block: &[u8], one_bit_alpha: bool) -> [[u8; 4]; 16] {
let byte = |i: usize| block.get(i).copied().unwrap_or(0);
let (c0, c1) = (u16::from_le_bytes([byte(0), byte(1)]), u16::from_le_bytes([byte(2), byte(3)]));
let (a, b) = (rgb565(c0), rgb565(c1));
let mix = |wa: u32, wb: u32| {
let d = wa + wb;
let at = |i: usize| ((wa * u32::from(a[i]) + wb * u32::from(b[i]) + d / 2) / d) as u8;
[at(0), at(1), at(2), 255]
};
let palette = if c0 > c1 || !one_bit_alpha {
[[a[0], a[1], a[2], 255], [b[0], b[1], b[2], 255], mix(2, 1), mix(1, 2)]
} else {
[[a[0], a[1], a[2], 255], [b[0], b[1], b[2], 255], mix(1, 1), [0; 4]]
};
let palette = dds_bc::colour_palette(c0, c1, c0 > c1 || !one_bit_alpha);
let indices = u32::from_le_bytes([byte(4), byte(5), byte(6), byte(7)]);
std::array::from_fn(|t| palette[(indices >> (2 * t)) as usize & 3])
}
@@ -259,21 +255,9 @@ fn bc2_alpha(block: &[u8]) -> [u8; 16] {
/// 0 and 255 when `a0 <= a1`.
fn bc3_alpha(block: &[u8]) -> [u8; 16] {
let byte = |i: usize| block.get(i).copied().unwrap_or(0);
let (a0, a1) = (u32::from(byte(0)), u32::from(byte(1)));
let mut palette = [a0, a1, 0, 0, 0, 0, 0, 255];
if a0 > a1 {
for (k, v) in palette.iter_mut().enumerate().skip(2) {
let k = k as u32;
*v = ((8 - k) * a0 + (k - 1) * a1 + 3) / 7;
}
} else {
for (k, v) in palette.iter_mut().enumerate().take(6).skip(2) {
let k = k as u32;
*v = ((6 - k) * a0 + (k - 1) * a1 + 2) / 5;
}
}
let palette = dds_bc::alpha_palette(byte(0), byte(1));
let bits = u64::from_le_bytes([byte(2), byte(3), byte(4), byte(5), byte(6), byte(7), 0, 0]);
std::array::from_fn(|t| palette[((bits >> (3 * t)) & 7) as usize] as u8)
std::array::from_fn(|t| palette[((bits >> (3 * t)) & 7) as usize])
}
fn with_alpha(mut texels: [[u8; 4]; 16], alpha: [u8; 16]) -> [[u8; 4]; 16] {
@@ -282,3 +266,260 @@ fn with_alpha(mut texels: [[u8; 4]; 16], alpha: [u8; 16]) -> [[u8; 4]; 16] {
}
texels
}
const DDSD_CAPS: u32 = 0x1;
const DDSD_HEIGHT: u32 = 0x2;
const DDSD_WIDTH: u32 = 0x4;
const DDSD_PITCH: u32 = 0x8;
const DDSD_PIXELFORMAT: u32 = 0x1000;
const DDSD_MIPMAPCOUNT: u32 = 0x2_0000;
const DDSD_LINEARSIZE: u32 = 0x8_0000;
const DDSCAPS_COMPLEX: u32 = 0x8;
const DDSCAPS_TEXTURE: u32 = 0x1000;
const DDSCAPS_MIPMAP: u32 = 0x40_0000;
fn encode_err(reason: impl std::fmt::Display) -> CodecError {
CodecError::encode(Format::Dds, reason)
}
/// How a written texture stores its pixels.
#[derive(Clone, Copy, PartialEq, Eq)]
enum Store {
/// 8-bit luminance.
Luminance,
/// 8-bit luminance, then 8-bit alpha.
LuminanceAlpha,
/// Blue, green, red and an unused byte (written as 255, so a reader that takes it for
/// alpha still sees an opaque texture).
Bgrx,
Bgra,
Bc1,
Bc2,
Bc3,
}
impl Store {
/// The storage for an image of `layout`, as [`crate::fidelity::plan`] chose it.
fn new(compression: DdsCompression, layout: ChannelLayout) -> Result<Store, CodecError> {
Ok(match (compression, layout) {
(DdsCompression::None, ChannelLayout::Gray) => Store::Luminance,
(DdsCompression::None, ChannelLayout::GrayA) => Store::LuminanceAlpha,
(DdsCompression::None, ChannelLayout::Rgb) => Store::Bgrx,
(DdsCompression::None, ChannelLayout::Rgba) => Store::Bgra,
(DdsCompression::Bc1, ChannelLayout::Rgba) => Store::Bc1,
(DdsCompression::Bc2, ChannelLayout::Rgba) => Store::Bc2,
(DdsCompression::Bc3, ChannelLayout::Rgba) => Store::Bc3,
(_, layout) => return Err(encode_err(format!("{layout:?} pixels can't be written as `{}`", compression.name()))),
})
}
/// Bytes per pixel, or per 4×4 block for a block compression.
fn unit(self) -> usize {
match self {
Store::Luminance => 1,
Store::LuminanceAlpha => 2,
Store::Bgrx | Store::Bgra => 4,
Store::Bc1 => 8,
Store::Bc2 | Store::Bc3 => 16,
}
}
fn is_block(self) -> bool {
matches!(self, Store::Bc1 | Store::Bc2 | Store::Bc3)
}
/// Bytes of one row of pixels (or of 4×4 blocks) of a level `width` wide.
fn row_bytes(self, width: usize) -> Option<usize> {
if self.is_block() { width.div_ceil(4).checked_mul(self.unit()) } else { width.checked_mul(self.unit()) }
}
fn level_bytes(self, width: usize, height: usize) -> Option<usize> {
let rows = if self.is_block() { height.div_ceil(4) } else { height };
self.row_bytes(width)?.checked_mul(rows)
}
/// DDS_PIXELFORMAT: flags, FourCC, bits per pixel and the red, green, blue and alpha masks.
fn pixel_format(self) -> (u32, [u8; 4], u32, [u32; 4]) {
match self {
Store::Luminance => (DDPF_LUMINANCE, [0; 4], 8, [0xff, 0, 0, 0]),
Store::LuminanceAlpha => (DDPF_LUMINANCE | DDPF_ALPHAPIXELS, [0; 4], 16, [0xff, 0, 0, 0xff00]),
Store::Bgrx => (DDPF_RGB, [0; 4], 32, [0xff_0000, 0xff00, 0xff, 0]),
Store::Bgra => (DDPF_RGB | DDPF_ALPHAPIXELS, [0; 4], 32, [0xff_0000, 0xff00, 0xff, 0xff00_0000]),
Store::Bc1 => (DDPF_FOURCC, *b"DXT1", 0, [0; 4]),
Store::Bc2 => (DDPF_FOURCC, *b"DXT3", 0, [0; 4]),
Store::Bc3 => (DDPF_FOURCC, *b"DXT5", 0, [0; 4]),
}
}
}
/// Levels in a full mip chain: the larger side halves down to 1.
fn mip_levels(width: u32, height: u32) -> u32 {
32 - width.max(height).max(1).leading_zeros()
}
fn half((w, h): (usize, usize)) -> (usize, usize) {
((w / 2).max(1), (h / 2).max(1))
}
pub(crate) fn encode(image: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Vec<u8>, CodecError> {
let img = image.converted(plan.layout, SampleType::U8);
let pixels = img.as_u8().ok_or_else(|| encode_err("the image was not converted to 8 bits per channel"))?;
let store = Store::new(opts.dds.compression, img.layout())?;
let (width, height) = img.dimensions();
let levels = if opts.dds.mipmaps { mip_levels(width, height) } else { 1 };
let top = (width as usize, height as usize);
// Every level's size, checked before anything is allocated.
let mut sizes = Vec::new();
let mut size = top;
for _ in 0..levels {
sizes.push(store.level_bytes(size.0, size.1).ok_or_else(|| encode_err("texture size overflow"))?);
size = half(size);
}
let total = sizes.iter().try_fold(HEADER, |total, &s| total.checked_add(s)).ok_or_else(|| encode_err("texture size overflow"))?;
let header = header(width, height, store, levels, sizes.first().copied().unwrap_or(0))?;
let mut out = crate::options::alloc_zeroed::<u8>(total)?;
out.get_mut(..HEADER).ok_or_else(|| encode_err("output buffer too small"))?.copy_from_slice(&header);
let channels = img.layout().channels();
let mut level = Cow::Borrowed(pixels);
let (mut size, mut at) = (top, HEADER);
for (i, &bytes) in sizes.iter().enumerate() {
let dst = out.get_mut(at..at + bytes).ok_or_else(|| encode_err("output buffer too small"))?;
write_level(store, &level, size, dst)?;
at += bytes;
if i + 1 < sizes.len() {
let next = half(size);
level = Cow::Owned(downsample(&level, size, next, channels)?);
size = next;
}
}
Ok(out)
}
/// The `DDS ` signature and DDS_HEADER. `top` is the top level's size in bytes.
fn header(width: u32, height: u32, store: Store, levels: u32, top: usize) -> Result<[u8; HEADER], CodecError> {
let mut b = [0u8; HEADER];
let mut put = |at: usize, v: u32| {
if let Some(d) = b.get_mut(at..at + 4) {
d.copy_from_slice(&v.to_le_bytes());
}
};
// Uncompressed: the pitch of one row. Compressed: the whole top level (its "linear size").
let (size_flag, pitch_or_size) = if store.is_block() { (DDSD_LINEARSIZE, Some(top)) } else { (DDSD_PITCH, store.row_bytes(width as usize)) };
let pitch_or_size =
pitch_or_size.and_then(|v| u32::try_from(v).ok()).ok_or_else(|| encode_err(format!("a {width}x{height} level is too large for a DDS header")))?;
let mipmapped = levels > 1;
put(4, 124);
put(8, DDSD_CAPS | DDSD_HEIGHT | DDSD_WIDTH | DDSD_PIXELFORMAT | size_flag | if mipmapped { DDSD_MIPMAPCOUNT } else { 0 });
put(12, height);
put(16, width);
put(20, pitch_or_size);
put(28, levels);
let (flags, fourcc, bits, masks) = store.pixel_format();
put(76, 32);
put(80, flags);
put(84, u32::from_le_bytes(fourcc));
put(88, bits);
for (i, mask) in masks.into_iter().enumerate() {
put(92 + 4 * i, mask);
}
put(108, DDSCAPS_TEXTURE | if mipmapped { DDSCAPS_COMPLEX | DDSCAPS_MIPMAP } else { 0 });
b[..4].copy_from_slice(b"DDS ");
Ok(b)
}
/// Runs `f` on each `len`-byte row of `out` with its index, in parallel on native targets.
fn rows(out: &mut [u8], len: usize, f: impl Fn(usize, &mut [u8]) + Send + Sync) {
#[cfg(not(target_arch = "wasm32"))]
{
use rayon::prelude::*;
out.par_chunks_mut(len.max(1)).enumerate().for_each(|(i, row)| f(i, row));
}
#[cfg(target_arch = "wasm32")]
for (i, row) in out.chunks_mut(len.max(1)).enumerate() {
f(i, row);
}
}
/// Writes one level of `width` × `height` pixels (in the image's layout) into `dst`.
fn write_level(store: Store, pixels: &[u8], (width, height): (usize, usize), dst: &mut [u8]) -> Result<(), CodecError> {
let row_bytes = store.row_bytes(width).ok_or_else(|| encode_err("texture size overflow"))?;
match store {
Store::Luminance | Store::LuminanceAlpha => {
// The image's gray (and alpha) samples, in the same order.
let src = pixels.get(..dst.len()).ok_or_else(|| encode_err("the level is smaller than its size"))?;
dst.copy_from_slice(src);
}
Store::Bgrx | Store::Bgra => {
let channels = if store == Store::Bgra { 4 } else { 3 };
rows(dst, row_bytes, |y, row| {
let src = pixels.get(y * width * channels..).unwrap_or_default();
for (d, s) in row.as_chunks_mut::<4>().0.iter_mut().zip(src.chunks_exact(channels)) {
let byte = |i: usize| s.get(i).copied().unwrap_or(255);
*d = [byte(2), byte(1), byte(0), byte(3)];
}
});
}
Store::Bc1 | Store::Bc2 | Store::Bc3 => {
let (texels, _) = pixels.as_chunks::<4>();
rows(dst, row_bytes, |by, row| {
for (bx, d) in row.chunks_exact_mut(store.unit()).enumerate() {
let (block, inside) = gather(texels, (width, height), bx, by);
let encoded: &[u8] = match store {
Store::Bc1 => &dds_bc::encode_bc1(&block, inside),
Store::Bc2 => &dds_bc::encode_bc2(&block, inside),
_ => &dds_bc::encode_bc3(&block, inside),
};
for (d, s) in d.iter_mut().zip(encoded) {
*d = *s;
}
}
});
}
}
Ok(())
}
/// Block (`bx`, `by`)'s texels, and which of them lie inside the image.
fn gather(texels: &[[u8; 4]], (width, height): (usize, usize), bx: usize, by: usize) -> (Block, Mask) {
let mut block = [[0u8; 4]; 16];
let mut inside: Mask = 0;
for (t, texel) in block.iter_mut().enumerate() {
let (x, y) = (bx * 4 + t % 4, by * 4 + t / 4);
if x < width
&& y < height
&& let Some(p) = texels.get(y * width + x)
{
*texel = *p;
inside |= 1 << t;
}
}
(block, inside)
}
/// The next mip level: each pixel the average of the 2×2 pixels it covers, or of 2×3, 3×2 or
/// 3×3 along the last column and row of an odd-sized level, so no pixel is dropped.
fn downsample(src: &[u8], (w, h): (usize, usize), (nw, nh): (usize, usize), channels: usize) -> Result<Vec<u8>, CodecError> {
let len = nw.checked_mul(nh).and_then(|n| n.checked_mul(channels)).ok_or_else(|| encode_err("mip level size overflow"))?;
let mut dst = crate::options::alloc_zeroed::<u8>(len)?;
let span = |i: usize, n: usize, size: usize| 2 * i..=if i + 1 == n { size.saturating_sub(1) } else { 2 * i + 1 };
rows(&mut dst, nw * channels, |y, row| {
let ys = span(y, nh, h);
for (x, px) in row.chunks_exact_mut(channels.max(1)).enumerate() {
let xs = span(x, nw, w);
for (c, v) in px.iter_mut().enumerate() {
let (mut sum, mut n) = (0u32, 0u32);
for sy in ys.clone() {
for sx in xs.clone() {
if let Some(&s) = src.get((sy * w + sx) * channels + c) {
sum += u32::from(s);
n += 1;
}
}
}
*v = ((sum + n / 2) / n.max(1)) as u8;
}
}
});
Ok(dst)
}
+368
View File
@@ -0,0 +1,368 @@
//! BC1, BC2 and BC3 (DXT1, DXT3, DXT5) blocks, after Microsoft's public block compression
//! documentation ("Block Compression (Direct3D 10)" and "Texture Block Compression in Direct3D
//! 11"): the palettes the DDS reader decodes with, and the block encoder the DDS writer uses.
//!
//! The encoder fits a block's colours with two RGB565 endpoints along their principal axis,
//! refines them by least squares, and scores every candidate against the exact palette the
//! reader rebuilds, so the indices it picks are the best ones for what a decoder shows.
/// The 16 texels of a 4×4 block, row by row, as RGBA.
pub(crate) type Block = [[u8; 4]; 16];
/// Bit `t` set for each texel `t` of a block that counts: edge blocks of a texture whose size is
/// not a multiple of four have texels outside the image.
pub(crate) type Mask = u16;
pub(crate) fn rgb565(c: u16) -> [u8; 3] {
let scale = |v: u16, max: u16| ((u32::from(v) * 255 + u32::from(max) / 2) / u32::from(max)) as u8;
[scale(c >> 11, 31), scale((c >> 5) & 63, 63), scale(c & 31, 31)]
}
/// The colours a colour block's indices select. Four-colour mode (BC1 with `c0 > c1`, and every
/// BC2 and BC3 block) adds two colours at thirds; three-colour mode adds the midpoint and
/// transparent black.
pub(crate) fn colour_palette(c0: u16, c1: u16, four: bool) -> [[u8; 4]; 4] {
let (a, b) = (rgb565(c0), rgb565(c1));
let mix = |wa: u32, wb: u32| {
let d = wa + wb;
let at = |i: usize| ((wa * u32::from(a[i]) + wb * u32::from(b[i]) + d / 2) / d) as u8;
[at(0), at(1), at(2), 255]
};
if four {
[[a[0], a[1], a[2], 255], [b[0], b[1], b[2], 255], mix(2, 1), mix(1, 2)]
} else {
[[a[0], a[1], a[2], 255], [b[0], b[1], b[2], 255], mix(1, 1), [0; 4]]
}
}
/// A BC3 alpha block's eight values: six interpolated at sevenths when `a0 > a1`, else four
/// interpolated at fifths followed by 0 and 255.
pub(crate) fn alpha_palette(a0: u8, a1: u8) -> [u8; 8] {
let (a0, a1) = (u32::from(a0), u32::from(a1));
let mut palette = [a0, a1, 0, 0, 0, 0, 0, 255];
if a0 > a1 {
for (k, v) in palette.iter_mut().enumerate().skip(2) {
let k = k as u32;
*v = ((8 - k) * a0 + (k - 1) * a1 + 3) / 7;
}
} else {
for (k, v) in palette.iter_mut().enumerate().take(6).skip(2) {
let k = k as u32;
*v = ((6 - k) * a0 + (k - 1) * a1 + 2) / 5;
}
}
palette.map(|v| v as u8)
}
/// A BC1 block. Texels under half opacity become BC1's transparent black, which needs the
/// three-colour mode; an opaque block takes whichever mode fits its colours better.
pub(crate) fn encode_bc1(block: &Block, inside: Mask) -> [u8; 8] {
let transparent = select(block, inside, |t| t[3] < 128);
colour_block(block, inside & !transparent, transparent, true)
}
/// A BC2 block: four bits of alpha per texel, then a four-colour block.
pub(crate) fn encode_bc2(block: &Block, inside: Mask) -> [u8; 16] {
let bits = block.iter().enumerate().fold(0u64, |bits, (t, texel)| bits | ((u64::from(texel[3]) * 15 + 127) / 255) << (4 * t));
join(bits.to_le_bytes(), colour_block(block, inside, 0, false))
}
/// A BC3 block: an interpolated alpha block, then a four-colour block.
pub(crate) fn encode_bc3(block: &Block, inside: Mask) -> [u8; 16] {
join(alpha_block(block, inside), colour_block(block, inside, 0, false))
}
fn join(a: [u8; 8], b: [u8; 8]) -> [u8; 16] {
std::array::from_fn(|i| if i < 8 { a.get(i) } else { b.get(i - 8) }.copied().unwrap_or(0))
}
/// The texels of `inside` for which `f` holds.
fn select(block: &Block, inside: Mask, f: impl Fn(&[u8; 4]) -> bool) -> Mask {
block.iter().enumerate().filter(|(t, texel)| inside >> t & 1 != 0 && f(texel)).fold(0, |m, (t, _)| m | 1 << t)
}
fn rgb_of(texel: &[u8; 4]) -> [f32; 3] {
[f32::from(texel[0]), f32::from(texel[1]), f32::from(texel[2])]
}
/// RGB565 of a colour, rounding each channel to the nearest level.
fn to565(c: [f32; 3]) -> u16 {
let q = |v: f32, max: f32| (v.clamp(0.0, 255.0) * max / 255.0).round() as u16;
q(c[0], 31.0) << 11 | q(c[1], 63.0) << 5 | q(c[2], 31.0)
}
fn distance(a: &[u8; 4], b: &[u8; 4]) -> u32 {
(0..3).map(|i| u32::from(a[i].abs_diff(b[i])).pow(2)).sum()
}
/// A colour block (endpoints, then 2-bit indices) for the `opaque` texels' colours, with the
/// `transparent` ones on BC1's transparent index. `bc1` allows the three-colour mode; BC2 and
/// BC3 always decode four colours.
fn colour_block(block: &Block, opaque: Mask, transparent: Mask, bc1: bool) -> [u8; 8] {
let colours: Vec<[f32; 3]> = block.iter().enumerate().filter(|(t, _)| opaque >> t & 1 != 0).map(|(_, texel)| rgb_of(texel)).collect();
if colours.is_empty() {
// Nothing visible: every texel on the transparent index (or index 0 of black).
let indices = if transparent != 0 { u32::MAX } else { 0 };
return pack(0, 0, indices);
}
let (lo, hi) = principal_endpoints(&colours);
let mut candidates = vec![(to565(lo), to565(hi))];
let modes: &[bool] = if transparent != 0 {
&[false]
} else if bc1 {
&[true, false]
} else {
&[true]
};
let mut best: Option<(u32, [u8; 8])> = None;
// Each refinement starts from the best block so far: its indices give the least-squares
// endpoints, which become one more candidate.
for round in 0..3 {
for &(e0, e1) in &candidates {
for &four in modes {
let (bytes, error) = fit(block, opaque, transparent, e0, e1, four);
if best.is_none_or(|(e, _)| error < e) {
best = Some((error, bytes));
}
}
}
let Some((error, bytes)) = best else { break };
if error == 0 || round == 2 {
break;
}
candidates = least_squares(block, opaque, &bytes).into_iter().collect();
if candidates.is_empty() {
break;
}
}
best.map_or([0; 8], |(_, bytes)| bytes)
}
/// Orders endpoints `e0`, `e1` for the mode (`c0 > c1` for four colours, `c0 <= c1` for three)
/// and picks each texel's nearest palette colour. Returns the block and its squared error.
fn fit(block: &Block, opaque: Mask, transparent: Mask, e0: u16, e1: u16, four: bool) -> ([u8; 8], u32) {
let (c0, c1) = if four == (e0 >= e1) { (e0, e1) } else { (e1, e0) };
// Equal endpoints can't be in four-colour order; the palette is then one colour either way.
let palette = colour_palette(c0, c1, four && c0 > c1);
let choices = if four && c0 > c1 { 4 } else { 3 };
let mut indices = 0u32;
let mut error = 0u32;
for (t, texel) in block.iter().enumerate() {
let index = if transparent >> t & 1 != 0 {
3
} else if opaque >> t & 1 != 0 {
let (i, d) = palette.iter().take(choices).enumerate().map(|(i, p)| (i, distance(texel, p))).min_by_key(|&(_, d)| d).unwrap_or((0, 0));
error += d;
i as u32
} else {
0
};
indices |= index << (2 * t);
}
(pack(c0, c1, indices), error)
}
fn pack(c0: u16, c1: u16, indices: u32) -> [u8; 8] {
let (c0, c1, i) = (c0.to_le_bytes(), c1.to_le_bytes(), indices.to_le_bytes());
[c0[0], c0[1], c1[0], c1[1], i[0], i[1], i[2], i[3]]
}
/// The ends of the colours' spread along their principal axis (the covariance's dominant
/// eigenvector, by power iteration), clamped to the RGB cube.
fn principal_endpoints(colours: &[[f32; 3]]) -> ([f32; 3], [f32; 3]) {
let n = colours.len().max(1) as f32;
let mean = [0, 1, 2].map(|i| colours.iter().map(|c| c[i]).sum::<f32>() / n);
let mut cov = [[0f32; 3]; 3];
for c in colours {
let d = [c[0] - mean[0], c[1] - mean[1], c[2] - mean[2]];
for (i, row) in cov.iter_mut().enumerate() {
for (j, v) in row.iter_mut().enumerate() {
*v += d[i] * d[j];
}
}
}
let times = |a: [f32; 3]| [0, 1, 2].map(|i| cov[i][0] * a[0] + cov[i][1] * a[1] + cov[i][2] * a[2]);
let iterate = |start: usize| {
let mut axis = [0f32; 3];
axis[start] = 1.0;
for _ in 0..8 {
let next = times(axis);
let len = next.iter().map(|v| v * v).sum::<f32>().sqrt();
if !len.is_normal() {
break;
}
axis = next.map(|v| v / len);
}
axis
};
// From each channel's axis, keeping the one the colours spread along most: a channel that
// varies on its own is already an eigenvector, so a single start there would never leave it
// even when the others vary more together.
let spread = |a: [f32; 3]| times(a).iter().zip(a).map(|(x, y)| x * y).sum::<f32>();
let axis = [0, 1, 2].map(iterate).into_iter().max_by(|&a, &b| spread(a).total_cmp(&spread(b))).unwrap_or([1.0, 0.0, 0.0]);
let along = |c: &[f32; 3]| (c[0] - mean[0]) * axis[0] + (c[1] - mean[1]) * axis[1] + (c[2] - mean[2]) * axis[2];
let (lo, hi) = colours.iter().map(along).fold((f32::MAX, f32::MIN), |(lo, hi), t| (lo.min(t), hi.max(t)));
let at = |t: f32| [0, 1, 2].map(|i| (mean[i] + t * axis[i]).clamp(0.0, 255.0));
if lo > hi { (mean, mean) } else { (at(lo), at(hi)) }
}
/// The endpoints that minimise the squared error for the indices `bytes` already chose: each
/// texel's index fixes its weights between the endpoints, giving 2×2 normal equations per
/// channel. `None` when the indices don't constrain both endpoints.
fn least_squares(block: &Block, opaque: Mask, bytes: &[u8; 8]) -> Option<(u16, u16)> {
let (c0, c1) = (u16::from_le_bytes([bytes[0], bytes[1]]), u16::from_le_bytes([bytes[2], bytes[3]]));
let indices = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]);
let four = c0 > c1;
// How much of c0 each index holds.
let weight = |i: u32| match (four, i) {
(_, 0) => Some(1.0f32),
(_, 1) => Some(0.0),
(true, 2) => Some(2.0 / 3.0),
(true, _) => Some(1.0 / 3.0),
(false, 2) => Some(0.5),
(false, _) => None,
};
let (mut aa, mut ab, mut bb) = (0f32, 0f32, 0f32);
let (mut ax, mut bx) = ([0f32; 3], [0f32; 3]);
for (t, texel) in block.iter().enumerate() {
if opaque >> t & 1 == 0 {
continue;
}
let Some(a) = weight(indices >> (2 * t) & 3) else { continue };
let b = 1.0 - a;
let c = rgb_of(texel);
aa += a * a;
ab += a * b;
bb += b * b;
for ((x0, x1), v) in ax.iter_mut().zip(bx.iter_mut()).zip(c) {
*x0 += a * v;
*x1 += b * v;
}
}
let det = aa * bb - ab * ab;
if det.abs() < 1e-3 {
return None;
}
let e0 = [0, 1, 2].map(|i| (ax[i] * bb - bx[i] * ab) / det);
let e1 = [0, 1, 2].map(|i| (bx[i] * aa - ax[i] * ab) / det);
Some((to565(e0), to565(e1)))
}
/// A BC3 alpha block: whichever of the two modes (eight values between the extremes, or six
/// between the extremes other than 0 and 255, plus exact 0 and 255) fits better.
fn alpha_block(block: &Block, inside: Mask) -> [u8; 8] {
let alphas = || block.iter().enumerate().filter(move |(t, _)| inside >> t & 1 != 0).map(|(_, texel)| texel[3]);
let range = |values: &mut dyn Iterator<Item = u8>| values.fold(None, |r: Option<(u8, u8)>, a| Some(r.map_or((a, a), |(lo, hi)| (lo.min(a), hi.max(a)))));
let (lo, hi) = range(&mut alphas()).unwrap_or((0, 0));
// Eight values need a0 > a1; equal ends fall to the six-value mode, which starts with a0 too.
let eight = alpha_fit(block, inside, hi, lo);
let (mid_lo, mid_hi) = range(&mut alphas().filter(|a| !matches!(a, 0 | 255))).unwrap_or((0, 0));
let six = alpha_fit(block, inside, mid_lo, mid_hi);
if six.1 < eight.1 { six.0 } else { eight.0 }
}
fn alpha_fit(block: &Block, inside: Mask, a0: u8, a1: u8) -> ([u8; 8], u32) {
let palette = alpha_palette(a0, a1);
let mut bits = 0u64;
let mut error = 0u32;
for (t, texel) in block.iter().enumerate() {
if inside >> t & 1 == 0 {
continue;
}
let (i, d) = palette.iter().enumerate().map(|(i, &p)| (i, u32::from(p.abs_diff(texel[3])).pow(2))).min_by_key(|&(_, d)| d).unwrap_or((0, 0));
error += d;
bits |= (i as u64) << (3 * t);
}
let b = bits.to_le_bytes();
([a0, a1, b[0], b[1], b[2], b[3], b[4], b[5]], error)
}
#[cfg(test)]
mod tests {
use super::*;
fn decode_colour(bytes: &[u8; 8], bc1: bool) -> Block {
let (c0, c1) = (u16::from_le_bytes([bytes[0], bytes[1]]), u16::from_le_bytes([bytes[2], bytes[3]]));
let palette = colour_palette(c0, c1, c0 > c1 || !bc1);
let indices = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]);
std::array::from_fn(|t| palette[(indices >> (2 * t)) as usize & 3])
}
#[test]
fn solid_blocks_land_on_the_nearest_565_colour() {
for colour in [[0, 0, 0, 255], [255, 255, 255, 255], [12, 200, 77, 255]] {
let block = [colour; 16];
let out = decode_colour(&encode_bc1(&block, u16::MAX), true);
for texel in out {
assert!(texel.iter().zip(colour).all(|(a, b)| a.abs_diff(b) <= 4), "{texel:?} for {colour:?}");
}
}
}
#[test]
fn two_colour_blocks_are_exact_when_the_colours_are_565() {
let (a, b) = ([255, 0, 0, 255], [0, 0, 255, 255]);
let block: Block = std::array::from_fn(|t| if t % 3 == 0 { a } else { b });
for bc1 in [true, false] {
let bytes = if bc1 { encode_bc1(&block, u16::MAX) } else { colour_block(&block, u16::MAX, 0, false) };
assert_eq!(decode_colour(&bytes, bc1), block);
}
}
#[test]
fn bc1_punches_through_texels_under_half_alpha() {
let block: Block = std::array::from_fn(|t| if t < 8 { [200, 100, 50, 255] } else { [9, 9, 9, 127] });
let bytes = encode_bc1(&block, u16::MAX);
let (c0, c1) = (u16::from_le_bytes([bytes[0], bytes[1]]), u16::from_le_bytes([bytes[2], bytes[3]]));
assert!(c0 <= c1, "three-colour mode");
let out = decode_colour(&bytes, true);
assert!(out[..8].iter().all(|t| t[3] == 255) && out[8..].iter().all(|t| *t == [0; 4]), "{out:?}");
}
#[test]
fn four_colour_blocks_keep_c0_above_c1() {
// A ramp needs all four colours; BC2 and BC3 blocks must not rely on the order, but
// older decoders read c0 <= c1 as three colours, so the encoder never writes that.
let block: Block = std::array::from_fn(|t| [(t * 16) as u8, 0, 0, 255]);
let bytes = colour_block(&block, u16::MAX, 0, false);
assert!(u16::from_le_bytes([bytes[0], bytes[1]]) > u16::from_le_bytes([bytes[2], bytes[3]]));
}
#[test]
fn alpha_blocks_pick_the_mode_that_keeps_0_and_255() {
// 0, 255 and a cluster in between: the six-value mode stores 0 and 255 exactly.
let block: Block = std::array::from_fn(|t| [0, 0, 0, [0, 255, 100, 110][t % 4]]);
let bytes = alpha_block(&block, u16::MAX);
assert!(bytes[0] <= bytes[1], "six-value mode");
let palette = alpha_palette(bytes[0], bytes[1]);
let bits = u64::from_le_bytes([bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7], 0, 0]);
for (t, texel) in block.iter().enumerate() {
let got = palette[(bits >> (3 * t)) as usize & 7];
assert!(got.abs_diff(texel[3]) <= 2, "texel {t}: {got} for {}", texel[3]);
}
}
#[test]
fn the_principal_axis_is_found_when_one_channel_varies_on_its_own() {
// Red alternates on its own (variance 64); green and blue alternate together (variance 49
// each, 98 along their shared axis), uncorrelated with red. Starting on red alone would
// keep red's axis and flatten green and blue; the block must do better than its bounding box.
let block: Block = std::array::from_fn(|t| {
let gb = if (t / 2) % 2 == 0 { 121 } else { 135 };
[if t % 2 == 0 { 120 } else { 136 }, gb, gb, 255]
});
let error = |bytes: &[u8; 8]| decode_colour(bytes, false).iter().zip(&block).map(|(a, b)| distance(a, b)).sum::<u32>();
let (_, bounding_box) = fit(&block, u16::MAX, 0, to565([120.0, 121.0, 121.0]), to565([136.0, 135.0, 135.0]), true);
let ours = error(&colour_block(&block, u16::MAX, 0, false));
assert!(ours < bounding_box, "{ours} vs the bounding box's {bounding_box}");
}
#[test]
fn texels_outside_the_image_are_ignored() {
// Only the top-left texel is inside: its colour alone decides the endpoints.
let mut block: Block = [[0, 255, 0, 255]; 16];
block[0] = [255, 0, 255, 255];
let out = decode_colour(&encode_bc1(&block, 1), true);
assert_eq!(out[0], [255, 0, 255, 255]);
}
}
+1
View File
@@ -1,4 +1,5 @@
pub(crate) mod dds;
pub(crate) mod dds_bc;
pub(crate) mod deep_exr;
pub(crate) mod dpx;
pub(crate) mod exr;
+6 -3
View File
@@ -6,7 +6,7 @@ use std::fmt;
use crate::format::{Format, caps};
use crate::image::{ChannelLayout, Image, SampleType};
use crate::options::EncodeOptions;
use crate::options::{DdsCompression, EncodeOptions};
/// Something that will be lost or altered when writing an image to a format.
#[derive(Debug, Clone, PartialEq)]
@@ -165,6 +165,8 @@ pub(crate) fn plan(image: &Image, format: Format, opts: &EncodeOptions) -> Plan
},
// CIELab has no float form: float Lab is written with 16 bits.
Format::Tiff if layout.is_lab() && sample.is_float() => Plan { layout: pick_layout(layout, c.layouts, c.alpha), sample: SampleType::U16 },
// A block-compressed DDS stores RGBA whatever the source; uncompressed keeps gray and RGB.
Format::Dds if opts.dds.compression.is_block() => Plan { layout: ChannelLayout::Rgba, sample: SampleType::U8 },
_ => Plan { layout: pick_layout(layout, c.layouts, c.alpha), sample: pick_sample(sample, c.depths) },
}
}
@@ -211,7 +213,8 @@ pub fn fidelity_warnings_with(image: &Image, format: Format, opts: &EncodeOption
if sl.has_alpha() && !p.layout.has_alpha() && image.has_translucency() {
w.push(W::AlphaDiscarded);
}
if format == Format::Gif && p.layout.has_alpha() && image.has_translucency() {
let binary_alpha = format == Format::Gif || (format == Format::Dds && opts.dds.compression == DdsCompression::Bc1);
if binary_alpha && p.layout.has_alpha() && image.has_translucency() {
w.push(W::AlphaBinarized);
}
if sl.is_cmyk() && !p.layout.is_cmyk() {
@@ -227,7 +230,7 @@ pub fn fidelity_warnings_with(image: &Image, format: Format, opts: &EncodeOption
// Compression
if format == Format::Gif {
w.push(W::PaletteQuantized);
} else if c.lossy || (format == Format::WebP && !opts.webp_lossless) {
} else if c.lossy || (format == Format::WebP && !opts.webp_lossless) || (format == Format::Dds && opts.dds.compression.is_block()) {
w.push(W::LossyCompression);
}
+4 -6
View File
@@ -19,7 +19,8 @@ pub enum Format {
Pcx,
/// X PixMap icons (XPM2 plain text and XPM3 C-string arrays).
Xpm,
/// DirectDraw Surface: the top mip level of a 2D texture, uncompressed or BC1–BC3.
/// DirectDraw Surface: a 2D texture, uncompressed or BC1–BC3. Reading takes the top mip
/// level; writing can add the full mip chain ([`crate::DdsOptions`]).
Dds,
Qoi,
OpenExr,
@@ -71,10 +72,6 @@ pub const ASYMMETRIC_EXCEPTIONS: &[(Format, &str)] = &[
Format::Pcx,
"PCX is supported for opening legacy paint and clipart assets. New files should use PNG; writing obsolete indexed and planar variants is intentionally not provided.",
),
(
Format::Dds,
"DDS is supported for opening game and 3D textures. Writing one well means choosing a block compression and generating mip levels, which belongs in a texture export, not Save As.",
),
(
Format::Avif,
"AVIF encode uses ravif (pure Rust) but decoding requires dav1d (C); read stays unsupported \
@@ -255,7 +252,8 @@ pub fn caps(format: Format) -> FormatCaps {
Format::Pnm => FormatCaps { depths: &[S::U8, S::U16, S::F32], layouts: ALL_LAYOUTS, ..base },
Format::Pcx => FormatCaps { read: true, write: false, layouts: &[L::Rgb], alpha: false, ..base },
Format::Xpm => FormatCaps { read: true, write: false, layouts: &[L::Rgba], ..base },
Format::Dds => FormatCaps { read: true, write: false, layouts: RGB_GRAY, ..base },
// Uncompressed by default; a block compression is lossy (see `fidelity_warnings_with`).
Format::Dds => FormatCaps { layouts: RGB_GRAY, ..base },
Format::Qoi => base,
Format::OpenExr => FormatCaps { depths: &[S::F16, S::F32], layouts: RGB_GRAY, ..base },
Format::Hdr => FormatCaps { depths: &[S::F32], layouts: &[L::Rgb], alpha: false, lossy: true, ..base },
+4 -2
View File
@@ -38,7 +38,9 @@ pub use crate::format::{ASYMMETRIC_EXCEPTIONS, Format, FormatCaps, caps, detect,
pub use crate::image::{
ChannelLayout, CryptomatteBuffer, CryptomatteLayer, DecodeWarning, DeepChannel, DeepImage, ExrChannelInfo, ExrPartInfo, Image, Metadata, SampleType,
};
pub use crate::options::{DecodeOptions, DpxBits, EncodeOptions, ExrCompression, JpegFormat, Limits, PngCompression, PngStereo, TiffCompression};
pub use crate::options::{
DdsCompression, DdsOptions, DecodeOptions, DpxBits, EncodeOptions, ExrCompression, JpegFormat, Limits, PngCompression, PngStereo, TiffCompression,
};
pub use crate::orientation::{exif_orientation, upright_exif, upright_xmp};
pub use crate::resolution::{exif_resolution, export_exif, export_xmp, photoshop_resolution, xmp_resolution};
pub use crate::source_settings::{WebpEncoding, jpeg_quality, webp_encoding};
@@ -252,7 +254,7 @@ pub fn encode(image: &Image, format: Format, opts: &EncodeOptions) -> Result<Vec
Format::Pnm => pnm::encode(image, plan, opts),
Format::Pcx => Err(CodecError::unsupported(format, "PCX is import-only; save as PNG instead")),
Format::Xpm => Err(CodecError::unsupported(format, "XPM is import-only; save as PNG instead")),
Format::Dds => Err(CodecError::unsupported(format, "DDS is import-only; save as PNG instead")),
Format::Dds => dds::encode(image, plan, opts),
Format::Fits => fits::encode(image, plan, opts),
Format::Dpx => dpx::encode(image, plan, opts),
Format::Cineon => Err(CodecError::unsupported(format, "Cineon is import-only; save as DPX, TIFF or EXR instead")),
+58
View File
@@ -213,6 +213,61 @@ impl DpxBits {
}
}
/// How a DDS texture stores its pixels.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum DdsCompression {
/// Uncompressed, 8 bits per channel: BGRA (BGRX without alpha), or luminance with or
/// without alpha for grayscale. Lossless.
#[default]
None,
/// BC1 (DXT1): 4 bits per pixel. Alpha is on or off: texels under half opacity become
/// transparent black.
Bc1,
/// BC2 (DXT3): 8 bits per pixel, with 4 bits of explicit alpha per texel.
Bc2,
/// BC3 (DXT5): 8 bits per pixel, with smooth interpolated alpha.
Bc3,
}
impl DdsCompression {
pub const ALL: [DdsCompression; 4] = [DdsCompression::None, DdsCompression::Bc1, DdsCompression::Bc2, DdsCompression::Bc3];
/// The name scripts and the CLI use: `none`, `bc1`, `bc2` or `bc3`.
pub fn name(self) -> &'static str {
match self {
DdsCompression::None => "none",
DdsCompression::Bc1 => "bc1",
DdsCompression::Bc2 => "bc2",
DdsCompression::Bc3 => "bc3",
}
}
/// [`Self::name`] back to a compression, also taking the DXT names (`dxt1`, `dxt3`,
/// `dxt5`), in any case. Anything else is `None`.
pub fn from_name(name: &str) -> Option<Self> {
match name.to_ascii_lowercase().as_str() {
"none" => Some(DdsCompression::None),
"bc1" | "dxt1" => Some(DdsCompression::Bc1),
"bc2" | "dxt3" => Some(DdsCompression::Bc2),
"bc3" | "dxt5" => Some(DdsCompression::Bc3),
_ => None,
}
}
/// A block compression (lossy), not uncompressed pixels.
pub fn is_block(self) -> bool {
self != DdsCompression::None
}
}
/// What a DDS file is written with.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct DdsOptions {
pub compression: DdsCompression,
/// Write the full mip chain down to 1×1, each level a box filter of the one above.
pub mipmaps: bool,
}
/// Options for [`crate::encode`].
#[derive(Debug, Clone)]
pub struct EncodeOptions {
@@ -240,6 +295,8 @@ pub struct EncodeOptions {
pub exr_compression: ExrCompression,
/// DPX bits per sample. `None` follows the image: 8 for 8-bit, 16 otherwise.
pub dpx_bits: Option<DpxBits>,
/// DDS compression and mip chain.
pub dds: DdsOptions,
/// Embed the ICC profile when the format supports it.
pub embed_icc: bool,
/// Embed EXIF/XMP/DPI/text when the format supports it.
@@ -261,6 +318,7 @@ impl Default for EncodeOptions {
tiff_bigtiff: false,
exr_compression: ExrCompression::Zip16,
dpx_bits: None,
dds: DdsOptions::default(),
embed_icc: true,
embed_metadata: true,
}
+5 -4
View File
@@ -59,11 +59,12 @@ const WHITE: u16 = 0xffff;
const INDICES_0123: [u8; 4] = [0x04, 0x0e, 0, 0];
#[test]
fn dds_is_readable_but_not_writable() {
fn dds_is_readable_and_writable() {
assert_eq!(from_extension("texture.DDS"), Some(Format::Dds));
assert!(caps(Format::Dds).read && !caps(Format::Dds).write);
let img = photocraft_codecs::Image::from_u8(1, 1, ChannelLayout::Rgba, vec![0, 0, 0, 0]).unwrap();
assert!(matches!(encode(&img, Format::Dds, &Default::default()), Err(CodecError::Unsupported { .. })));
assert!(caps(Format::Dds).read && caps(Format::Dds).write);
let img = photocraft_codecs::Image::from_u8(1, 1, ChannelLayout::Rgba, vec![1, 2, 3, 0]).unwrap();
let b = encode(&img, Format::Dds, &Default::default()).unwrap();
assert_eq!(decode(&b).unwrap().data(), &[1, 2, 3, 0]);
}
#[test]
+372
View File
@@ -0,0 +1,372 @@
//! Writing DDS textures: every storage read back through the reader, the header fields, sizes
//! that aren't a multiple of four, the mip chain, and the block-compression error on ramps.
use photocraft_codecs::{ChannelLayout, DdsCompression, DdsOptions, EncodeOptions, FidelityWarning, Format, Image, decode, encode, fidelity_warnings_with};
const DDSD_PITCH: u32 = 0x8;
const DDSD_MIPMAPCOUNT: u32 = 0x2_0000;
const DDSD_LINEARSIZE: u32 = 0x8_0000;
const REQUIRED: u32 = 0x1007; // CAPS | HEIGHT | WIDTH | PIXELFORMAT
const DDSCAPS_COMPLEX: u32 = 0x8;
const DDSCAPS_TEXTURE: u32 = 0x1000;
const DDSCAPS_MIPMAP: u32 = 0x40_0000;
const DDPF_ALPHAPIXELS: u32 = 0x1;
const DDPF_FOURCC: u32 = 0x4;
const DDPF_RGB: u32 = 0x40;
const DDPF_LUMINANCE: u32 = 0x2_0000;
const BLOCKS: [DdsCompression; 3] = [DdsCompression::Bc1, DdsCompression::Bc2, DdsCompression::Bc3];
fn opts(compression: DdsCompression, mipmaps: bool) -> EncodeOptions {
EncodeOptions { dds: DdsOptions { compression, mipmaps }, ..Default::default() }
}
fn le32(b: &[u8], at: usize) -> u32 {
u32::from_le_bytes(b[at..at + 4].try_into().unwrap())
}
/// A ramp along one line through RGB (red up, green up half as fast, blue down) across the
/// texture, and alpha falling from opaque down it: every block's colours lie on a line, which
/// is what a colour block stores.
fn ramp(w: u32, h: u32, layout: ChannelLayout) -> Image {
let ch = layout.channels();
let at = |v: u32, n: u32| (v * 255 / (n - 1).max(1)) as u8;
let mut data = Vec::new();
for y in 0..h {
for x in 0..w {
let r = at(x, w);
let rgba = [r, 64 + r / 2, 255 - r, 255 - at(y, h)];
match ch {
1 => data.push(rgba[0]),
2 => data.extend([rgba[0], rgba[3]]),
_ => data.extend(&rgba[..ch]),
}
}
}
Image::from_u8(w, h, layout, data).unwrap()
}
/// The same RGBA image, fully opaque.
fn opaque(img: &Image) -> Image {
let data = img.data().chunks(4).flat_map(|p| [p[0], p[1], p[2], 255]).collect();
Image::from_u8(img.width(), img.height(), ChannelLayout::Rgba, data).unwrap()
}
/// Largest difference in channel `c` (of four) between RGBA images.
fn max_diff_channel(a: &Image, b: &Image, c: usize) -> u8 {
a.data().chunks(4).zip(b.data().chunks(4)).map(|(x, y)| x[c].abs_diff(y[c])).max().unwrap_or(0)
}
/// Largest colour difference between RGBA images where `b` is visible (BC1 turns texels under
/// half opacity into transparent black).
fn max_colour_diff(a: &Image, b: &Image) -> u8 {
a.data().chunks(4).zip(b.data().chunks(4)).filter(|(_, y)| y[3] != 0).flat_map(|(x, y)| (0..3).map(|c| x[c].abs_diff(y[c]))).max().unwrap_or(0)
}
/// A 2D gradient: red across, green down and blue along the diagonal, opaque.
fn gradient(w: u32, h: u32) -> Image {
let data = (0..h).flat_map(|y| (0..w).flat_map(move |x| [(x * 4).min(255) as u8, (y * 4).min(255) as u8, ((x + y) * 2).min(255) as u8, 255])).collect();
Image::from_u8(w, h, ChannelLayout::Rgba, data).unwrap()
}
/// PSNR of the colour (not the alpha) of two RGBA images.
fn psnr(a: &Image, b: &Image) -> f64 {
let rgb = |i: &Image| i.data().chunks(4).flat_map(|p| [p[0], p[1], p[2]]).map(f64::from).collect::<Vec<_>>();
let (a, b) = (rgb(a), rgb(b));
let mse = a.iter().zip(&b).map(|(x, y)| (x - y).powi(2)).sum::<f64>() / a.len() as f64;
if mse == 0.0 { f64::INFINITY } else { 10.0 * (255.0f64.powi(2) / mse).log10() }
}
/// Bytes of one level as a reader computes them from the header's pixel format.
fn level_bytes(file: &[u8], w: usize, h: usize) -> usize {
if le32(file, 80) & DDPF_FOURCC != 0 {
let block = if &file[84..88] == b"DXT1" { 8 } else { 16 };
w.div_ceil(4) * h.div_ceil(4) * block
} else {
w * h * le32(file, 88) as usize / 8
}
}
/// Mip level `i` of a written texture, cut out as a single-level texture and decoded.
fn level(file: &[u8], i: u32) -> Image {
let (w, h) = (le32(file, 16) as usize, le32(file, 12) as usize);
let size = |i: u32| ((w >> i).max(1), (h >> i).max(1));
let start = 128 + (0..i).map(|j| level_bytes(file, size(j).0, size(j).1)).sum::<usize>();
let (lw, lh) = size(i);
let mut b = file[..128].to_vec();
b[12..16].copy_from_slice(&(lh as u32).to_le_bytes());
b[16..20].copy_from_slice(&(lw as u32).to_le_bytes());
b[28..32].copy_from_slice(&1u32.to_le_bytes());
b.extend(&file[start..start + level_bytes(file, lw, lh)]);
decode(&b).unwrap()
}
#[test]
fn uncompressed_textures_round_trip_every_layout_exactly() {
for (layout, pf_flags, bits, masks) in [
(ChannelLayout::Gray, DDPF_LUMINANCE, 8, [0xff, 0, 0, 0]),
(ChannelLayout::GrayA, DDPF_LUMINANCE | DDPF_ALPHAPIXELS, 16, [0xff, 0, 0, 0xff00]),
(ChannelLayout::Rgb, DDPF_RGB, 32, [0xff_0000, 0xff00, 0xff, 0]),
(ChannelLayout::Rgba, DDPF_RGB | DDPF_ALPHAPIXELS, 32, [0xff_0000, 0xff00, 0xff, 0xff00_0000]),
] {
for (w, h) in [(1, 1), (5, 3), (17, 9)] {
let img = ramp(w, h, layout);
let b = encode(&img, Format::Dds, &EncodeOptions::default()).unwrap();
let back = decode(&b).unwrap();
assert_eq!(back.layout(), layout, "{layout:?} {w}x{h}");
assert_eq!(back.data(), img.data(), "{layout:?} {w}x{h}");
assert_eq!((&b[..4], le32(&b, 4), le32(&b, 76)), (&b"DDS "[..], 124, 32));
assert_eq!(le32(&b, 8), REQUIRED | DDSD_PITCH, "pitch, not linear size, and no mip count");
assert_eq!((le32(&b, 12), le32(&b, 16)), (h, w));
assert_eq!(le32(&b, 20), w * bits / 8, "pitch of {layout:?} {w}x{h}");
assert_eq!((le32(&b, 28), le32(&b, 108)), (1, DDSCAPS_TEXTURE));
assert_eq!((le32(&b, 80), le32(&b, 88)), (pf_flags, bits));
assert_eq!([le32(&b, 92), le32(&b, 96), le32(&b, 100), le32(&b, 104)], masks);
assert_eq!(b.len(), 128 + (w * h * bits / 8) as usize);
}
}
}
#[test]
fn block_compressed_textures_round_trip_through_the_reader() {
for (compression, fourcc, block) in [(DdsCompression::Bc1, b"DXT1", 8), (DdsCompression::Bc2, b"DXT3", 16), (DdsCompression::Bc3, b"DXT5", 16)] {
// Not multiples of four, and the 1×1 and 2×2 textures smaller than one block.
for (w, h) in [(1, 1), (2, 2), (4, 4), (5, 3), (7, 13), (33, 17)] {
let img = ramp(w, h, ChannelLayout::Rgba);
let b = encode(&img, Format::Dds, &opts(compression, false)).unwrap();
let linear = w.div_ceil(4) * h.div_ceil(4) * block;
assert_eq!(&b[84..88], fourcc);
assert_eq!((le32(&b, 80), le32(&b, 88)), (DDPF_FOURCC, 0));
assert_eq!(le32(&b, 8), REQUIRED | DDSD_LINEARSIZE, "linear size, not pitch");
assert_eq!(le32(&b, 20), linear, "{compression:?} {w}x{h}: the top level's size");
assert_eq!(b.len(), 128 + linear as usize);
let back = decode(&b).unwrap();
assert_eq!((back.dimensions(), back.layout()), ((w, h), ChannelLayout::Rgba));
// Steep ramps across tiny textures put three colours where a block interpolates at
// thirds, so this bound is loose; it still catches swapped channels or misplaced
// blocks. `block_compression_error_on_gradients_is_bounded` is the tight one. A BC1
// block with transparent texels has only three colours, so BC1's colours are
// checked on the opaque ramp.
let (src, back_colour) = if compression == DdsCompression::Bc1 {
let o = opaque(&img);
let back = decode(&encode(&o, Format::Dds, &opts(compression, false)).unwrap()).unwrap();
(o, back)
} else {
(img.clone(), back.clone())
};
let colour = max_colour_diff(&src, &back_colour);
assert!(colour <= 24, "{compression:?} {w}x{h}: colour off by {colour}");
let alpha = max_diff_channel(&img, &back, 3);
match compression {
DdsCompression::Bc1 => assert!(back.data().chunks(4).zip(img.data().chunks(4)).all(|(b, i)| b[3] == if i[3] < 128 { 0 } else { 255 })),
_ => assert!(alpha <= 8, "{compression:?} {w}x{h}: alpha off by {alpha}"),
}
}
}
}
#[test]
fn gray_and_rgb_sources_are_written_as_rgba_blocks() {
for layout in [ChannelLayout::Gray, ChannelLayout::GrayA, ChannelLayout::Rgb] {
for compression in BLOCKS {
let img = ramp(8, 8, layout);
let back = decode(&encode(&img, Format::Dds, &opts(compression, false)).unwrap()).unwrap();
let want = img.convert(ChannelLayout::Rgba, img.sample_type());
assert_eq!(back.layout(), ChannelLayout::Rgba);
let colour = max_colour_diff(&want, &back);
assert!(colour <= 8, "{layout:?} {compression:?}: colour off by {colour}");
}
}
}
#[test]
fn block_compression_error_on_gradients_is_bounded() {
let (line, plane) = (ramp(64, 64, ChannelLayout::Rgba), gradient(64, 64));
for compression in BLOCKS {
let roundtrip = |img: &Image| decode(&encode(img, Format::Dds, &opts(compression, false)).unwrap()).unwrap();
let (line_back, plane_back) = (roundtrip(&line), roundtrip(&plane));
let (line_err, plane_err) = (max_colour_diff(&line, &line_back), max_colour_diff(&plane, &plane_back));
let p = psnr(&plane, &plane_back);
let alpha = max_diff_channel(&line, &line_back, 3);
// Colours on a line only lose the RGB565 rounding of the endpoints (up to 4 levels in
// red and blue). A gradient in two directions is not on a line: the spread off a
// block's best line alone limits this one to about 40 dB, and RGB565 costs a little
// more (the encoder measures 38.6 dB and 9 levels at most).
assert!(line_err <= 5, "{compression:?}: colour ramp off by {line_err}");
assert!(plane_err <= 10, "{compression:?}: 2D gradient off by {plane_err}");
assert!(p >= 38.0, "{compression:?}: 2D gradient PSNR {p:.1} dB");
match compression {
DdsCompression::Bc1 => assert!(line_back.data().chunks(4).zip(line.data().chunks(4)).all(|(b, i)| b[3] == if i[3] < 128 { 0 } else { 255 })),
// Four bits: within half a step of 17.
DdsCompression::Bc2 => assert!(alpha <= 8, "BC2 alpha off by {alpha}"),
// Eight values across a block's few levels of alpha.
_ => assert!(alpha <= 2, "BC3 alpha off by {alpha}"),
}
}
}
#[test]
fn solid_and_cutout_textures_keep_their_values() {
// RGB565-exact colours and BC2's 4-bit alpha steps come back exactly.
let solid = Image::from_u8(6, 5, ChannelLayout::Rgba, [255, 0, 255, 170].repeat(30)).unwrap();
for compression in BLOCKS {
let back = decode(&encode(&solid, Format::Dds, &opts(compression, false)).unwrap()).unwrap();
let want = if compression == DdsCompression::Bc1 { [255, 0, 255, 255] } else { [255, 0, 255, 170] };
assert!(back.data().chunks(4).all(|p| p == want), "{compression:?}: {:?}", &back.data()[..4]);
}
// A cutout: BC1's transparent texels decode as transparent black, the rest stay opaque.
let cutout: Vec<u8> = (0..36).flat_map(|i| if (i / 6 + i % 6) % 2 == 0 { [200, 40, 40, 255] } else { [10, 10, 10, 0] }).collect();
let cutout = Image::from_u8(6, 6, ChannelLayout::Rgba, cutout).unwrap();
for compression in BLOCKS {
let back = decode(&encode(&cutout, Format::Dds, &opts(compression, false)).unwrap()).unwrap();
assert_eq!(max_diff_channel(&cutout, &back, 3), 0, "{compression:?}");
if compression == DdsCompression::Bc1 {
assert!(back.data().chunks(4).filter(|p| p[3] == 0).all(|p| p == [0, 0, 0, 0]));
}
}
}
#[test]
fn mipmaps_write_the_full_chain_box_filtered() {
// 16×8 halves to 8×4, 4×2, 2×1 and 1×1: five levels.
let img = ramp(16, 8, ChannelLayout::Rgba);
let b = encode(&img, Format::Dds, &opts(DdsCompression::None, true)).unwrap();
assert_eq!(le32(&b, 8), REQUIRED | DDSD_PITCH | DDSD_MIPMAPCOUNT);
assert_eq!(le32(&b, 28), 5);
assert_eq!(le32(&b, 108), DDSCAPS_TEXTURE | DDSCAPS_COMPLEX | DDSCAPS_MIPMAP);
assert_eq!(b.len(), 128 + 4 * (16 * 8 + 8 * 4 + 4 * 2 + 2 + 1));
assert_eq!(decode(&b).unwrap().data(), img.data(), "the reader still reads the top level");
let mut above = img.clone();
for i in 1..5 {
let got = level(&b, i);
assert_eq!(got.dimensions(), ((16 >> i).max(1), (8 >> i).max(1)));
let want = box_filter(&above);
assert_eq!(got.data(), want.data(), "level {i}");
above = got;
}
// 1×1 has a single level.
let one = encode(&ramp(1, 1, ChannelLayout::Rgba), Format::Dds, &opts(DdsCompression::None, true)).unwrap();
assert_eq!((le32(&one, 28), le32(&one, 8) & DDSD_MIPMAPCOUNT), (1, 0));
}
/// The documented filter, written independently: each pixel averages the 2×2 pixels it covers,
/// and the last column and row of an odd-sized level take in the leftover pixel too.
fn box_filter(src: &Image) -> Image {
let (w, h) = (src.width() as usize, src.height() as usize);
let (nw, nh) = ((w / 2).max(1), (h / 2).max(1));
let ch = src.layout().channels();
let span = |i: usize, n: usize, size: usize| (2 * i, if i + 1 == n { size } else { 2 * i + 2 });
let mut out = Vec::new();
for y in 0..nh {
for x in 0..nw {
let ((x0, x1), (y0, y1)) = (span(x, nw, w), span(y, nh, h));
for c in 0..ch {
let values: Vec<u32> =
(y0..y1).flat_map(|sy| (x0..x1).map(move |sx| (sy, sx))).map(|(sy, sx)| u32::from(src.data()[(sy * w + sx) * ch + c])).collect();
let n = values.len() as u32;
out.push(((values.iter().sum::<u32>() + n / 2) / n) as u8);
}
}
}
Image::from_u8(nw as u32, nh as u32, src.layout(), out).unwrap()
}
#[test]
fn odd_sized_mip_chains_keep_every_pixel() {
// 5×3 → 2×1 → 1×1. The 2×1 level's right pixel averages columns 2–4 of all three rows.
let img = ramp(5, 3, ChannelLayout::Gray);
let b = encode(&img, Format::Dds, &opts(DdsCompression::None, true)).unwrap();
assert_eq!(le32(&b, 28), 3);
assert_eq!(b.len(), 128 + 5 * 3 + 2 + 1);
let second = level(&b, 1);
assert_eq!(second.data(), box_filter(&img).data());
let px = |x: usize, y: usize| u32::from(img.data()[y * 5 + x]);
let right: u32 = (0..3).flat_map(|y| (2..5).map(move |x| (x, y))).map(|(x, y)| px(x, y)).sum();
assert_eq!(u32::from(second.data()[1]), (right + 4) / 9);
}
#[test]
fn block_compressed_mip_chains_have_a_block_per_small_level() {
for compression in BLOCKS {
let block = if compression == DdsCompression::Bc1 { 8 } else { 16 };
let img = ramp(12, 6, ChannelLayout::Rgba);
let b = encode(&img, Format::Dds, &opts(compression, true)).unwrap();
// 12×6, 6×3, 3×1, 1×1: 3×2, 2×1, 1 and 1 blocks.
assert_eq!(le32(&b, 28), 4);
assert_eq!(le32(&b, 8), REQUIRED | DDSD_LINEARSIZE | DDSD_MIPMAPCOUNT);
assert_eq!(le32(&b, 20), 6 * block);
assert_eq!(b.len(), 128 + (6 + 2 + 1 + 1) * block as usize);
for i in 0..4 {
let got = level(&b, i);
assert_eq!(got.dimensions(), ((12 >> i).max(1), (6 >> i).max(1)), "{compression:?} level {i}");
}
}
}
#[test]
fn strips_and_tiny_textures_encode_with_every_option() {
for (w, h) in [(1, 1), (1, 9), (9, 1), (2, 3), (3, 2)] {
for compression in DdsCompression::ALL {
for mipmaps in [false, true] {
let img = ramp(w, h, ChannelLayout::Rgba);
let b = encode(&img, Format::Dds, &opts(compression, mipmaps)).unwrap_or_else(|e| panic!("{w}x{h} {compression:?} {mipmaps}: {e}"));
assert_eq!(decode(&b).unwrap().dimensions(), (w, h));
}
}
}
}
#[test]
fn fidelity_warnings_follow_the_compression() {
let translucent = ramp(8, 8, ChannelLayout::Rgba);
let warns = |c| fidelity_warnings_with(&translucent, Format::Dds, &opts(c, false));
assert!(warns(DdsCompression::None).is_empty(), "uncompressed RGBA loses nothing");
for c in BLOCKS {
assert!(warns(c).contains(&FidelityWarning::LossyCompression), "{c:?}");
}
assert!(warns(DdsCompression::Bc1).contains(&FidelityWarning::AlphaBinarized));
assert!(!warns(DdsCompression::Bc3).contains(&FidelityWarning::AlphaBinarized));
}
#[test]
fn compression_names_round_trip() {
for c in DdsCompression::ALL {
assert_eq!(DdsCompression::from_name(c.name()), Some(c));
}
assert_eq!(DdsCompression::from_name("DXT5"), Some(DdsCompression::Bc3));
assert_eq!(DdsCompression::from_name("dxt1"), Some(DdsCompression::Bc1));
for bad in ["", "bc7", "dxt2", "uncompressed "] {
assert_eq!(DdsCompression::from_name(bad), None, "{bad:?}");
}
}
/// Encode timings on a synthetic ~30 MP texture (6336×4752). Ignored by default; run in release:
///
/// ```sh
/// cargo test --release -p photocraft-codecs --test dds_write -- --ignored --nocapture
/// ```
#[test]
#[ignore]
fn dds_encode_timings_on_30_mp() {
let (w, h) = (6336u32, 4752u32);
let mut s = 0x1234_5678u32;
let mut data = Vec::with_capacity(w as usize * h as usize * 4);
for y in 0..h {
for x in 0..w {
s ^= s << 13;
s ^= s >> 17;
s ^= s << 5;
data.extend([(x / 25 + y / 19) as u8, (x / 31) as u8 ^ (s as u8 & 3), (y / 23) as u8, (255 - x / 25) as u8]);
}
}
let img = Image::from_u8(w, h, ChannelLayout::Rgba, data).unwrap();
for compression in DdsCompression::ALL {
for mipmaps in [false, true] {
let t = std::time::Instant::now();
let b = encode(&img, Format::Dds, &opts(compression, mipmaps)).unwrap();
eprintln!("{compression:?} mipmaps={mipmaps}: {:.0} ms, {} MB", t.elapsed().as_secs_f64() * 1e3, b.len() >> 20);
}
}
}
+3
View File
@@ -137,6 +137,9 @@ mod fits {
mod dpx {
cases!(Format::Dpx);
}
mod dds {
cases!(Format::Dds);
}
mod qoi {
cases!(Format::Qoi);
}
+120 -31
View File
@@ -1528,13 +1528,16 @@ fn bevel_height(shape: &Map, b: &Bevel, g: &BevelGeom, tex: &TextureCtx, pattern
tent(&mut h, g.width);
h
} else {
let sharp = sharp_edges(shape);
let (din, dout) = (bevel_dist_inside(shape, &sharp, size), bevel_dist_outside(shape, &sharp, size));
let mut h = Map::new(shape.w, shape.h, 0.0);
let [din, dout] = bevel_dists(shape, size);
let soft = g.chisel_soft > 0.0;
for i in 0..h.v.len() {
h.v[i] = bevel_chisel_h(g.paint, size, din[i], dout[i], soft);
}
let w = shape.w;
let v = par_fill(w, shape.h, 0.0, |y, row| {
for (x, o) in row.iter_mut().enumerate() {
let i = y * w + x;
*o = bevel_chisel_h(g.paint, size, din[i], dout[i], soft);
}
});
let mut h = Map { w, h: shape.h, v };
// Inner and outer Chisel Soft are not blurred in Photoshop (their facets equal Chisel
// Hard's across a straight edge); emboss styles keep the blur until measured.
if soft && g.paint == BevelPaint::Both {
@@ -1555,14 +1558,23 @@ fn bevel_height(shape: &Map, b: &Bevel, g: &BevelGeom, tex: &TextureCtx, pattern
let place = Placement::anchored(tex.anchor, t.link, t.phase, t.scale, 0.0);
let k = t.depth / unit;
let (x0, y0) = (tex.rect.x0, tex.rect.y0);
for y in 0..h.h {
for x in 0..h.w {
let (u, v) = place.map(f64::from(x0 + x as i32) + 0.5, f64::from(y0 + y as i32) + 0.5);
let p = tile.sample(u, v);
let l = (0.299 * p[0] + 0.587 * p[1] + 0.114 * p[2]) * p[3];
h.v[y * h.w + x] += k * if t.invert { 1.0 - l } else { l };
}
}
let w = h.w;
par_units(
h.v.len(),
&mut h.v,
w,
&mut [] as &mut [()],
0,
|| (),
|_, y, row, _| {
for (x, o) in row.iter_mut().enumerate() {
let (u, v) = place.map(f64::from(x0 + x as i32) + 0.5, f64::from(y0 + y as i32) + 0.5);
let p = tile.sample(u, v);
let l = (0.299 * p[0] + 0.587 * p[1] + 0.114 * p[2]) * p[3];
*o += k * if t.invert { 1.0 - l } else { l };
}
},
);
}
if b.soften >= 1.0 {
tent(&mut h, b.soften);
@@ -1644,23 +1656,33 @@ fn bevel_maps(shape: &Map, b: &Bevel, light: &GlobalLight, tex: &TextureCtx, pat
};
let shade_into = |depth: f32, region_kind: u8| -> (Map, Map) {
let (mut hi, mut sh) = (Map::new(shape.w, shape.h, 0.0), Map::new(shape.w, shape.h, 0.0));
for y in 0..shape.h as i64 {
for x in 0..shape.w as i64 {
let i = y as usize * shape.w + x as usize;
let gx = (hmap.get(x + 1, y) - hmap.get(x - 1, y)) * 0.5 * depth;
let gy = (hmap.get(x, y + 1) - hmap.get(x, y - 1)) * 0.5 * depth;
let n = [-gx, -gy, 1.0];
let len = (n[0] * n[0] + n[1] * n[1] + 1.0).sqrt();
let shade = (n[0] * light_v[0] + n[1] * light_v[1] + n[2] * light_v[2]) / len;
let region = if region_kind == 0 { shape.v[i] } else { f32::from(shape.v[i] < 1.0 - INSIDE_EPS && on_bevel(x, y)) };
let k = shade - se;
if k > 0.0 {
hi.v[i] = (k / (1.0 - se).max(1e-3)).clamp(0.0, 1.0) * region;
} else {
sh.v[i] = (-k / se.max(1e-3)).clamp(0.0, 1.0) * region;
// Rows on threads: every pixel reads only `hmap` and `shape`.
par_units(
hi.v.len(),
&mut hi.v,
shape.w,
&mut sh.v,
shape.w,
|| (),
|_, y, hi_row, sh_row| {
let y = y as i64;
for x in 0..shape.w as i64 {
let i = y as usize * shape.w + x as usize;
let gx = (hmap.get(x + 1, y) - hmap.get(x - 1, y)) * 0.5 * depth;
let gy = (hmap.get(x, y + 1) - hmap.get(x, y - 1)) * 0.5 * depth;
let n = [-gx, -gy, 1.0];
let len = (n[0] * n[0] + n[1] * n[1] + 1.0).sqrt();
let shade = (n[0] * light_v[0] + n[1] * light_v[1] + n[2] * light_v[2]) / len;
let region = if region_kind == 0 { shape.v[i] } else { f32::from(shape.v[i] < 1.0 - INSIDE_EPS && on_bevel(x, y)) };
let k = shade - se;
if k > 0.0 {
hi_row[x as usize] = (k / (1.0 - se).max(1e-3)).clamp(0.0, 1.0) * region;
} else {
sh_row[x as usize] = (-k / se.max(1e-3)).clamp(0.0, 1.0) * region;
}
}
}
}
},
);
(apply_contour(hi, &b.gloss_contour), apply_contour(sh, &b.gloss_contour))
};
let maps = match g.paint {
@@ -1676,7 +1698,13 @@ fn bevel_maps(shape: &Map, b: &Bevel, light: &GlobalLight, tex: &TextureCtx, pat
let (hi, sh) = shade_into(g.depth, 0);
let (ho, so) = shade_into(if g.pillow { -g.depth } else { g.depth }, 1);
let mix = |a: Map, b: Map| -> Map {
let v = a.v.iter().zip(&b.v).zip(&shape.v).map(|((x, y), s)| if *s > INSIDE_EPS { *x } else { 0.0 } + y * (1.0 - s.min(1.0))).collect();
let v = par_fill(a.w, a.h, 0.0, |y, row| {
for (x, o) in row.iter_mut().enumerate() {
let i = y * a.w + x;
let (x, y, s) = (a.v[i], b.v[i], shape.v[i]);
*o = if s > INSIDE_EPS { x } else { 0.0 } + y * (1.0 - s.min(1.0));
}
});
Map { w: a.w, h: a.h, v }
};
vec![mix(hi, ho), mix(sh, so)]
@@ -2309,6 +2337,18 @@ pub fn distance_field(kind: FieldKind, alpha: Vec<f32>, w: usize, h: usize, reac
}
}
/// Both chiselled bevel fields of `alpha`, `[BevelInside, BevelOutside]`: each is its
/// [`distance_field`], with the sharp edges they both start from found once.
pub fn bevel_distance_fields(alpha: Vec<f32>, w: usize, h: usize, reach: f32) -> [Vec<f32>; 2] {
bevel_dists(&Map { w, h, v: alpha }, reach)
}
/// The inside and outside bevel distances of `s`, from one set of sharp edges.
fn bevel_dists(s: &Map, reach: f32) -> [Vec<f32>; 2] {
let sharp = sharp_edges(s);
[bevel_dist_inside(s, &sharp, reach), bevel_dist_outside(s, &sharp, reach)]
}
#[cfg(test)]
mod tests {
use super::*;
@@ -2951,6 +2991,55 @@ mod tests {
}
}
#[test]
fn bevel_maps_do_not_depend_on_the_threads() {
// Every style and technique, softened or not, and a texture; one thread (in order) is the reference.
let mut patchy = aa_disc(101, 101, 40.0);
for (i, v) in patchy.v.iter_mut().enumerate() {
if (i % 101 / 13 + i / 101 / 17) % 5 == 0 {
*v *= 0.6;
}
}
let shapes = [aa_disc(97, 83, 30.0), aa_round_rect(131, 77, 9.0, 11.0, 120.0, 66.0, 14.0), aa_bar(90, 130, 60.0, 150.0, 25.0), patchy];
let mut sf = photocraft_raster::Surface::new(photocraft_color::PixelFormat::RGBA8);
sf.fill_rect(Rect::new(0, 0, 4, 2), &[0.0, 0.0, 0.0, 1.0]);
sf.fill_rect(Rect::new(0, 2, 4, 4), &[1.0, 1.0, 1.0, 1.0]);
let pats = [Pattern::new("stripes", sf, 4, 4)];
let light = GlobalLight::default();
let styles = [BevelStyle::InnerBevel, BevelStyle::OuterBevel, BevelStyle::Emboss, BevelStyle::PillowEmboss, BevelStyle::StrokeEmboss];
for (k, shape) in shapes.iter().enumerate() {
let tex = TextureCtx { rect: Rect::new(3, 5, 3 + shape.w as i32, 5 + shape.h as i32), patterns: &pats, anchor: (0.0, 0.0) };
for style in styles {
for technique in [BevelTechnique::Smooth, BevelTechnique::ChiselHard, BevelTechnique::ChiselSoft] {
for (size, soften, textured) in [(5.0, 0.0, false), (12.0, 2.0, false), (8.0, 0.0, true)] {
let mut b = bevel_of(style, technique);
(b.size, b.soften) = (size, soften);
if textured {
b.texture = Some(photocraft_doc::BevelTexture {
name: "stripes".into(),
id: String::new(),
scale: 1.0,
depth: 1.0,
invert: false,
link: true,
phase: (0.0, 0.0),
});
}
let run = || bevel_maps(shape, &b, &light, &tex, &PreparedPatterns::new(&pats, PREPARED_PATTERN_BYTES)).0;
let one = with_threads(1, run);
for threads in [2, 5, 16] {
let many = with_threads(threads, run);
assert_eq!(one.len(), many.len());
for (j, (a, m)) in one.iter().zip(&many).enumerate() {
assert_eq!(bits(&a.v), bits(&m.v), "shape {k}, {style:?} {technique:?} size {size} map {j} on {threads} threads");
}
}
}
}
}
}
}
/// The four fields that use the distance transform, of `m`.
fn distance_field_of(m: &Map, reach: f32) -> Vec<Vec<f32>> {
[FieldKind::Inside, FieldKind::Outside, FieldKind::BevelInside, FieldKind::BevelOutside]
+36 -6
View File
@@ -288,8 +288,8 @@ pub(crate) fn import(name: &str, bytes: &[u8]) -> Result<Document> {
Ok(r.document)
}
/// What a headless save writes beyond the format: JPEG quality and format, TIFF layers, Targa bits,
/// PSD previews and the PSD merged image.
/// What a headless save writes beyond the format: JPEG quality and format, TIFF layers, Targa
/// bits, DPX bits, DDS compression and mipmaps, PSD previews and the PSD merged image.
#[derive(Debug, Clone, Copy)]
pub(crate) struct SaveOpts {
/// Photoshop's 0–12 JPEG scale.
@@ -303,6 +303,9 @@ pub(crate) struct SaveOpts {
pub tga_bits: Option<photocraft_io::TgaBits>,
/// DPX: 8, 10, 12 or 16 bits per sample (`"dpxBits"`); unset, 8 for an 8-bit document, else 16.
pub dpx_bits: Option<photocraft_io::DpxBits>,
/// DDS: the compression and whether to write the mip chain (`"ddsCompression"`,
/// `"ddsMipmaps"`); unset, uncompressed without mipmaps.
pub dds: photocraft_io::DdsOptions,
/// PSD/PSB: embed the thumbnail resource (Preferences › File Handling › Image Previews).
pub image_previews: bool,
/// PSD/PSB: write the real merged image (Preferences › File Handling › Maximize PSD and PSB
@@ -318,6 +321,7 @@ impl Default for SaveOpts {
tiff_layers: false,
tga_bits: None,
dpx_bits: None,
dds: photocraft_io::DdsOptions::default(),
image_previews: true,
psd_maximize_compatibility: true,
}
@@ -338,6 +342,7 @@ impl SaveOpts {
tiff_layers: p.get("tiffLayers").and_then(Value::as_bool).unwrap_or(false),
tga_bits: tga_bits_param(p, cmd)?,
dpx_bits: dpx_bits_param(p, cmd)?,
dds: dds_param(p, cmd)?.unwrap_or_default(),
image_previews: file_handling.image_previews != crate::prefs::Ask::Never,
psd_maximize_compatibility: file_handling.maximize_psd_compatibility != crate::prefs::Ask::Never,
})
@@ -401,6 +406,30 @@ pub fn dpx_bits_param(p: &Value, cmd: &str) -> Result<Option<photocraft_io::DpxB
}
}
/// The DDS options in `p`: `"ddsCompression": "none" | "bc1" | "bc2" | "bc3"` (or the DXT names)
/// and `"ddsMipmaps": bool`. `None` when neither is given (Save As then asks); either alone
/// leaves the other at its default (uncompressed, no mipmaps). Anything else is a bad parameter
/// of command `cmd`.
pub fn dds_param(p: &Value, cmd: &str) -> Result<Option<photocraft_io::DdsOptions>> {
let bad = |msg: String| EngineError::BadParams { cmd: cmd.into(), msg };
let compression = match p.get("ddsCompression") {
None | Some(Value::Null) => None,
Some(v) => Some(
v.as_str()
.and_then(photocraft_io::DdsCompression::from_name)
.ok_or_else(|| bad(format!("`ddsCompression` must be \"none\", \"bc1\", \"bc2\" or \"bc3\", got {v}")))?,
),
};
let mipmaps = match p.get("ddsMipmaps") {
None | Some(Value::Null) => None,
Some(v) => Some(v.as_bool().ok_or_else(|| bad(format!("`ddsMipmaps` must be true or false, got {v}")))?),
};
if compression.is_none() && mipmaps.is_none() {
return Ok(None);
}
Ok(Some(photocraft_io::DdsOptions { compression: compression.unwrap_or_default(), mipmaps: mipmaps.unwrap_or(false) }))
}
/// Encodes `doc` for `path`'s extension.
pub(crate) fn encode(doc: &Document, path: &str, save: impl Into<SaveOpts>) -> Result<(Vec<u8>, Vec<String>)> {
crate::allocation::checkpoint("saving document")?;
@@ -418,6 +447,7 @@ pub(crate) fn encode(doc: &Document, path: &str, save: impl Into<SaveOpts>) -> R
// lossy or lossless as it was), as Photoshop's Save does.
photocraft_io::apply_file_encoding(doc, &mut opts.encode);
opts.encode.dpx_bits = save.dpx_bits;
opts.encode.dds = save.dds;
if let Some(q) = save.quality {
let q = jpeg_quality(q);
opts.encode.jpeg_quality = q;
@@ -1685,7 +1715,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Save a Copy…",
&["File"],
Some("Cmd+Alt+S"),
r##"{"path":str (format from the extension),"quality":0..12? (JPEG),"jpegFormat":"baseline|optimized|progressive"? (JPEG; default baseline),"layers":bool=true,"overwrite":bool=false (replace the document's own file),"tiffLayers":bool=false (TIFF: keep the layers; flat by default),"tgaBits":24|32? (TGA: 32 writes the alpha channel, or else the transparency, as the alpha; unset, 32 with an alpha channel or transparency, else 24),"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16)}"##,
r##"{"path":str (format from the extension),"quality":0..12? (JPEG),"jpegFormat":"baseline|optimized|progressive"? (JPEG; default baseline),"layers":bool=true,"overwrite":bool=false (replace the document's own file),"tiffLayers":bool=false (TIFF: keep the layers; flat by default),"tgaBits":24|32? (TGA: 32 writes the alpha channel, or else the transparency, as the alpha; unset, 32 with an alpha channel or transparency, else 24),"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"ddsCompression":"none|bc1|bc2|bc3"="none" (DDS: uncompressed, or block compression; BC1 keeps alpha on or off),"ddsMipmaps":bool=false (DDS: also write the full mip chain)}"##,
native_doc,
save_a_copy
),
@@ -1748,7 +1778,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Batch…",
&["File", "Automate"],
None,
r##"{"steps":[[commandId,params]|{"command":id,"params":{}}…] (a recorded action),"input":folder|[paths],"includeSubfolders":bool=false (a folder input also takes the images in its subfolders, up to 32 levels and 100000 files; each result keeps its subfolder under output),"output":folder,"format":"same|png|jpg|psd|tiff|…"="same","quality":0..12?,"jpegFormat":"baseline|optimized|progressive"?,"naming":string? (blank keeps source names; {name}, {serial} padded to 3 digits, {date} as YYYY-MM-DD UTC; extension added automatically),"startingSerial":1..4294967295=1 (input position, including failed inputs),"tiffLayers":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"calledActions":[{"name","steps"}…]? (the actions Play Action steps call; default: the session's)} → {files, errors} (an input whose output name was already written in the run goes to errors)"##,
r##"{"steps":[[commandId,params]|{"command":id,"params":{}}…] (a recorded action),"input":folder|[paths],"includeSubfolders":bool=false (a folder input also takes the images in its subfolders, up to 32 levels and 100000 files; each result keeps its subfolder under output),"output":folder,"format":"same|png|jpg|psd|tiff|…"="same","quality":0..12?,"jpegFormat":"baseline|optimized|progressive"?,"naming":string? (blank keeps source names; {name}, {serial} padded to 3 digits, {date} as YYYY-MM-DD UTC; extension added automatically),"startingSerial":1..4294967295=1 (input position, including failed inputs),"tiffLayers":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"ddsCompression":"none|bc1|bc2|bc3"="none" (DDS: uncompressed, or block compression; BC1 keeps alpha on or off),"ddsMipmaps":bool=false (DDS: also write the full mip chain),"calledActions":[{"name","steps"}…]? (the actions Play Action steps call; default: the session's)} → {files, errors} (an input whose output name was already written in the run goes to errors)"##,
native,
batch
),
@@ -1757,7 +1787,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Image Processor…",
&["File", "Scripts"],
None,
r##"{"input":folder|[paths],"includeSubfolders":bool=false (a folder input also takes the images in its subfolders, up to 32 levels and 100000 files; each result keeps its subfolder under output),"output":folder,"format":"jpg|png|psd|tiff|…"="jpg","quality":0..12=8,"jpegFormat":"baseline|optimized|progressive"?,"tiffLayers":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"width":px?,"height":px? (fit, never enlarge),"convertToSrgb":bool=false} → {files, errors} (an input whose output name was already written in the run goes to errors)"##,
r##"{"input":folder|[paths],"includeSubfolders":bool=false (a folder input also takes the images in its subfolders, up to 32 levels and 100000 files; each result keeps its subfolder under output),"output":folder,"format":"jpg|png|psd|tiff|…"="jpg","quality":0..12=8,"jpegFormat":"baseline|optimized|progressive"?,"tiffLayers":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"ddsCompression":"none|bc1|bc2|bc3"="none" (DDS: uncompressed, or block compression; BC1 keeps alpha on or off),"ddsMipmaps":bool=false (DDS: also write the full mip chain),"width":px?,"height":px? (fit, never enlarge),"convertToSrgb":bool=false} → {files, errors} (an input whose output name was already written in the run goes to errors)"##,
native,
image_processor
),
@@ -1785,7 +1815,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Layers to Files…",
&["File", "Export"],
None,
r##"{"dir":folder,"format":"png|jpg|psd|tiff|…"="png","prefix":str=document name,"visibleOnly":bool=true,"quality":0..12?,"jpegFormat":"baseline|optimized|progressive"?,"tiffLayers":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16)} → {files}"##,
r##"{"dir":folder,"format":"png|jpg|psd|tiff|…"="png","prefix":str=document name,"visibleOnly":bool=true,"quality":0..12?,"jpegFormat":"baseline|optimized|progressive"?,"tiffLayers":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"ddsCompression":"none|bc1|bc2|bc3"="none" (DDS: uncompressed, or block compression; BC1 keeps alpha on or off),"ddsMipmaps":bool=false (DDS: also write the full mip chain)} → {files}"##,
native_doc,
layers_to_files
),
+47
View File
@@ -219,6 +219,53 @@ fn save_a_copy_writes_the_dpx_bits_asked_for() {
}
}
/// `ddsCompression` picks the DDS FourCC (or none, uncompressed) and `ddsMipmaps` the mip count
/// in the header; unset, an uncompressed texture without mipmaps. Bad values are refused before
/// anything is written.
#[test]
fn save_a_copy_writes_the_dds_options_asked_for() {
let dir = tmp("dds-options");
let le32 = |b: &[u8], at: usize| u32::from_le_bytes(b[at..at + 4].try_into().unwrap());
for (params, fourcc, mips) in [
(json!({}), [0; 4], 1),
(json!({"ddsCompression": "none", "ddsMipmaps": true}), [0; 4], 5),
(json!({"ddsCompression": "bc1"}), *b"DXT1", 1),
(json!({"ddsCompression": "DXT3", "ddsMipmaps": false}), *b"DXT3", 1),
(json!({"ddsCompression": "bc3", "ddsMipmaps": true}), *b"DXT5", 5),
(json!({"ddsCompression": null, "ddsMipmaps": null}), [0; 4], 1),
] {
let mut s = session(16, 12, 8);
let out = join(&dir, "a.dds");
let mut p = params.clone();
p["path"] = json!(out);
s.execute("file.saveACopy", p).unwrap();
let bytes = std::fs::read(&out).unwrap();
assert_eq!((&bytes[..4], &bytes[84..88], le32(&bytes, 28)), (&b"DDS "[..], &fourcc[..], mips), "{params}");
assert_eq!(photocraft_io::import("a.dds", &bytes).unwrap().document.size, photocraft_geom::Size::new(16, 12), "{params}");
}
let mut s = session(8, 8, 8);
let state = |s: &Session| s.active().map(|st| (st.revision, st.path.clone(), st.is_dirty()));
let before = state(&s);
for bad in [json!({"ddsCompression": "bc7"}), json!({"ddsCompression": 1}), json!({"ddsMipmaps": "yes"}), json!({"ddsMipmaps": 1})] {
let out = join(&dir, "bad.dds");
let mut p = bad.clone();
p["path"] = json!(out);
let r = s.execute("file.saveACopy", p);
assert!(matches!(r, Err(EngineError::BadParams { .. })), "{bad}: {r:?}");
assert!(!std::path::Path::new(&out).exists(), "{bad}: nothing written");
assert_eq!(state(&s), before, "{bad}: the document is untouched");
}
}
#[test]
fn dds_params_are_none_until_one_is_given() {
use photocraft_io::{DdsCompression, DdsOptions};
assert_eq!(dds_param(&json!({}), "file.saveAs").unwrap(), None);
assert_eq!(dds_param(&json!({"ddsCompression": null}), "file.saveAs").unwrap(), None);
assert_eq!(dds_param(&json!({"ddsMipmaps": true}), "file.saveAs").unwrap(), Some(DdsOptions { compression: DdsCompression::None, mipmaps: true }));
assert_eq!(dds_param(&json!({"ddsCompression": "bc2"}), "file.saveAs").unwrap(), Some(DdsOptions { compression: DdsCompression::Bc2, mipmaps: false }));
}
/// A `tgaBits` other than 24 or 32 is a bad parameter, reported before anything is written.
#[test]
fn bad_targa_bits_are_refused_before_writing() {
+76 -14
View File
@@ -49,13 +49,32 @@ pub(crate) fn file_bytes(p: &Value, cmd: &str, what: &str) -> Result<(Vec<u8>, S
Ok((bytes, stem))
}
/// A name not used by any preset in `taken` (case-insensitive): `name`, `name 2`, `name 3`…
fn unique(name: &str, taken: &[String]) -> String {
let free = |n: &str| !taken.iter().any(|t| t.eq_ignore_ascii_case(n));
if free(name) {
return name.to_string();
/// The preset names in use, for naming new presets.
struct UniqueNames {
/// Every name in use, ASCII-lowercased.
taken: std::collections::HashSet<String>,
/// The next number to try for each repeated name (lowercased).
next: std::collections::HashMap<String, usize>,
}
impl UniqueNames {
fn new<'a>(names: impl IntoIterator<Item = &'a str>) -> Self {
UniqueNames { taken: names.into_iter().map(str::to_ascii_lowercase).collect(), next: std::collections::HashMap::new() }
}
/// A name not in use (case-insensitive): `name`, `name 2`, `name 3`… It is in use from then on.
fn unique(&mut self, name: &str) -> String {
let key = name.to_ascii_lowercase();
if self.taken.insert(key.clone()) {
return name.to_string();
}
// Resume numbering per name, so a file of identical names stays linear.
let from = self.next.get(&key).copied().unwrap_or(2);
let i = (from..).find(|i| !self.taken.contains(&format!("{key} {i}"))).unwrap_or(from);
self.taken.insert(format!("{key} {i}"));
self.next.insert(key, i + 1);
format!("{name} {i}")
}
(2..).map(|i| format!("{name} {i}")).find(|n| free(n)).unwrap_or_else(|| name.to_string())
}
fn import_abr(s: &mut Session, p: &Value) -> Result<Value> {
@@ -93,11 +112,10 @@ fn add_abr_presets(s: &mut Session, p: &Value, group: String, imp: photocraft_io
if p.get("replace").and_then(Value::as_bool).unwrap_or(true) {
s.tools.presets.retain(|x| x.builtin || x.group != group);
}
let mut taken: Vec<String> = s.tools.presets.iter().map(|x| x.name.clone()).collect();
let mut taken = UniqueNames::new(s.tools.presets.iter().map(|x| x.name.as_str()));
let mut names = Vec::with_capacity(imp.presets.len());
for mut preset in imp.presets {
preset.name = unique(&preset.name, &taken);
taken.push(preset.name.clone());
preset.name = taken.unique(&preset.name);
names.push(preset.name.clone());
s.tools.presets.push(preset);
}
@@ -180,8 +198,7 @@ fn import_image(s: &mut Session, p: &Value) -> Result<Value> {
let doc = crate::file_cmds::import(decode_name, &bytes)?;
let (v, w, h) = image_paint(&doc, cmd)?;
let (brush, tw, th) = crate::brush_cmds::sampled_brush(cmd, &v, w, h)?;
let taken: Vec<String> = s.tools.presets.iter().map(|x| x.name.clone()).collect();
let name = unique(&name, &taken);
let name = UniqueNames::new(s.tools.presets.iter().map(|x| x.name.as_str())).unique(&name);
s.tools.presets.push(photocraft_paint::BrushPreset {
name: name.clone(),
brush: brush.clone(),
@@ -526,10 +543,55 @@ mod tests {
assert_eq!(s.tools.presets.len(), n, "a failed import adds nothing");
}
/// A version 1 brush with a computed tip and no name is named after its tip ("9 px Round"). The
/// import used to number each repeated name by searching the preset names once for every number
/// it tried, so importing 10,000 such brushes took minutes. The test fails when the import takes
/// more than 20 seconds.
#[test]
fn repeated_brush_names_are_numbered_in_linear_time() {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut s = Session::new();
s.execute("brush.presets.save", json!({"name": "9 PX ROUND 3"})).unwrap();
let round = LegacyBrush {
name: String::new(),
spacing: 25,
anti_alias: true,
tip: LegacyTip::Computed { diameter: 9, hardness: 100, angle: 0, roundness: 100 },
};
let data = photocraft_paint::tile::b64_encode(&write_v12(1, &vec![round; 10_000], false).unwrap());
let r = s.execute("brush.presets.importAbr", json!({"data": data})).unwrap();
let names: Vec<String> = r["imported"].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect();
let _ = tx.send(names);
});
let names = match rx.recv_timeout(std::time::Duration::from_secs(20)) {
Ok(names) => names,
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => panic!("importing the brushes did not finish"),
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => panic!("the import thread panicked"),
};
// Numbering starts at 2 and skips the number a preset already uses, in any letter case.
assert_eq!(names[..4], ["9 px Round", "9 px Round 2", "9 px Round 4", "9 px Round 5"]);
assert_eq!(names.last(), Some(&format!("9 px Round {}", names.len() + 1)));
assert_eq!(names.iter().collect::<std::collections::HashSet<_>>().len(), names.len());
}
#[test]
fn repeated_brush_names_take_the_first_free_number() {
let mut s = Session::new();
s.execute("brush.presets.save", json!({"name": "speckle 3"})).unwrap();
let tip = AbrSample { id: String::new(), width: 6, height: 4, depth: 8, data: vec![255; 24] };
let brush = |name: &str| LegacyBrush { name: name.into(), spacing: 25, anti_alias: true, tip: LegacyTip::Sampled(tip.clone()) };
let file = write_v12(2, &["Speckle", "SPECKLE", "speckle", "Speckle 2", "Speckle", "hard round"].map(brush), true).unwrap();
let r = s.execute("brush.presets.importAbr", json!({"data": photocraft_paint::tile::b64_encode(&file)})).unwrap();
// Names that differ only in letter case count as the same name. The file's "Speckle 2" comes
// after "SPECKLE" took that name, and "hard round" clashes with the built-in Hard Round.
assert_eq!(r["imported"], json!(["Speckle", "SPECKLE 2", "speckle 4", "Speckle 2 2", "Speckle 5", "hard round 2"]));
}
#[test]
fn unique_names() {
let taken = vec!["A".to_string(), "a 2".to_string()];
assert_eq!(unique("a", &taken), "a 3");
assert_eq!(unique("B", &taken), "B");
let mut taken = UniqueNames::new(["A", "a 2"]);
assert_eq!(taken.unique("a"), "a 3");
assert_eq!(taken.unique("B"), "B");
}
}
+14 -7
View File
@@ -32,17 +32,17 @@ use std::os::fd::BorrowedFd;
/// Largest attribute-name list [`copy_xattrs`] will read. Names are short; a file with a
/// megabyte of them is not something a save should spend memory on.
#[cfg(unix)]
#[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))]
const MAX_XATTR_NAMES: usize = 1 << 20;
/// Largest single attribute value [`copy_xattrs`] will carry over. Finder tags and `user.*`
/// attributes are tens of bytes; a resource fork can be large, and past this it is not worth
/// holding a copy of it in memory during a save.
#[cfg(unix)]
#[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))]
const MAX_XATTR_VALUE: usize = 16 << 20;
/// First read size for an attribute list or value; grown on `ERANGE`.
#[cfg(unix)]
#[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))]
const XATTR_CHUNK: usize = 4 << 10;
/// Copy, best effort, everything [`crate::atomic::atomic_write`] should carry from the file it
@@ -92,7 +92,7 @@ fn copy_xattrs_at(from: &Path, to: &Path) -> io::Result<()> {
/// `com.apple.rootless` on macOS and `security.*` on Linux belong to the system and refuse to
/// be written by an ordinary process, and failing a document save over one would be absurd.
/// `Err` means the attribute *list* could not be read, so nothing was copied.
#[cfg(unix)]
#[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))]
pub fn copy_xattrs(from: BorrowedFd<'_>, to: BorrowedFd<'_>) -> io::Result<()> {
use rustix::fs::{XattrFlags, fsetxattr};
@@ -106,9 +106,16 @@ pub fn copy_xattrs(from: BorrowedFd<'_>, to: BorrowedFd<'_>) -> io::Result<()> {
Ok(())
}
/// [`copy_xattrs`] where rustix offers no extended-attribute calls (FreeBSD and the other BSDs):
/// nothing is copied, as on Windows.
#[cfg(all(unix, not(any(target_os = "linux", target_os = "android", target_vendor = "apple"))))]
pub fn copy_xattrs(_from: BorrowedFd<'_>, _to: BorrowedFd<'_>) -> io::Result<()> {
Ok(())
}
/// Attributes that describe how the *old* file's bytes are stored rather than anything about
/// the document, and so must never be pinned onto a file holding different bytes.
#[cfg(unix)]
#[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))]
fn skip_xattr(name: &[u8]) -> bool {
// `com.apple.decmpfs` marks a file whose data lives compressed inside the attribute itself.
// The kernel hides it from a plain `listxattr` and refuses to set it, but a file claiming
@@ -117,7 +124,7 @@ fn skip_xattr(name: &[u8]) -> bool {
}
/// The NUL-separated attribute names of `fd`.
#[cfg(unix)]
#[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))]
fn list_xattrs(fd: BorrowedFd<'_>) -> io::Result<Vec<u8>> {
let mut buf = vec![0u8; XATTR_CHUNK];
loop {
@@ -137,7 +144,7 @@ fn list_xattrs(fd: BorrowedFd<'_>) -> io::Result<Vec<u8>> {
/// The value of one attribute, or `None` if it cannot be read or is larger than
/// [`MAX_XATTR_VALUE`]. A missing value is normal: another process may remove an attribute
/// between the listing and the read.
#[cfg(unix)]
#[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))]
fn read_xattr(fd: BorrowedFd<'_>, name: &[u8]) -> Option<Vec<u8>> {
let mut buf = vec![0u8; XATTR_CHUNK];
loop {
+2 -1
View File
@@ -36,7 +36,7 @@ fn assert_no_temp(dir: &Path) {
// ---------------------------------------------------------------- extended attributes (Unix)
#[cfg(unix)]
#[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))]
mod xattrs {
use super::*;
@@ -164,6 +164,7 @@ fn replacing_a_file_keeps_its_mode() {
atomic_write(&p, b"second").unwrap();
assert_eq!(std::fs::metadata(&p).unwrap().permissions().mode() & 0o777, 0o600);
assert_no_temp(&d.0);
}
// ----------------------------------------------------------------------------- ACL (macOS)
+84 -15
View File
@@ -203,6 +203,11 @@ impl B {
self.fields.push((kind, reach_for(width)));
In::Field(kind)
}
/// A chiselled bevel's inside and outside fields, always asked for together ([`bevel_fields`]
/// computes them as one).
fn bevel_fields(&mut self, width: f32) -> [In; 2] {
[FieldKind::BevelInside, FieldKind::BevelOutside].map(|k| self.field(k, width))
}
/// `Map::shifted` by whole pixels (`invert`: of `1 - a`); identity shifts of the plain map
/// are skipped.
fn shift(&mut self, src: In, dx: f32, dy: f32, outside: f32, invert: bool) -> In {
@@ -374,8 +379,7 @@ pub(crate) fn program_with(e: &Effect, light: &GlobalLight, vector_shape: bool,
let mut h = if bv.technique == BevelTechnique::Smooth {
b.conv(In::Shape, photocraft_compose::effects::tent_kernel(g.width))
} else {
let din = b.field(FieldKind::BevelInside, size);
let dout = b.field(FieldKind::BevelOutside, size);
let [din, dout] = b.bevel_fields(size);
let soft = g.chisel_soft > 0.0;
let h = b.push(stage(Kernel::MBevelH, Some(din), Some(dout), [paint(g.paint), size, f32::from(u8::from(soft)), 0.0], 0));
// As `effects::bevel_height`: only emboss styles blur a Chisel Soft ramp.
@@ -542,20 +546,51 @@ fn threaded_inside(kind: FieldKind) -> bool {
/// fields run on all threads inside `distance_field`, from one window. That is also the window the CPU
/// compositor computes them from, so a rebuilt field is exactly the one it gives.
fn field_banded(kind: FieldKind, reach: i32, shape: &[f32], region: Rect, out: Rect, halo: i32) -> Vec<f32> {
if threaded_inside(kind) {
let win = out.inflate(halo).intersect(&region);
if win.is_empty() {
return Vec::new();
}
let f = photocraft_compose::effects::distance_field(kind, crop(shape, region, win), win.width() as usize, win.height() as usize, reach as f32);
return if win == out { f } else { crop(&f, win, out) };
}
let parts = par_map(bands(out, BAND.max(halo)), |band| {
let win = band.inflate(halo).intersect(&region);
let f = photocraft_compose::effects::distance_field(kind, crop(shape, region, win), win.width() as usize, win.height() as usize, reach as f32);
crop(&f, win, band)
let [f] = fields_in_windows(threaded_inside(kind), shape, region, out, halo, |s, w, h| {
[photocraft_compose::effects::distance_field(kind, s, w, h, reach as f32)]
});
parts.concat()
f
}
/// [`field`] of both bevel fields over the same `out`, `[BevelInside, BevelOutside]`, with the
/// sharp edges of each window found once (the two kinds have the same radius and threading).
pub(crate) fn bevel_fields(reach: i32, shape: &[f32], region: Rect, out: Rect) -> [Vec<f32>; 2] {
let kind = FieldKind::BevelInside;
fields_in_windows(threaded_inside(kind), shape, region, out, field_radius(kind, reach) + 1, |s, w, h| {
photocraft_compose::effects::bevel_distance_fields(s, w, h, reach as f32)
})
}
/// The fields `compute` gives for a `w × h` window of the region-sized `shape`, over `out` (inside
/// `region`), each window grown by `halo`: `out` as one window when `whole` (the field spreads over
/// threads itself), else bands of it in parallel.
fn fields_in_windows<const N: usize>(
whole: bool,
shape: &[f32],
region: Rect,
out: Rect,
halo: i32,
compute: impl Fn(Vec<f32>, usize, usize) -> [Vec<f32>; N] + Sync + Send,
) -> [Vec<f32>; N] {
let part = |r: Rect| -> [Vec<f32>; N] {
let win = r.inflate(halo).intersect(&region);
if win.is_empty() {
return std::array::from_fn(|_| Vec::new());
}
let f = compute(crop(shape, region, win), win.width() as usize, win.height() as usize);
if win == r { f } else { f.map(|f| crop(&f, win, r)) }
};
if whole {
return part(out);
}
let parts = par_map(bands(out, BAND.max(halo)), part);
std::array::from_fn(|k| {
let mut f = Vec::with_capacity(parts.iter().map(|p| p[k].len()).sum());
for p in &parts {
f.extend_from_slice(&p[k]);
}
f
})
}
/// The layer's shape (`compose::layer_shape`) over `r`, in parallel bands.
@@ -875,6 +910,40 @@ mod tests {
assert!(too_small_noticed, "a 2 row halo went unnoticed, so this test guards nothing");
}
/// Both bevel fields from one window (sharp edges found once) are bit for bit the fields
/// computed one at a time, over the whole region and over a part of it.
#[test]
fn paired_bevel_fields_equal_the_fields_one_at_a_time() {
let region = Rect::new(-20, -10, 300, 290);
let (w, h) = (region.width() as usize, region.height() as usize);
let mut shape = vec![0.0f32; w * h];
for y in 0..h {
for x in 0..w {
let (fx, fy) = (x as f32 - 150.0, y as f32 - 140.0);
let r = (fx * fx + fy * fy).sqrt() + 8.0 * (fy * 0.07).sin() * (fx * 0.05).cos();
shape[y * w + x] = (90.0 - r).clamp(0.0, 1.0) * if (x / 13 + y / 17) % 5 == 0 { 0.6 } else { 1.0 };
}
}
let bits = |v: &[f32]| v.iter().map(|x| x.to_bits()).collect::<Vec<_>>();
for reach in [3, 9, 40] {
for out in [region, Rect::new(40, 30, 170, 120), Rect::new(-20, 200, 300, 290)] {
let [inside, outside] = bevel_fields(reach, &shape, region, out);
assert_eq!(bits(&inside), bits(&field(FieldKind::BevelInside, reach, &shape, region, out)), "inside, reach {reach}, {out:?}");
assert_eq!(bits(&outside), bits(&field(FieldKind::BevelOutside, reach, &shape, region, out)), "outside, reach {reach}, {out:?}");
// The banded path (wasm) pairs them per band the same way.
let halo = field_radius(FieldKind::BevelInside, reach) + 1;
let banded =
fields_in_windows(false, &shape, region, out, halo, |s, w, h| photocraft_compose::effects::bevel_distance_fields(s, w, h, reach as f32));
for (k, kind) in [FieldKind::BevelInside, FieldKind::BevelOutside].into_iter().enumerate() {
let [one] = fields_in_windows(false, &shape, region, out, halo, |s, w, h| {
[photocraft_compose::effects::distance_field(kind, s, w, h, reach as f32)]
});
assert_eq!(bits(&banded[k]), bits(&one), "banded {kind:?}, reach {reach}, {out:?}");
}
}
}
}
/// #3646: a stroke's outer edge is the fitted 5 × 5 chamfer while it is narrow and the exact
/// Euclidean transform once it is wide — the chamfer's octagon facets a wide stroke. The GPU
/// builds it from the field its width picks (`stroke_outside_exact`), while the spread of drop
+39 -24
View File
@@ -1169,7 +1169,7 @@ impl Compositor {
let aprons: Vec<i32> = plan.fx.iter().zip(&paged).map(|(f, &pg)| if pg { Self::fx_apron(doc, f) } else { 0 }).collect();
for (f, &pg) in plan.fx.iter().zip(&paged) {
if !pg {
self.sync_fx(device, queue, encoder, doc, f, (f.layer.id, None), f.region, &mut stats);
self.sync_fx(device, queue, encoder, doc, f, (f.layer.id, None), f.region, &mut stats)?;
}
}
let fr = self.frame_res(device, queue, canvas, &plan);
@@ -1200,7 +1200,7 @@ impl Compositor {
continue;
}
let window = pf.inflate(aprons.get(i).copied().unwrap_or(0)).intersect(&f.region);
self.sync_fx(device, queue, encoder, doc, f, (f.layer.id, Some(cell)), window, &mut stats);
self.sync_fx(device, queue, encoder, doc, f, (f.layer.id, Some(cell)), window, &mut stats)?;
}
let chunks = grid_rects(part, self.chunk);
stats.chunks += chunks.len();
@@ -1811,7 +1811,7 @@ impl Compositor {
key: FxKey,
region: Rect,
stats: &mut Stats,
) {
) -> Result<(), Unsupported> {
let layer = f.layer;
let canvas = doc.bounds();
let is_group = matches!(layer.content, LayerContent::Group(_));
@@ -1882,13 +1882,13 @@ impl Compositor {
}
// Present: kept or (re)inserted above.
let stamp = self.stamp;
let Some(e) = self.fx.get_mut(&key) else { return };
let Some(e) = self.fx.get_mut(&key) else { return Ok(()) };
e.last_used = frame;
e.stamp = stamp;
e.doc = doc.id;
e.tiles = [content_src.map(fx::snapshot), mask_src.map(fx::snapshot)];
if region.is_empty() {
return;
return Ok(());
}
let t_trace = web_time_now();
@@ -1910,27 +1910,41 @@ impl Compositor {
layer.effects.items.iter().filter(|e| e.enabled()).map(|e| fx::program_with(e, &doc.global_light, vector_shape, &doc.patterns, anchor)).collect();
let want = field_reaches(&progs);
e.fields.retain(|k, _| want.contains_key(k));
for (&kind, &reach) in &want {
let have = e.fields.get(&kind).map(|(r, _)| *r);
let out = match have {
Some(r) if r >= reach => {
if damage.is_empty() {
continue;
}
damage.inflate(fx::field_radius(kind, r)).intersect(&region)
// Each field to recompute: over which rectangle, exact up to which reach.
let jobs: Vec<(FieldKind, Rect, i32)> = want
.iter()
.filter_map(|(&kind, &reach)| match e.fields.get(&kind).map(|(r, _)| *r) {
Some(r) if r >= reach => (!damage.is_empty()).then(|| (kind, damage.inflate(fx::field_radius(kind, r)).intersect(&region), r)),
_ => Some((kind, region, reach)),
})
.collect();
// A chiselled bevel's two fields are asked for and kept together (`fx::bevel_fields`), so they
// are recomputed together, over one rectangle at one reach.
let job = |k: FieldKind| jobs.iter().find(|j| j.0 == k).map(|j| (j.1, j.2));
let (bevel_in, bevel_out) = (job(FieldKind::BevelInside), job(FieldKind::BevelOutside));
if bevel_in != bevel_out {
return Err(Unsupported(format!("bevel fields of `{}` out of step: inside {bevel_in:?}, outside {bevel_out:?}", layer.name)));
}
for (kind, out, reach) in jobs {
let fields = match kind {
FieldKind::BevelInside => {
let [inside, outside] = fx::bevel_fields(reach, &e.shape_cpu, region, out);
vec![(FieldKind::BevelInside, inside), (FieldKind::BevelOutside, outside)]
}
_ => region,
// Computed with BevelInside, over the same rectangle at the same reach (checked above).
FieldKind::BevelOutside => continue,
_ => vec![(kind, fx::field(kind, reach, &e.shape_cpu, region, out))],
};
let reach = have.filter(|r| *r >= reach).unwrap_or(reach);
let v = fx::field(kind, reach, &e.shape_cpu, region, out);
if have.is_none_or(|r| r < reach) {
e.fields.insert(kind, (reach, Tex::map(device, "pc_fx_field", region, MAP32)));
for (kind, v) in fields {
if e.fields.get(&kind).is_none_or(|(r, _)| *r < reach) {
e.fields.insert(kind, (reach, Tex::map(device, "pc_fx_field", region, MAP32)));
}
if let Some((_, t)) = e.fields.get(&kind) {
t.write_r32(queue, region, out, &v);
}
staged += v.len() as u64 * 4;
stats.fx_pixels += out.width() as u64 * out.height() as u64;
}
if let Some((_, t)) = e.fields.get(&kind) {
t.write_r32(queue, region, out, &v);
}
staged += v.len() as u64 * 4;
stats.fx_pixels += out.width() as u64 * out.height() as u64;
}
if let Some(t) = t_trace.filter(|_| !damage.is_empty()) {
@@ -1945,7 +1959,7 @@ impl Compositor {
if prog.maps == 0 {
continue;
}
let Some(e) = self.fx.get_mut(&key) else { return };
let Some(e) = self.fx.get_mut(&key) else { return Ok(()) };
let same = e.progs[i].key == prog.key && e.progs[i].maps.len() == prog.maps;
let d = if same { damage } else { region };
if d.is_empty() {
@@ -1960,6 +1974,7 @@ impl Compositor {
stats.fx_programs += 1;
self.run_program(device, queue, encoder, key, i, prog, d, stats);
}
Ok(())
}
/// Record the passes of `prog` (maps of item `item` of `layer`) recomputing damage `d`.
+157
View File
@@ -0,0 +1,157 @@
//! Guides ⇄ PSD beyond image resource 1032.
//!
//! Photoshop writes two undocumented resources next to 1032 (Grid and Guides), layout
//! inferred from the psd-tools, ag-psd and Photoshop-authored corpora (#5965):
//!
//! * 1092, Grid and Guides version 2: `u32` version 2, `u32` horizontal and vertical grid
//! cycle (576 = 18 px in 1/32 px, as in 1032), `u32` count, then per guide 9 bytes: `i32`
//! position in 1/32 px, `u8` direction (0 vertical, 1 horizontal), one `u8` that is always 0
//! in the corpora, and the guide's colour as `u8` R, G, B. In every corpus file its guides
//! equal 1032's, in the same order.
//! * 1097: `u32` count (1092's), then per guide a length-prefixed ASCII GUID, a `u32` 0 and a
//! second length-prefixed GUID, always all zeros.
//!
//! Import takes the guides' own colours from 1092 (a guide in Photoshop's default colour keeps
//! following the preference colour). Export keeps both resources only while 1092 still
//! describes the document's guides and colours; otherwise it leaves both out, so the file
//! carries 1032 alone, as Photoshop files before version 2 did (#5966).
use photocraft_doc::{Document, Guides};
/// Grid and Guides, version 2 (positions, directions and colours).
pub const GUIDES_V2: u16 = 1092;
/// One GUID per guide, in 1092's order.
pub const GUIDE_IDS: u16 = 1097;
/// Photoshop's default guide colour (Cyan, `#4affff`), which 1092 also stores for guides
/// that use the preference colour.
pub const DEFAULT_GUIDE_RGB: [u8; 3] = [0x4a, 0xff, 0xff];
/// One guide of a 1092 resource.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct GuideV2 {
/// Position in 1/32 px.
pub location: i32,
pub vertical: bool,
pub rgb: [u8; 3],
}
fn be_u32(data: &[u8], at: usize) -> Option<u32> {
let b = data.get(at..at.checked_add(4)?)?;
Some(u32::from_be_bytes([b[0], b[1], b[2], b[3]]))
}
/// The guides of a 1092 resource, or `None` when it is not version 2 or is cut short.
pub fn parse_v2(data: &[u8]) -> Option<Vec<GuideV2>> {
if be_u32(data, 0)? != 2 {
return None;
}
let count = usize::try_from(be_u32(data, 12)?).ok()?;
// Each guide takes 9 bytes: a count the data can't hold is not a 1092 we understand.
if count > data.len().saturating_sub(16) / 9 {
return None;
}
(0..count)
.map(|i| {
let at = 16 + i * 9;
let g = data.get(at..at + 9)?;
Some(GuideV2 { location: i32::from_be_bytes([g[0], g[1], g[2], g[3]]), vertical: g[4] != 1, rgb: [g[6], g[7], g[8]] })
})
.collect()
}
/// The number of entries a 1097 resource declares.
pub fn ids_count(data: &[u8]) -> Option<u32> {
be_u32(data, 0)
}
/// The document's guides as 1092 would store them, sorted.
fn expected(guides: &Guides) -> Vec<GuideV2> {
let mut v: Vec<GuideV2> = [(&guides.vertical, true), (&guides.horizontal, false)]
.into_iter()
.flat_map(|(list, vertical)| {
list.iter().map(move |&p| GuideV2 { location: (p * 32.0).round() as i32, vertical, rgb: guides.color(vertical, p).unwrap_or(DEFAULT_GUIDE_RGB) })
})
.collect();
v.sort();
v
}
/// Whether an imported 1092 still describes the document's guides: the same positions,
/// directions and colours, in any order.
pub fn describes(doc: &Document, data: &[u8]) -> bool {
let Some(mut stored) = parse_v2(data) else { return false };
stored.sort();
stored == expected(&doc.guides)
}
/// Whether to write back an imported 1092 or 1097: only while 1092 describes the guides
/// (and, for 1097, holds as many entries as 1092).
pub fn keep_resource(doc: &Document, id: u16, data: &[u8]) -> bool {
match id {
GUIDES_V2 => describes(doc, data),
GUIDE_IDS => {
let v2 = doc.metadata.psd_resources.iter().find(|(rid, _, _)| *rid == GUIDES_V2);
v2.is_some_and(|(_, _, d)| describes(doc, d) && parse_v2(d).map(|g| g.len()).and_then(|n| u32::try_from(n).ok()) == ids_count(data))
}
_ => true,
}
}
/// Gives the document's guides the colours an imported 1092 stores. Only guides that 1032
/// also has are coloured, and Photoshop's default colour is left to the preference.
pub fn apply_colors(doc: &mut Document) {
let Some(stored) = doc.metadata.psd_resources.iter().find(|(id, _, _)| *id == GUIDES_V2).and_then(|(_, _, d)| parse_v2(d)) else {
return;
};
for g in stored {
if g.rgb == DEFAULT_GUIDE_RGB {
continue;
}
let pos = f64::from(g.location) / 32.0;
let list = if g.vertical { &doc.guides.vertical } else { &doc.guides.horizontal };
if list.contains(&pos) {
doc.guides.set_color(g.vertical, pos, Some(g.rgb));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn v2(guides: &[(i32, u8, [u8; 3])]) -> Vec<u8> {
let mut v = Vec::new();
for n in [2u32, 576, 576, guides.len() as u32] {
v.extend_from_slice(&n.to_be_bytes());
}
for (loc, dir, rgb) in guides {
v.extend_from_slice(&loc.to_be_bytes());
v.extend_from_slice(&[*dir, 0]);
v.extend_from_slice(rgb);
}
v
}
#[test]
fn parses_corpus_layout() {
// clipping-mask4.psd (psd-tools): one vertical and one horizontal guide at 16 px.
let d = v2(&[(512, 0, DEFAULT_GUIDE_RGB), (512, 1, DEFAULT_GUIDE_RGB)]);
let g = parse_v2(&d).unwrap();
assert_eq!(g.len(), 2);
assert!(g[0].vertical && !g[1].vertical);
assert_eq!(g[1].location, 512);
}
#[test]
fn rejects_other_versions_and_short_or_hostile_data() {
let mut d = v2(&[(512, 0, DEFAULT_GUIDE_RGB)]);
assert!(parse_v2(&d[..d.len() - 1]).is_none());
d[3] = 1;
assert!(parse_v2(&d).is_none());
let mut huge = v2(&[]);
huge[12..16].copy_from_slice(&u32::MAX.to_be_bytes());
assert!(parse_v2(&huge).is_none());
assert!(parse_v2(&[]).is_none());
assert!(ids_count(&[0, 0]).is_none());
}
}
+2 -1
View File
@@ -45,6 +45,7 @@ pub mod effects_map;
pub mod exr_parts;
mod flat;
mod gradient_bake;
pub mod guides_map;
pub use gradient_bake::{Method as GradientMethod, bake as bake_gradient};
mod kra;
pub mod linked;
@@ -67,8 +68,8 @@ pub mod text_styles_map;
pub mod tiff_layers;
pub mod vector_map;
pub use photocraft_codecs::DpxBits;
use photocraft_codecs::{CodecError, EncodeOptions};
pub use photocraft_codecs::{DdsCompression, DdsOptions, DpxBits};
use photocraft_doc::{Adjustment, Document, Layer, LayerContent};
use photocraft_psd::{PsdError, PsdFile};
+100 -41
View File
@@ -43,40 +43,43 @@ pub fn interleave(planes: &[Option<&[u8]>], fill: &[Vec<u8>], n: usize, s: Sampl
let ch = planes.len();
let row = ch * bps;
let mut out = vec![0u8; n * row];
// One band of rows per core, written straight into its slice of the shared output: the
// per-pixel channel loop stays, but a band holds ~1/cores of a 20 MP image's iterations
// instead of all of them.
if out.is_empty() {
return out;
}
let bs = bands(n);
let parts: Vec<Vec<u8>> = par_map(bs.clone(), |band| {
let mut part = vec![0u8; band.len() * row];
for (i, p) in band.clone().enumerate() {
for (c, plane) in planes.iter().enumerate() {
let dst = &mut part[(i * ch + c) * bps..(i * ch + c + 1) * bps];
match plane {
Some(pl) if pl.len() >= (p + 1) * bps => {
let src = &pl[p * bps..(p + 1) * bps];
for k in 0..bps {
dst[k] = src[bps - 1 - k];
}
if cfg!(target_endian = "big") {
dst.copy_from_slice(src);
}
if invert[c] {
invert_sample(dst, s);
}
}
_ => dst.copy_from_slice(&fill[c]),
}
let step = bs.first().map_or(n, |b| b.len()) * row;
par_map(bs.into_iter().zip(out.chunks_mut(step)).collect(), |(band, part)| {
for (c, plane) in planes.iter().enumerate() {
let src = plane.and_then(|p| p.get(band.start * bps..)).unwrap_or_default();
let inv = src.len() >= bps && invert[c];
let fill = fill.get(c).map(Vec::as_slice).unwrap_or_default();
match s {
SampleType::U8 => write_channel(part, ch, c, src, fill, |[v]| [if inv { 255 - v } else { v }]),
SampleType::U16 => write_channel(part, ch, c, src, fill, |v| {
let v = u16::from_be_bytes(v);
(if inv { 65535 - v } else { v }).to_ne_bytes()
}),
SampleType::F32 => write_channel(part, ch, c, src, fill, |v| {
// Preserve float bits when no inversion is requested.
if inv { (1.0 - f32::from_be_bytes(v)).to_ne_bytes() } else { u32::from_be_bytes(v).to_ne_bytes() }
}),
}
}
part
});
for (band, part) in bs.into_iter().zip(parts) {
out[band.start * row..band.end * row].copy_from_slice(&part);
}
out
}
fn write_channel<const N: usize>(out: &mut [u8], ch: usize, c: usize, src: &[u8], fill: &[u8], decode: impl Fn([u8; N]) -> [u8; N]) {
let mut samples = src.as_chunks::<N>().0.iter();
for pixel in out.chunks_exact_mut(ch * N) {
let dst = &mut pixel[c * N..(c + 1) * N];
match samples.next() {
Some(sample) => dst.copy_from_slice(&decode(*sample)),
None => dst.copy_from_slice(fill),
}
}
}
/// Splits interleaved native-endian bytes into big-endian planes.
pub fn deinterleave(bytes: &[u8], ch: usize, s: SampleType, invert: &[bool]) -> Vec<Vec<u8>> {
let bps = s.bytes();
@@ -217,21 +220,29 @@ pub fn bands(n: usize) -> Vec<std::ops::Range<usize>> {
mod tests {
use super::*;
#[test]
fn banded_shuffles_match_a_sequential_reference() {
fn seq_interleave(planes: &[Option<&[u8]>], fill: &[Vec<u8>], n: usize, s: SampleType, invert: &[bool]) -> Vec<u8> {
let bps = s.bytes();
let ch = planes.len();
let mut out = vec![0u8; n * ch * bps];
for (c, plane) in planes.iter().enumerate() {
for i in 0..n {
let dst = &mut out[(i * ch + c) * bps..(i * ch + c + 1) * bps];
pub(super) fn previous_interleave(planes: &[Option<&[u8]>], fill: &[Vec<u8>], n: usize, s: SampleType, invert: &[bool]) -> Vec<u8> {
let bps = s.bytes();
let ch = planes.len();
let row = ch * bps;
let mut out = vec![0u8; n * row];
// One band of rows per core, written straight into its slice of the shared output: the
// per-pixel channel loop stays, but a band holds ~1/cores of a 20 MP image's iterations
// instead of all of them.
let bs = bands(n);
let parts: Vec<Vec<u8>> = par_map(bs.clone(), |band| {
let mut part = vec![0u8; band.len() * row];
for (i, p) in band.clone().enumerate() {
for (c, plane) in planes.iter().enumerate() {
let dst = &mut part[(i * ch + c) * bps..(i * ch + c + 1) * bps];
match plane {
Some(p) if p.len() >= (i + 1) * bps => {
let src = &p[i * bps..(i + 1) * bps];
Some(pl) if pl.len() >= (p + 1) * bps => {
let src = &pl[p * bps..(p + 1) * bps];
for k in 0..bps {
dst[k] = src[bps - 1 - k];
}
if cfg!(target_endian = "big") {
dst.copy_from_slice(src);
}
if invert[c] {
invert_sample(dst, s);
}
@@ -240,8 +251,16 @@ mod tests {
}
}
}
out
part
});
for (band, part) in bs.into_iter().zip(parts) {
out[band.start * row..band.end * row].copy_from_slice(&part);
}
out
}
#[test]
fn banded_shuffles_match_a_sequential_reference() {
fn seq_deinterleave(bytes: &[u8], ch: usize, s: SampleType, invert: &[bool]) -> Vec<Vec<u8>> {
let bps = s.bytes();
let n = bytes.len() / (ch * bps).max(1);
@@ -260,16 +279,17 @@ mod tests {
planes
}
// Odd pixel counts cross band boundaries; a missing plane exercises the fill path.
for n in [1usize, 7, 64, 1000] {
for n in [0usize, 1, 7, 64, 1000] {
for s in [SampleType::U8, SampleType::U16, SampleType::F32] {
let bps = s.bytes();
let planes: Vec<Vec<u8>> = (0..4).map(|c| (0..n * bps).map(|i| (i * 13 + c * 5) as u8).collect()).collect();
let mut refs: Vec<Option<&[u8]>> = planes.iter().map(|p| Some(&p[..])).collect();
refs[2] = None;
refs[3] = Some(&planes[3][..(n * bps).saturating_sub(1)]);
let fill: Vec<Vec<u8>> = (0..4).map(|c| vec![c as u8 * 40; bps]).collect();
let invert = [false, true, false, true];
let got = interleave(&refs, &fill, n, s, &invert);
let want = seq_interleave(&refs, &fill, n, s, &invert);
let want = previous_interleave(&refs, &fill, n, s, &invert);
assert_eq!(got, want, "interleave n={n}");
let planes_back = deinterleave(&got, 4, s, &invert);
let want_back = seq_deinterleave(&got, 4, s, &invert);
@@ -287,6 +307,7 @@ mod tests {
let fill = vec![zero_sample(s); 3];
let inv = [false, true, false];
let il = interleave(&refs, &fill, 4, s, &inv);
assert_eq!(interleave(&refs, &[], 4, s, &inv), il);
let back = deinterleave(&il, 3, s, &inv);
if s == SampleType::F32 {
// 1 - (1 - v) may not be exact for floats; only check non-inverted planes.
@@ -302,6 +323,12 @@ mod tests {
fn missing_planes_use_fill() {
let il = interleave(&[None, Some(&[5, 6])], &[vec![9], vec![0]], 2, SampleType::U8, &[false, false]);
assert_eq!(il, vec![9, 5, 9, 6]);
for s in [SampleType::U8, SampleType::U16, SampleType::F32] {
for n in [1, 1000] {
let fill = vec![vec![9; s.bytes()]; 2];
assert_eq!(interleave(&[None, Some(&[1, 2, 3][..s.bytes() - 1])], &fill, n, s, &[]), vec![9; n * 2 * s.bytes()]);
}
}
}
#[test]
@@ -343,3 +370,35 @@ mod tests {
assert_eq!(decode_be(&f, 0, SampleType::F32), 2.5);
}
}
#[cfg(test)]
mod measurement {
use super::*;
#[test]
#[ignore = "manual 24 MP release benchmark"]
fn benchmark_interleave() {
use std::{hint::black_box, time::Instant};
const N: usize = 24_000_000;
for sample in [SampleType::U8, SampleType::U16, SampleType::F32] {
let bps = sample.bytes();
let planes: Vec<Vec<u8>> = (0..4).map(|c| (0..N * bps).map(|i| (i * 13 + c * 5) as u8).collect()).collect();
let refs: Vec<_> = planes.iter().map(|p| Some(p.as_slice())).collect();
let fill = vec![vec![0; bps]; 4];
let invert = [false, true, false, true];
assert_eq!(tests::previous_interleave(&refs, &fill, N, sample, &invert), interleave(&refs, &fill, N, sample, &invert));
for trial in 0..6 {
let mut paths = [("main", tests::previous_interleave as fn(_, _, _, _, _) -> _), ("split", interleave)];
if trial % 2 == 1 {
paths.reverse();
}
for (name, path) in paths {
let t = Instant::now();
let out = black_box(path)(&refs, &fill, N, sample, &invert);
let elapsed = t.elapsed().as_secs_f64() * 1000.0;
black_box(&out);
println!("interleave,{sample:?},{trial},{name},{elapsed:.6}");
}
}
}
}
}
+4
View File
@@ -1274,6 +1274,10 @@ pub(crate) fn keep_imported_resource(doc: &Document, id: u16, data: &[u8]) -> bo
if !crate::annotations_map::keep_resource(doc, id) {
return false;
}
// Guides v2 and guide GUIDs: only while they still describe the document's guides.
if !crate::guides_map::keep_resource(doc, id, data) {
return false;
}
// A preserved clipping-path resource is only valid while it names the current one.
if id == crate::vector_map::CLIPPING_PATH {
let n = usize::from(data.first().copied().unwrap_or(0));
+1
View File
@@ -836,6 +836,7 @@ pub(crate) fn psd_to_document_decoded(
id => doc.metadata.psd_resources.push((id, String::from_utf8_lossy(&r.name).into_owned(), Arc::new(r.data.clone()))),
}
}
crate::guides_map::apply_colors(&mut doc);
for b in &file.global_blocks {
doc.metadata.psd_global_blocks.push((b.signature, b.key, Arc::new(b.data.clone())));
}
+128
View File
@@ -0,0 +1,128 @@
//! Image resources 1092 (guides with colours) and 1097 (guide GUIDs) next to 1032 (#5966).
use photocraft_io::guides_map::{DEFAULT_GUIDE_RGB, GUIDE_IDS, GUIDES_V2};
use photocraft_io::*;
use photocraft_psd::testgen;
use photocraft_psd::{Compression, ImageResource, PsdFile, Version};
const RED: [u8; 3] = [0xff, 0x00, 0x00];
type Edit = fn(&mut photocraft_doc::Document);
fn guides_v1(guides: &[(i32, u8)]) -> Vec<u8> {
let mut v = Vec::new();
for n in [1u32, 576, 576, guides.len() as u32] {
v.extend_from_slice(&n.to_be_bytes());
}
for (loc, dir) in guides {
v.extend_from_slice(&loc.to_be_bytes());
v.push(*dir);
}
v
}
fn guides_v2(guides: &[(i32, u8, [u8; 3])]) -> Vec<u8> {
let mut v = Vec::new();
for n in [2u32, 576, 576, guides.len() as u32] {
v.extend_from_slice(&n.to_be_bytes());
}
for (loc, dir, rgb) in guides {
v.extend_from_slice(&loc.to_be_bytes());
v.extend_from_slice(&[*dir, 0]);
v.extend_from_slice(rgb);
}
v
}
fn guide_ids(n: u32) -> Vec<u8> {
let mut v = n.to_be_bytes().to_vec();
for i in 0..n {
let id = format!("0000000{i}-1111-2222-3333-444444444444");
let zero = "00000000-0000-0000-0000-000000000000";
v.extend_from_slice(&36u32.to_be_bytes());
v.extend_from_slice(id.as_bytes());
v.extend_from_slice(&0u32.to_be_bytes());
v.extend_from_slice(&36u32.to_be_bytes());
v.extend_from_slice(zero.as_bytes());
}
v
}
/// A file as Photoshop writes it: a vertical guide at 16 px in the default colour and a red
/// horizontal one at 10 px, in 1032, 1092 and 1097.
fn photoshop_file() -> PsdFile {
let mut f = testgen::small(Version::Psd, Compression::Raw);
f.resources.push(ImageResource::new(1032, guides_v1(&[(512, 0), (320, 1)])));
f.resources.push(ImageResource::new(GUIDES_V2, guides_v2(&[(512, 0, DEFAULT_GUIDE_RGB), (320, 1, RED)])));
f.resources.push(ImageResource::new(GUIDE_IDS, guide_ids(2)));
f
}
fn resource(f: &PsdFile, id: u16) -> Option<&[u8]> {
f.resources.iter().find(|r| r.id == id).map(|r| r.data.as_slice())
}
#[test]
fn colours_come_from_guides_v2() {
let (d, _) = psd_to_document(&photoshop_file());
assert_eq!(d.guides.vertical, [16.0]);
assert_eq!(d.guides.horizontal, [10.0]);
assert_eq!(d.guides.color(false, 10.0), Some(RED));
// The default colour stays with the preference.
assert_eq!(d.guides.color(true, 16.0), None);
}
#[test]
fn unchanged_guides_keep_both_resources_byte_exact() {
let f = photoshop_file();
let (d, _) = psd_to_document(&f);
let out = document_to_psd(&d);
assert_eq!(resource(&out, GUIDES_V2), resource(&f, GUIDES_V2));
assert_eq!(resource(&out, GUIDE_IDS), resource(&f, GUIDE_IDS));
}
#[test]
fn edited_guides_drop_both_resources() {
let (base, _) = psd_to_document(&photoshop_file());
let edits: [(&str, Edit); 4] = [
("clear", |d| d.guides = Default::default()),
("add", |d| d.guides.horizontal.push(40.0)),
("move", |d| {
d.guides.vertical[0] = 20.0;
}),
("recolour", |d| d.guides.set_color(true, 16.0, Some(RED))),
];
for (name, edit) in edits {
let mut d = base.clone();
edit(&mut d);
let out = document_to_psd(&d);
assert!(resource(&out, GUIDES_V2).is_none(), "{name}: stale 1092 written");
assert!(resource(&out, GUIDE_IDS).is_none(), "{name}: stale 1097 written");
let (back, _) = psd_to_document(&out);
assert_eq!(back.guides.vertical, d.guides.vertical, "{name}");
assert_eq!(back.guides.horizontal, d.guides.horizontal, "{name}");
}
}
#[test]
fn mismatched_ids_or_malformed_v2_are_not_kept() {
let mut f = photoshop_file();
f.resources.retain(|r| r.id != GUIDE_IDS);
f.resources.push(ImageResource::new(GUIDE_IDS, guide_ids(3)));
let (d, _) = psd_to_document(&f);
let out = document_to_psd(&d);
assert!(resource(&out, GUIDES_V2).is_some());
assert!(resource(&out, GUIDE_IDS).is_none());
let mut f = photoshop_file();
for r in &mut f.resources {
if r.id == GUIDES_V2 {
r.data.truncate(20);
}
}
let (d, _) = psd_to_document(&f);
assert_eq!(d.guides.color(false, 10.0), None);
let out = document_to_psd(&d);
assert!(resource(&out, GUIDES_V2).is_none());
assert!(resource(&out, GUIDE_IDS).is_none());
}
+4
View File
@@ -12,5 +12,9 @@ photocraft-geom = { workspace = true }
photocraft-color = { workspace = true }
serde = { workspace = true }
# Native only: converting a region's tiles is independent per tile, so it runs on all cores.
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rayon = { workspace = true }
[dev-dependencies]
proptest = { workspace = true }
+258 -65
View File
@@ -117,6 +117,16 @@ pub struct Surface {
tiles: BTreeMap<TileCoord, Arc<Tile>>,
}
/// Use stack storage when it fits, otherwise reuse a dynamically sized sample buffer.
fn sample_buffer<'a>(channels: usize, stack: &'a mut [f32], heap: &'a mut Vec<f32>) -> &'a mut [f32] {
if let Some(samples) = stack.get_mut(..channels) {
samples
} else {
heap.resize(channels, 0.0);
heap
}
}
/// Tiles of one pixel value met while moving a surface ([`Surface::translated`]): each scanned
/// once, and one tile kept per value so the targets share it.
#[derive(Default)]
@@ -166,6 +176,55 @@ impl PartialEq for Surface {
}
}
/// Converts the `data` span covering one tile into that tile's bytes.
fn write_tile(t: &mut Tile, tc: TileCoord, r: Rect, data: &[f32], w: usize, n: usize, sample: SampleType) {
let tr = tc.rect().intersect(&r);
// Convert contiguous channel samples a row at a time. Dispatching on depth here removes the
// per-pixel channel loop and lets the compiler vectorise conversion.
let span = tr.width() as usize * n;
for y in tr.y0..tr.y1 {
let o = (((y - r.y0) as usize) * w + (tr.x0 - r.x0) as usize) * n;
let base = (((y - tc.ty * TILE_SIZE) as usize) * TILE_SIZE as usize + (tr.x0 - tc.tx * TILE_SIZE) as usize) * n;
let src = &data[o..o + span];
let bytes = sample.bytes();
let dst = &mut t.data[base * bytes..(base + span) * bytes];
match sample {
SampleType::U8 => {
for (d, v) in dst.iter_mut().zip(src) {
*d = (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
}
}
SampleType::U16 => {
for (d, v) in dst.as_chunks_mut::<2>().0.iter_mut().zip(src) {
let q = (v.clamp(0.0, 1.0) * 65535.0 + 0.5) as u16;
d.copy_from_slice(&q.to_ne_bytes());
}
}
SampleType::F32 => {
for (d, v) in dst.as_chunks_mut::<4>().0.iter_mut().zip(src) {
d.copy_from_slice(&v.to_ne_bytes());
}
}
}
}
}
/// Converts the tiles of a region write. A tile holds no state another tile reads, so the tiles
/// are converted on all cores; each writes only its own bytes, in the same arithmetic.
#[cfg(not(target_arch = "wasm32"))]
fn convert_tiles(tiles: &mut [(TileCoord, Arc<Tile>)], r: Rect, data: &[f32], w: usize, n: usize, sample: SampleType) {
use rayon::prelude::*;
tiles.par_iter_mut().for_each(|(tc, t)| write_tile(Arc::make_mut(t), *tc, r, data, w, n, sample));
}
/// Serial counterpart, for the web build (there are no threads to spread tiles over).
#[cfg(target_arch = "wasm32")]
fn convert_tiles(tiles: &mut [(TileCoord, Arc<Tile>)], r: Rect, data: &[f32], w: usize, n: usize, sample: SampleType) {
for (tc, t) in tiles.iter_mut() {
write_tile(Arc::make_mut(t), *tc, r, data, w, n, sample);
}
}
impl Surface {
/// Reads a region into a newly allocated buffer, returning an error instead of aborting if
/// the requested pixel buffer cannot be represented or reserved.
@@ -239,11 +298,20 @@ impl Surface {
decode_pixel(&self.format, &self.default_pixel)
}
/// Decode the default pixel, using dynamic storage only when the stack buffer is too small.
fn default_samples<'a>(&self, stack: &'a mut [f32], heap: &'a mut Vec<f32>) -> &'a mut [f32] {
let px = sample_buffer(self.channels(), stack, heap);
for (i, v) in px.iter_mut().enumerate() {
*v = read_sample(&self.default_pixel, self.format.sample, i);
}
px
}
/// Mutable access to a tile, allocating it (filled with the default pixel) or un-sharing it.
pub fn tile_mut(&mut self, c: TileCoord) -> &mut Tile {
let fmt = self.format;
let dp = self.default_pixel.clone();
let arc = self.tiles.entry(c).or_insert_with(|| Arc::new(Tile::filled(&fmt, &dp)));
let dp = &self.default_pixel;
let arc = self.tiles.entry(c).or_insert_with(|| Arc::new(Tile::filled(&fmt, dp)));
Arc::make_mut(arc)
}
@@ -319,9 +387,9 @@ impl Surface {
/// Drop tiles that are entirely the default pixel.
pub fn prune(&mut self) {
let dp = self.default_pixel.clone();
let dp = self.default_pixel.as_ref();
let bpp = dp.len();
self.tiles.retain(|_, t| t.data.chunks_exact(bpp).any(|px| px != &*dp));
self.tiles.retain(|_, t| t.data.chunks_exact(bpp).any(|px| px != dp));
}
#[inline]
@@ -411,14 +479,27 @@ impl Surface {
let w = r.width() as usize;
out.clear();
out.resize(w * r.height() as usize * n, 0.0);
let dp = self.default_pixel();
self.read_samples_into(r, out);
}
/// Copy samples into a zero-initialized buffer sized for `r`.
fn read_samples_into(&self, r: Rect, out: &mut [f32]) {
let n = self.channels();
let w = r.width() as usize;
let mut stack = [0.0; 8];
let mut heap = Vec::new();
let dp = self.default_samples(&mut stack, &mut heap);
let zero = self.default_pixel.iter().all(|&b| b == 0);
for tc in r.tiles() {
let tr = tc.rect().intersect(&r);
let tile = self.tiles.get(&tc);
if tile.is_none() && zero {
continue;
}
for y in tr.y0..tr.y1 {
let o = (((y - r.y0) as usize) * w + (tr.x0 - r.x0) as usize) * n;
let span = tr.width() as usize * n;
let dst = &mut out[o..o + span];
let Some(dst) = out.get_mut(o..o + span) else { continue };
match tile {
Some(t) => {
let base = (((y - tc.ty * TILE_SIZE) as usize) * TILE_SIZE as usize + (tr.x0 - tc.tx * TILE_SIZE) as usize) * n;
@@ -426,9 +507,10 @@ impl Surface {
*d = read_sample(&t.data, self.format.sample, base + i);
}
}
None if n == 1 => dst.fill(dp[0]),
None => {
for px in dst.chunks_exact_mut(n) {
px.copy_from_slice(&dp);
px.copy_from_slice(dp);
}
}
}
@@ -445,7 +527,10 @@ impl Surface {
debug_assert_eq!(out.len(), w * r.height() as usize);
let fmt = self.format;
let n = fmt.channels();
let dp = to_rgba(&fmt, &self.default_pixel());
let mut stack = [0.0; 8];
let mut heap = Vec::new();
let px = self.default_samples(&mut stack, &mut heap);
let dp = to_rgba(&fmt, px);
for tc in r.tiles() {
let tr = tc.rect().intersect(&r);
let tile = self.tiles.get(&tc);
@@ -489,12 +574,11 @@ impl Surface {
}
}
_ => {
let mut px = [0.0f32; 8];
for (i, d) in dst.iter_mut().enumerate() {
for (c, v) in px.iter_mut().enumerate().take(n) {
for (c, v) in px.iter_mut().enumerate() {
*v = read_sample(&t.data, fmt.sample, base + i * n + c);
}
*d = to_rgba(&fmt, &px[..n]);
*d = to_rgba(&fmt, px);
}
}
}
@@ -510,7 +594,9 @@ impl Surface {
let fmt = self.format;
let rgba8 = matches!((fmt.mode, fmt.sample, fmt.alpha), (ColorMode::Rgb, SampleType::U8, true));
let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
let dp = to_rgba(&fmt, &self.default_pixel());
let mut stack = [0.0; 8];
let mut heap = Vec::new();
let dp = to_rgba(&fmt, self.default_samples(&mut stack, &mut heap));
let dp8 = [q(dp[0]), q(dp[1]), q(dp[2]), q(dp[3])];
let mut row = Vec::new();
for tc in r.tiles() {
@@ -551,68 +637,43 @@ impl Surface {
let n = self.channels();
let w = r.width() as usize;
let mut out = vec![0.0f32; w * r.height() as usize * n];
let dp = self.default_pixel();
for tc in r.tiles() {
let tr = tc.rect().intersect(&r);
let tile = self.tiles.get(&tc);
for y in tr.y0..tr.y1 {
for x in tr.x0..tr.x1 {
let o = (((y - r.y0) as usize) * w + (x - r.x0) as usize) * n;
match tile {
Some(t) => {
let base = (((y - tc.ty * TILE_SIZE) as usize) * TILE_SIZE as usize + (x - tc.tx * TILE_SIZE) as usize) * n;
for i in 0..n {
out[o + i] = read_sample(&t.data, self.format.sample, base + i);
}
}
None => out[o..o + n].copy_from_slice(&dp),
}
}
}
}
self.read_samples_into(r, &mut out);
out
}
/// Write interleaved normalised floats into a rectangle.
/// Write interleaved normalised floats into a rectangle, on the calling thread.
///
/// This never enters Rayon, so it is safe inside a `OnceLock` initializer or any other
/// section that must not run another Rayon job on its stack. [`Surface::write_region_par`]
/// writes the same bytes on all cores.
pub fn write_region(&mut self, r: Rect, data: &[f32]) {
let n = self.channels();
let w = r.width() as usize;
assert_eq!(data.len(), w * r.height() as usize * n, "region data length mismatch");
let sample = self.format.sample;
for tc in r.tiles() {
let tr = tc.rect().intersect(&r);
let t = self.tile_mut(tc);
// Convert contiguous channel samples a row at a time. Dispatching on depth here
// removes the per-pixel channel loop and lets the compiler vectorise conversion.
let span = tr.width() as usize * n;
for y in tr.y0..tr.y1 {
let o = (((y - r.y0) as usize) * w + (tr.x0 - r.x0) as usize) * n;
let base = (((y - tc.ty * TILE_SIZE) as usize) * TILE_SIZE as usize + (tr.x0 - tc.tx * TILE_SIZE) as usize) * n;
let src = &data[o..o + span];
let bytes = sample.bytes();
let dst = &mut t.data[base * bytes..(base + span) * bytes];
match sample {
SampleType::U8 => {
for (d, v) in dst.iter_mut().zip(src) {
*d = (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
}
}
SampleType::U16 => {
for (d, v) in dst.as_chunks_mut::<2>().0.iter_mut().zip(src) {
let q = (v.clamp(0.0, 1.0) * 65535.0 + 0.5) as u16;
d.copy_from_slice(&q.to_ne_bytes());
}
}
SampleType::F32 => {
for (d, v) in dst.as_chunks_mut::<4>().0.iter_mut().zip(src) {
d.copy_from_slice(&v.to_ne_bytes());
}
}
}
}
write_tile(self.tile_mut(tc), tc, r, data, w, n, sample);
}
}
/// [`Surface::write_region`] with the tiles converted on all cores (serially on the web).
///
/// This enters Rayon: while it waits for its tiles, the calling worker may run any other
/// queued Rayon job. Callers inside a `OnceLock` initializer or another section that must
/// not do that use [`Surface::write_region`].
pub fn write_region_par(&mut self, r: Rect, data: &[f32]) {
let n = self.channels();
let w = r.width() as usize;
assert_eq!(data.len(), w * r.height() as usize * n, "region data length mismatch");
let sample = self.format.sample;
// The tiles are disjoint, so converting them in any order (and on any thread) writes
// exactly the same bytes. `take_tiles` gives the missing tiles as one shared
// default-filled tile, copied per tile by `Arc::make_mut` in `convert_tiles`.
let mut tiles = self.take_tiles(r);
convert_tiles(&mut tiles, r, data, w, n, sample);
self.put_tiles(tiles);
}
/// Take the tiles covering `r` out of the surface (missing ones filled with the default
/// pixel), so they can be edited elsewhere, e.g. in parallel: `Arc::unwrap_or_clone` copies a
/// tile only if an undo snapshot still shares it. Give them back with [`Surface::put_tiles`].
@@ -1102,6 +1163,60 @@ pub fn is_hdr(sample: SampleType) -> bool {
#[cfg(test)]
mod tests {
/// Manually compare allocating reads with the previous implementation.
#[test]
#[ignore = "manual 24 MP release benchmark"]
fn benchmark_sparse_reads() {
use std::{hint::black_box, time::Instant};
let rect = Rect::new(0, 0, 6000, 4000);
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
for default in [0.0, 0.25] {
let mut s = Surface::with_default(PixelFormat::GRAY8.with_sample(depth), &[default]);
s.fill_rect(Rect::new(257, 257, 266, 266), &[0.75]);
assert_eq!(s.read_region(rect), previous_read_region(&s, rect));
for trial in 0..6 {
let mut paths = [("main", previous_read_region as fn(&Surface, Rect) -> Vec<f32>), ("split", Surface::read_region)];
if trial % 2 == 1 {
paths.reverse();
}
for (name, path) in paths {
let t = Instant::now();
let out = black_box(path)(&s, rect);
let elapsed = t.elapsed().as_secs_f64() * 1000.0;
black_box(&out);
println!("sparse,{depth:?}-{default},{trial},{name},{elapsed:.6}");
}
}
}
}
}
/// Previous allocating read path, retained as the bitwise-equivalence oracle.
fn previous_read_region(surface: &Surface, r: Rect) -> Vec<f32> {
let n = surface.channels();
let w = r.width() as usize;
let mut out = vec![0.0f32; w * r.height() as usize * n];
let dp = surface.default_pixel();
for tc in r.tiles() {
let tr = tc.rect().intersect(&r);
let tile = surface.tiles.get(&tc);
for y in tr.y0..tr.y1 {
for x in tr.x0..tr.x1 {
let o = (((y - r.y0) as usize) * w + (x - r.x0) as usize) * n;
match tile {
Some(t) => {
let base = (((y - tc.ty * TILE_SIZE) as usize) * TILE_SIZE as usize + (x - tc.tx * TILE_SIZE) as usize) * n;
for i in 0..n {
out[o + i] = read_sample(&t.data, surface.format.sample, base + i);
}
}
None => out[o..o + n].copy_from_slice(&dp),
}
}
}
}
out
}
/// Distinct tile allocations in `s`.
fn distinct_tiles(s: &Surface) -> usize {
let mut seen: Vec<*const Tile> = s.tiles().map(|(_, t)| Arc::as_ptr(t)).collect();
@@ -1213,19 +1328,52 @@ mod tests {
.collect();
let snapshot = actual.clone();
let snapshot_bytes = snapshot.to_interleaved(rect.inflate(1));
let mut par = actual.clone();
actual.write_region(rect, &data);
par.write_region_par(rect, &data);
write_region_reference(&mut expected, rect, &data);
assert_eq!(actual, expected, "{fmt:?}, {rect:?}");
assert_eq!(par, expected, "parallel {fmt:?}, {rect:?}");
assert_eq!(snapshot.to_interleaved(rect.inflate(1)), snapshot_bytes, "COW snapshot changed");
// Repeat against already materialised, unshared tiles too.
actual.write_region(rect, &data);
par.write_region_par(rect, &data);
assert_eq!(actual, expected);
assert_eq!(par, expected);
}
}
}
}
}
/// Mask builds call `write_region` inside a `OnceLock` initializer, where entering Rayon can
/// run another tile's job that waits on the same lock. With every global worker held, a write
/// that queued work on Rayon could not finish.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn write_region_does_not_enter_rayon() {
use std::sync::{Barrier, mpsc};
let workers = rayon::current_num_threads();
let held = Arc::new(Barrier::new(workers + 1));
let release = Arc::new(Barrier::new(workers + 1));
let (h, rel) = (held.clone(), release.clone());
rayon::spawn_broadcast(move |_| {
h.wait();
rel.wait();
});
held.wait();
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
let r = Rect::new(0, 0, 2 * TILE_SIZE, 2 * TILE_SIZE);
let mut s = Surface::new(PixelFormat::RGBA8);
s.write_region(r, &vec![0.5; (r.width() * r.height() * 4) as usize]);
tx.send(()).ok();
});
let done = rx.recv_timeout(std::time::Duration::from_secs(5)).is_ok();
release.wait();
assert!(done, "write_region waited on the Rayon pool");
}
#[test]
fn from_rgba_into_matches_from_rgba() {
for fmt in [PixelFormat::RGBA8, PixelFormat::GRAY8, PixelFormat::GRAYA8, PixelFormat::CMYKA8, PixelFormat::new(ColorMode::Lab, SampleType::F32, true)] {
@@ -1235,29 +1383,74 @@ mod tests {
}
}
/// Sample counts above the stack capacity use writable storage without truncating channels.
#[test]
fn sample_buffer_keeps_stack_fast_path_and_supports_dynamic_channels() {
for channels in [1, 8, 9, 32] {
let mut stack = [0.0; 8];
let mut heap = Vec::new();
let samples = sample_buffer(channels, &mut stack, &mut heap);
assert_eq!(samples.len(), channels);
samples.fill(0.25);
if channels <= stack.len() {
assert!(heap.is_empty());
assert_eq!(&stack[..channels], vec![0.25; channels]);
} else {
assert_eq!(heap, vec![0.25; channels]);
}
}
}
/// Compare sparse reads and converted accessors with the previous allocating path.
#[test]
fn zero_alloc_accessors_match_read_region() {
for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F, PixelFormat::GRAYA8, PixelFormat::CMYKA8, PixelFormat::GRAY8] {
let mut s = Surface::with_default(fmt, &vec![0.25; fmt.channels()]);
for (fmt, default) in [
PixelFormat::RGBA8,
PixelFormat::RGBA16,
PixelFormat::RGBA32F,
PixelFormat::GRAYA8,
PixelFormat::CMYKA8,
PixelFormat::GRAY8,
PixelFormat::GRAY8.with_sample(SampleType::F32),
PixelFormat::new(ColorMode::Lab, SampleType::U16, true),
]
.into_iter()
.flat_map(|fmt| [0.0, 0.25, -0.0].map(|default| (fmt, default)))
{
let mut s = Surface::with_default(fmt, &vec![default; fmt.channels()]);
let mut stack = [0.0; 8];
let mut heap = Vec::new();
for capacity in [0, stack.len()] {
let decoded = s.default_samples(&mut stack[..capacity], &mut heap);
assert!(decoded.iter().map(|v| v.to_bits()).eq(s.default_pixel().into_iter().map(f32::to_bits)));
}
let r = Rect::new(-300, -20, 300, 40);
for (i, (x, y)) in [(-299, -19), (0, 0), (255, 39), (256, 10), (299, 0)].into_iter().enumerate() {
let px: Vec<f32> = (0..fmt.channels()).map(|c| ((i * 3 + c) % 5) as f32 / 4.0).collect();
s.write_pixel(x, y, &px);
}
let region = s.read_region(r);
assert!(region.iter().map(|v| v.to_bits()).eq(previous_read_region(&s, r).into_iter().map(f32::to_bits)));
let mut into = vec![9.0; 3];
s.read_region_into(r, &mut into);
assert_eq!(region, into);
let mut rgba = vec![[0.0; 4]; (r.width() * r.height()) as usize];
s.read_rgba_into(r, &mut rgba);
let mut rgba8 = vec![[0; 4]; rgba.len()];
s.read_rgba8_into(r, &mut rgba8);
let n = fmt.channels();
for (i, p) in region.chunks_exact(n).enumerate() {
let want = to_rgba(&fmt, p);
assert_eq!(rgba8[i], want.map(|v| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8));
for c in 0..4 {
assert!((rgba[i][c] - want[c]).abs() < 1e-6, "{fmt:?} px {i}");
}
let (x, y) = (r.x0 + (i % r.width() as usize) as i32, r.y0 + (i / r.width() as usize) as i32);
assert_eq!(s.sample_channel(x, y, 0), p[0]);
for c in 0..n {
let bits = s.sample_channel(x, y, c).to_bits();
assert_eq!(bits, p[c].to_bits());
assert_eq!(bits, into[i * n + c].to_bits());
}
}
assert!(s.has_tiles_in(Rect::new(0, 0, 1, 1)));
assert!(!s.has_tiles_in(Rect::new(1000, 1000, 1001, 1001)));
+2
View File
@@ -25,8 +25,10 @@ flate2 = { workspace = true }
# line breaking. Native only: the CJK dictionary alone is 3.8 MB of wasm, and the web build must
# stay under Cloudflare's 25 MiB per-file cap (packaging/web/package.sh). The web build falls back
# to the generic Unicode rules for those scripts.
# Native only: the glyph rasteriser composites and converts its pixel buffers on all cores.
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
parley = { version = "0.11", default-features = false, features = ["std", "complex-scripts"] }
rayon = { workspace = true }
[build-dependencies]
# Deflates the bundled fonts (build.rs); `flate2` above inflates them.
+36 -11
View File
@@ -12,6 +12,9 @@ use crate::layout::{GlyphOrient, PlacedGlyph, TextLayout};
use crate::raster::{Bounds, Coverage, LineSink, Pen, Xform, rect_to};
use crate::warp::{Placement, Warp};
#[cfg(not(target_arch = "wasm32"))]
use rayon::prelude::*;
/// Faux-italic slant in degrees (Photoshop-like).
pub const FAUX_ITALIC_DEG: f32 = 12.0;
/// Faux-bold ring radius as a fraction of the font size (tuned so stem weight stays close to the old summed-coverage look).
@@ -276,35 +279,57 @@ pub fn rasterize_warped(layout: &TextLayout, transform: &Affine, format: PixelFo
}
let comps = color_in(&format, c);
let a = c.alpha.clamp(0.0, 1.0);
for (i, &cv) in cov.iter().enumerate() {
let cv = if antialias == AntiAlias::None { if cv >= 0.5 { 1.0 } else { 0.0 } } else { cv };
composite(&mut acc, &cov, w, stride, &comps, a, antialias);
}
// Unpremultiply.
unpremultiply(&mut acc, n, stride, format.sample);
surface.write_region_par(rect, &acc);
surface.prune();
Rendered { surface, rect }
}
/// Composites one colour's coverage over the premultiplied accumulator, resolving `AntiAlias::None`
/// to 0/1 first. A pixel's maths reads and writes only its own slots, so the rows are done in any
/// order — one per thread — for exactly the same bytes.
fn composite(acc: &mut [f32], cov: &[f32], width: usize, stride: usize, comps: &[f32], a: f32, antialias: AntiAlias) {
let n = comps.len();
let row = |acc: &mut [f32], cov: &[f32]| {
for (cv, px) in cov.iter().zip(acc.chunks_exact_mut(stride)) {
let cv = if antialias == AntiAlias::None { if *cv >= 0.5 { 1.0 } else { 0.0 } } else { *cv };
let s = cv * a;
if s <= 0.0 {
continue;
}
let px = &mut acc[i * stride..(i + 1) * stride];
let keep = 1.0 - s;
for j in 0..n {
px[j] = comps[j] * s + px[j] * keep;
}
px[n] = s + px[n] * keep;
}
}
// Unpremultiply.
for px in acc.chunks_exact_mut(stride) {
};
#[cfg(not(target_arch = "wasm32"))]
acc.par_chunks_mut(width * stride).zip(cov.par_chunks(width)).for_each(|(a, c)| row(a, c));
#[cfg(target_arch = "wasm32")]
acc.chunks_mut(width * stride).zip(cov.chunks(width)).for_each(|(a, c)| row(a, c));
}
/// Divides the premultiplied accumulator back to straight colour. Each pixel stands alone.
fn unpremultiply(acc: &mut [f32], n: usize, stride: usize, sample: SampleType) {
let one = |px: &mut [f32]| {
let a = px[n];
if a > 0.0 {
for v in &mut px[..n] {
let straight = *v / a;
// Float text can carry HDR and signed colours. Keep integer output bounded
// and retain the old handling of non-finite intermediate values.
*v = if format.sample == SampleType::F32 && straight.is_finite() { straight } else { straight.clamp(0.0, 1.0) };
*v = if sample == SampleType::F32 && straight.is_finite() { straight } else { straight.clamp(0.0, 1.0) };
}
}
}
surface.write_region(rect, &acc);
surface.prune();
Rendered { surface, rect }
};
#[cfg(not(target_arch = "wasm32"))]
acc.par_chunks_exact_mut(stride).for_each(one);
#[cfg(target_arch = "wasm32")]
acc.chunks_exact_mut(stride).for_each(one);
}
/// One element of a glyph outline in document space (see [`outlines`]).
+33 -1
View File
@@ -1,6 +1,6 @@
use photocraft_color::{Color, PixelFormat, SampleType};
use photocraft_doc::TextLayer;
use photocraft_doc::text::{Caps, CharStyle, FontFeature, Orientation, ParagraphRun, ParagraphStyle, TextAlign, TextDirection, TextRun, TextShape};
use photocraft_doc::text::{AntiAlias, Caps, CharStyle, FontFeature, Orientation, ParagraphRun, ParagraphStyle, TextAlign, TextDirection, TextRun, TextShape};
use photocraft_geom::Affine;
use crate::{TextEngine, fonts};
@@ -13,6 +13,38 @@ fn styled(text: &str, style: CharStyle) -> TextLayer {
TextLayer { text: text.into(), runs: vec![TextRun { len: text.len(), style }], ..Default::default() }
}
/// The rasteriser's pixel stages — composing one colour's coverage, unpremultiplying it, and
/// converting the tiles — are per pixel: no pixel reads a slot another pixel wrote. Spreading them
/// over threads therefore has to leave every byte as it was, which a one-thread pool checks.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn parallel_raster_matches_a_single_thread() {
let one = rayon::ThreadPoolBuilder::new().num_threads(1).build().expect("one-thread pool");
let mut e = TextEngine::new();
let boxed = TextShape::Box { x: 0.0, y: 0.0, width: 220.0, height: 90.0 };
let paragraph = TextLayer {
text: "A paragraph box that wraps over several lines and covers many tiles, so the whole raster is exercised.".into(),
font_family: "Inter".into(),
size_pt: 18.0,
shape: boxed,
..Default::default()
};
let no_aa = TextLayer { text: "No AA edge".into(), font_family: "Inter".into(), size_pt: 30.0, antialias: AntiAlias::None, ..Default::default() };
let cases: Vec<(TextLayer, f32, PixelFormat)> = vec![
(point("Parallel", 48.0), 72.0, PixelFormat::RGBA8),
(styled("Bold italic", CharStyle { size_pt: 40.0, faux_bold: true, faux_italic: true, ..Default::default() }), 300.0, PixelFormat::RGBA8),
(no_aa, 150.0, PixelFormat::RGBA16),
(styled("Wide gamut", CharStyle { size_pt: 36.0, color: Color::rgba(2.5, -0.25, 0.5, 0.4), ..Default::default() }), 96.0, PixelFormat::RGBA32F),
(with_para(paragraph, ParagraphStyle::default()), 144.0, PixelFormat::RGBA8),
];
for (layer, dpi, format) in cases {
let (_, spread) = e.render(&layer, dpi, format);
let (_, single) = one.install(|| e.render(&layer, dpi, format));
assert_eq!(spread.rect, single.rect, "{}", layer.text);
assert_eq!(spread.surface, single.surface, "{}", layer.text);
}
}
#[test]
fn raster_preserves_hdr_and_signed_float_text_colors() {
let mut e = TextEngine::new();
+1
View File
@@ -55,6 +55,7 @@ fn main() {
opts.encode.webp_quality = q;
}
opts.tga_bits = settings.tga_bits;
opts.encode.dds = settings.dds;
photocraft_io::export(doc, path, &opts).map(|r| (r.bytes, r.warnings)).map_err(|e| e.to_string())
})),
write: Some(Box::new(|path: &str, bytes: &[u8]| photocraft_format::atomic_write(std::path::Path::new(path), bytes).map_err(|e| e.to_string()))),
+38 -1
View File
@@ -29,6 +29,8 @@ pub(crate) struct BlendPreview {
area: Option<Rect>,
/// The document shown for `mode`, with its key.
shown: Option<(u64, Arc<Document>)>,
/// GPU preview key, affected region (including effects), and rectangles already refreshed.
covered: Option<(u64, Rect, Vec<Rect>)>,
}
/// The Layers panel, each frame it draws `layer`'s blend-mode dropdown: the mode under the
@@ -54,7 +56,7 @@ pub(crate) fn hover(app: &mut PhotocraftApp, layers: &[LayerId], mode: Option<Bl
_ => None,
};
}
Some(BlendPreview { doc, revision, layers: layers.to_vec(), mode: Some(mode), frame, area, shown: None })
Some(BlendPreview { doc, revision, layers: layers.to_vec(), mode: Some(mode), frame, area, shown: None, covered: None })
});
}
@@ -96,6 +98,41 @@ pub(crate) fn damage(app: &PhotocraftApp, doc: DocId, revision: u64, seen: u64,
(seen != now && ours(seen) && ours(now) && (is_preview_key(seen) || is_preview_key(now))).then_some(p.area)?
}
/// Limit an active preview to the view. Returning to the document restores the whole region.
pub(crate) fn clip_damage(app: &mut PhotocraftApp, doc: DocId, revision: u64, key: u64, region: Rect, visible: Rect) -> Rect {
let Some(p) = app.blend_preview.as_mut().filter(|p| p.doc == doc && p.revision == revision) else { return region };
if !p.shown.as_ref().is_some_and(|(shown, _)| *shown == key) {
p.covered = None;
return region;
}
let shown = region.intersect(&visible);
p.covered = Some((key, region, vec![shown]));
shown
}
/// A pan or zoom can expose pixels not yet rendered for the current preview key.
pub(crate) fn uncovered(app: &mut PhotocraftApp, doc: DocId, revision: u64, key: u64, visible: Rect) -> Option<Rect> {
let p = app.blend_preview.as_mut().filter(|p| p.doc == doc && p.revision == revision)?;
let (covered_key, region, covered) = p.covered.as_mut()?;
if *covered_key != key {
return None;
}
let want = region.intersect(&visible);
if want.is_empty() || covered.iter().any(|r| r.contains_rect(&want)) {
return None;
}
// Keep disjoint views without marking the unrendered gaps between them as covered.
// Bound bookkeeping during long pans: after eight rectangles, render the whole affected
// region once. A different mode/revision resets coverage through `clip_damage`.
covered.retain(|r| !want.contains_rect(r));
let refresh = if covered.len() < 8 { want } else { *region };
if refresh == *region {
covered.clear();
}
covered.push(refresh);
Some(refresh)
}
fn is_preview_key(key: u64) -> bool {
(BASE..BASE << 1).contains(&key)
}
+34
View File
@@ -138,3 +138,37 @@ fn a_hovered_mode_previews_on_every_selected_layer() {
hover(&mut app, &[top], Some(BlendMode::Multiply));
assert_ne!(display_doc(&mut app, 0).unwrap().1, key);
}
#[test]
fn blend_hover_disjoint_coverage_retains_views_without_hiding_gaps() {
let (s, top) = session();
let mut app = PhotocraftApp::new(s, crate::Services::default());
hover(&mut app, &[top], Some(BlendMode::Multiply));
let (_, key) = display_doc(&mut app, 0).unwrap();
let st = app.session.active().unwrap();
let (id, revision, region) = (st.doc.id, st.revision, st.doc.bounds());
let a = photocraft_geom::Rect::new(0, 0, 2, 2);
let b = photocraft_geom::Rect::new(4, 0, 6, 2);
assert_eq!(super::clip_damage(&mut app, id, revision, key, region, a), a);
assert_eq!(super::uncovered(&mut app, id, revision, key, b), Some(b));
assert_eq!(super::uncovered(&mut app, id, revision, key, a), None);
let gap = photocraft_geom::Rect::new(2, 0, 4, 2);
assert_eq!(super::uncovered(&mut app, id, revision, key, gap), Some(gap));
// Repeated distinct views have bounded bookkeeping, then the whole region is current.
let mut fell_back = false;
for x in (8..40).step_by(4) {
let visible = photocraft_geom::Rect::new(x, 0, x + 2, 2);
if super::uncovered(&mut app, id, revision, key, visible) == Some(region) {
fell_back = true;
break;
}
}
assert!(fell_back);
assert_eq!(super::uncovered(&mut app, id, revision, key, region), None);
hover(&mut app, &[top], Some(BlendMode::Screen));
let (_, next) = display_doc(&mut app, 0).unwrap();
assert_eq!(super::clip_damage(&mut app, id, revision, next, region, a), a);
assert_eq!(super::uncovered(&mut app, id, revision, next, b), Some(b));
hover(&mut app, &[top], None);
assert_eq!(super::clip_damage(&mut app, id, revision, 0, region, a), region);
}
+16 -1
View File
@@ -1608,10 +1608,16 @@ fn ensure_gpu(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize, visible:
};
let (doc, raw_key) = display_doc(app, idx);
let (display, _) = canvas_display(app, &doc, None);
// Repeated pattern tiles sample the same texture, including pixels outside the unwrapped
// viewport. Refresh the affected document region while Pattern Preview is enabled.
let blend_visible = if app.ui.view.pattern_preview { doc.bounds() } else { visible };
// The texture is shared by every display: only what it stores counts, not the monitor.
let display_key = texture_key(display.as_deref());
let seen = app.canvases.get(&(id, GPU_OUTPUT)).map(|c| (c.revision, c.preview_key));
let mut damage = seen.and_then(|seen| damage_since(app, idx, seen, (revision, raw_key), display_key, last_damage));
if let Some(region) = damage {
damage = Some(crate::blend_preview::clip_damage(app, id, revision, raw_key, region, blend_visible));
}
// To an adjustment dialog's preview: only what the view shows now (the rest as it moves there).
if damage.is_some()
&& let Some(r) = seen.filter(|s| s.0 == revision).and_then(|s| crate::adjust_preview::switch_region(app, id, revision, s.1 ^ display_key, raw_key))
@@ -1671,7 +1677,12 @@ fn ensure_gpu(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize, visible:
let r = gpu.refresh(id.0, &doc, Some(r), display.as_deref());
app.perf.record_refresh(&r);
}
return true;
// Blend previews keep offscreen pixels untouched until the view reaches them.
// Use the normal refresh path so CPU fallback and pending redraws stay synchronized.
damage = crate::blend_preview::uncovered(app, id, revision, raw_key, blend_visible);
if damage.is_none() {
return true;
}
}
// Display textures for a canvas this large would not fit the GPU: the reduced CPU canvas
// draws it (checked before the memory budget, which walks the history, every frame).
@@ -8959,3 +8970,7 @@ mod rotation_preview_isolation_tests {
#[cfg(test)]
#[path = "canvas_display_memo_tests.rs"]
mod display_memo_tests;
#[cfg(test)]
#[path = "canvas/blend_viewport_tests.rs"]
mod blend_viewport_regression;
@@ -0,0 +1,198 @@
use super::*;
use photocraft_color::BlendMode;
use serde_json::json;
fn render_state() -> Option<eframe::egui_wgpu::RenderState> {
match std::panic::catch_unwind(|| egui_kittest::wgpu::create_render_state(crate::gpu_canvas::wgpu_setup(), Default::default())) {
Ok(rs) => Some(rs),
Err(error) => {
if std::env::var_os("PHOTOCRAFT_REQUIRE_GPU_TESTS").is_some() {
std::panic::resume_unwind(error);
}
eprintln!("skipping blend viewport regression: no GPU adapter");
None
}
}
}
#[test]
fn blend_hover_refresh_is_limited_to_visible_canvas() {
check_blend_viewport(false, false);
}
#[test]
fn blend_hover_cpu_fallback_refresh_tracks_visible_canvas() {
check_blend_viewport(true, false);
}
#[test]
fn blend_hover_multiple_layers_tracks_visible_canvas() {
check_blend_viewport(false, true);
}
#[test]
fn blend_hover_pattern_tiles_and_multiple_views_reuse_current_pixels() {
let _guard = photocraft_testkit::gpu_lock();
let Some(rs) = render_state() else { return };
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
app.set_wgpu(rs);
app.run("file.new", json!({"width":1024,"height":768,"background":"#336699"})).unwrap();
let layer = app.run("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap();
app.run("edit.fill", json!({"color":"#cc8844"})).unwrap();
let ctx = egui::Context::default();
let a = DRect::new(724, 200, 1324, 500);
let b = DRect::new(0, 200, 300, 500);
assert!(ensure_gpu(&mut app, &ctx, 0, a));
app.ui.view.pattern_preview = true;
crate::blend_preview::hover(&mut app, &[photocraft_doc::LayerId(layer)], Some(BlendMode::Multiply));
assert!(ensure_gpu(&mut app, &ctx, 0, a));
assert_visible_pixels(&mut app, b);
let bounds = app.session.active().unwrap().doc.bounds();
assert_visible_pixels(&mut app, bounds);
crate::blend_preview::hover(&mut app, &[photocraft_doc::LayerId(layer)], None);
assert!(ensure_gpu(&mut app, &ctx, 0, a));
assert_visible_pixels(&mut app, bounds);
app.ui.view.pattern_preview = false;
for mode in [BlendMode::Multiply, BlendMode::Screen] {
crate::blend_preview::hover(&mut app, &[photocraft_doc::LayerId(layer)], Some(mode));
assert!(ensure_gpu(&mut app, &ctx, 0, a));
assert!(ensure_gpu(&mut app, &ctx, 0, b));
for visible in [a, b, a, b] {
app.perf.last_refresh_px = 0;
assert!(ensure_gpu(&mut app, &ctx, 0, visible));
assert_eq!(app.perf.last_refresh_px, 0, "unchanged views must retain coverage together");
assert_visible_pixels(&mut app, visible.intersect(&bounds));
}
}
// Enabling periodic display during an existing hover must catch up the unseen gap too.
app.ui.view.pattern_preview = true;
assert!(ensure_gpu(&mut app, &ctx, 0, a));
assert_visible_pixels(&mut app, bounds);
crate::blend_preview::hover(&mut app, &[photocraft_doc::LayerId(layer)], None);
assert!(ensure_gpu(&mut app, &ctx, 0, a));
assert_visible_pixels(&mut app, bounds);
}
#[test]
fn blend_hover_during_background_redraw_restores_committed_pixels() {
let _guard = photocraft_testkit::gpu_lock();
let Some(rs) = render_state() else { return };
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
app.set_wgpu(rs);
app.background_jobs = true;
app.run("file.new", json!({"width":2600,"height":1700,"background":"#336699"})).unwrap();
let layer = app.run("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap();
app.run("edit.fill", json!({"color":"#cc8844"})).unwrap();
app.run("layer.layerStyle.blendingOptions", json!({"knockout":"shallow"})).unwrap();
let ctx = egui::Context::default();
let visible = DRect::new(256, 256, 512, 512);
assert!(ensure_gpu(&mut app, &ctx, 0, visible));
assert!(!app.redraws.is_empty(), "fixture must start a deferred full redraw");
crate::blend_preview::hover(&mut app, &[photocraft_doc::LayerId(layer)], Some(BlendMode::Multiply));
assert!(ensure_gpu(&mut app, &ctx, 0, visible));
let panned = DRect::new(2048, 1024, 2304, 1280);
assert!(ensure_gpu(&mut app, &ctx, 0, panned));
settle_redraw(&mut app);
assert_visible_pixels(&mut app, panned);
crate::blend_preview::hover(&mut app, &[photocraft_doc::LayerId(layer)], None);
assert!(ensure_gpu(&mut app, &ctx, 0, panned));
settle_redraw(&mut app);
let bounds = app.session.active().unwrap().doc.bounds();
assert_visible_pixels(&mut app, bounds);
}
fn settle_redraw(app: &mut PhotocraftApp) {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
let gpu = app.gpu.clone().unwrap();
let id = app.session.active().unwrap().doc.id;
while !app.redraws.is_empty() {
crate::background_redraw::poll(app, &gpu, id);
assert!(std::time::Instant::now() < deadline, "background redraw did not finish");
std::thread::sleep(std::time::Duration::from_millis(2));
}
}
fn assert_visible_pixels(app: &mut PhotocraftApp, visible: DRect) {
let (doc, _) = display_doc(app, 0);
let gpu = app.gpu.clone().unwrap();
let reference = doc.id.0 ^ (1u64 << 60);
let (display, _) = canvas_display(app, &doc, None);
gpu.upload_composite(reference, &doc, display.as_deref());
let (_, size, shown) = gpu.read_texels(doc.id.0).unwrap();
let (_, _, want) = gpu.read_texels(reference).unwrap();
for y in visible.y0..visible.y1 {
for x in visible.x0..visible.x1 {
let index = y as usize * size[0] as usize + x as usize;
for channel in 0..4 {
assert!((shown[index][channel] - want[index][channel]).abs() <= 2.0 / 255.0, "preview differs at ({x},{y}) channel{channel}");
}
}
}
}
fn check_blend_viewport(cpu_fallback: bool, multiple_layers: bool) {
let _guard = photocraft_testkit::gpu_lock();
let Some(rs) = render_state() else { return };
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
app.set_wgpu(rs);
app.run("file.new", json!({"width":1024,"height":768,"background":"#336699"})).unwrap();
let layer = app.run("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap();
app.run("edit.fill", json!({"color":"#cc8844"})).unwrap();
if cpu_fallback {
app.run("layer.layerStyle.blendingOptions", json!({"knockout":"shallow"})).unwrap();
}
let mut layers = vec![photocraft_doc::LayerId(layer)];
if multiple_layers {
let other = app.run("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap();
app.run("edit.fill", json!({"color": "#4488cc"})).unwrap();
app.run("layer.setProps", json!({"layer": other, "opacity": 0.5})).unwrap();
layers.push(photocraft_doc::LayerId(other));
}
let ctx = egui::Context::default();
let visible = DRect::new(256, 256, 512, 512);
assert!(ensure_gpu(&mut app, &ctx, 0, visible));
assert!(app.redraws.is_empty(), "small fixture must finish synchronously");
if cpu_fallback {
assert!(!app.perf.last_refresh.starts_with("gpu"), "fixture must exercise CPU composition fallback");
}
let id = app.session.active().unwrap().doc.id;
let gpu = app.gpu.clone().unwrap();
let original = gpu.read_texels(id.0).unwrap();
crate::blend_preview::hover(&mut app, &layers, Some(BlendMode::Multiply));
assert!(ensure_gpu(&mut app, &ctx, 0, visible));
println!("BLEND_VIEWPORT kind={} pixels={} expected={} adapter={:?}", app.perf.last_refresh, app.perf.last_refresh_px, 256 * 256, app.perf.gpu_info);
assert_eq!(app.perf.last_refresh_px, 256 * 256, "hover must not recomposite offscreen pixels of a full-canvas layer");
assert_visible_pixels(&mut app, visible);
app.perf.last_refresh_px = 0;
assert!(ensure_gpu(&mut app, &ctx, 0, visible));
assert_eq!(app.perf.last_refresh_px, 0, "unchanged view and mode must reuse the texture");
let zoomed_out = DRect::new(128, 128, 640, 640);
assert!(ensure_gpu(&mut app, &ctx, 0, zoomed_out));
assert_eq!(app.perf.last_refresh_px, 512 * 512, "zooming out catches up the larger visible area");
let panned = DRect::new(640, 256, 896, 512);
assert!(ensure_gpu(&mut app, &ctx, 0, panned));
assert_eq!(app.perf.last_refresh_px, 256 * 256, "newly exposed pixels must catch up without a mode change");
crate::blend_preview::hover(&mut app, &layers, Some(BlendMode::Screen));
assert!(ensure_gpu(&mut app, &ctx, 0, panned));
assert_eq!(app.perf.last_refresh_px, 256 * 256);
assert!(ensure_gpu(&mut app, &ctx, 0, visible));
assert_eq!(app.perf.last_refresh_px, 256 * 256, "returning view must show the current mode");
assert_visible_pixels(&mut app, visible);
assert_visible_pixels(&mut app, panned);
crate::blend_preview::hover(&mut app, &layers, None);
assert!(ensure_gpu(&mut app, &ctx, 0, visible));
assert_eq!(gpu.read_texels(id.0).unwrap(), original, "cancel must restore the whole texture, including previously previewed offscreen pixels");
crate::blend_preview::hover(&mut app, &layers, Some(BlendMode::Multiply));
assert!(ensure_gpu(&mut app, &ctx, 0, visible));
app.run("layer.setProps", json!({"layer":layer,"blend":"Multiply"})).unwrap();
assert!(ensure_gpu(&mut app, &ctx, 0, visible));
let bounds = app.session.active().unwrap().doc.bounds();
assert_visible_pixels(&mut app, bounds);
app.run("file.new", json!({"width":1024,"height":768,"background":"white"})).unwrap();
assert!(ensure_gpu(&mut app, &ctx, 1, visible));
app.session.set_active(0);
app.sync_views();
assert!(ensure_gpu(&mut app, &ctx, 0, visible));
assert_visible_pixels(&mut app, bounds);
}
+32 -1
View File
@@ -1280,7 +1280,7 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest)
}
None => err("missing `path`"),
},
"app.save" => match crate::SaveChoices::from_params(p, "app.save") {
"app.save" => match crate::FormatChoices::from_params(p, "app.save") {
Ok(choices) => {
let path = s("path").map(str::to_string);
run_waiting(app, p.get("wait").and_then(Value::as_bool).unwrap_or(true), move |app| app.save_automation_with(path, choices))
@@ -2489,6 +2489,37 @@ mod tests {
assert_eq!(written.borrow().len(), 3, "a bad JPEG option writes nothing");
}
/// `app.save` takes the DDS options (`ddsCompression`, `ddsMipmaps`) and never stops at DDS
/// Options.
#[test]
fn automation_save_passes_dds_options_without_asking() {
use photocraft_io::{DdsCompression, DdsOptions};
use std::cell::RefCell;
use std::rc::Rc;
let asked: Rc<RefCell<Vec<DdsOptions>>> = Rc::default();
let a = asked.clone();
let services = crate::Services {
export: Some(Box::new(move |_d: &photocraft_doc::Document, _p: &str, s: &crate::ExportSettings| {
a.borrow_mut().push(s.dds);
Ok((b"out".to_vec(), Vec::new()))
})),
automation_write: Some(Box::new(|_: &str, _: &[u8]| Ok(()))),
..Default::default()
};
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), services);
let ctx = egui::Context::default();
app.run("file.new", json!({"width": 8, "height": 8})).unwrap();
for params in [json!({"path": "a.dds"}), json!({"path": "b.dds", "ddsCompression": "bc1", "ddsMipmaps": true})] {
let r = call(&mut app, &ctx, "app.save", params.clone());
assert_eq!(r["result"]["path"], params["path"], "{r}");
assert!(app.dds_options.is_none(), "automation saves never prompt");
}
assert_eq!(*asked.borrow(), [DdsOptions::default(), DdsOptions { compression: DdsCompression::Bc1, mipmaps: true }]);
let r = call(&mut app, &ctx, "app.save", json!({"path": "c.dds", "ddsCompression": "bc9"}));
assert!(r["error"].as_str().unwrap().contains("ddsCompression"), "{r}");
assert_eq!(asked.borrow().len(), 2, "a bad ddsCompression writes nothing");
}
#[test]
fn automation_open_and_save_use_the_roots_and_reply_with_warnings() {
use photocraft_color::{ColorMode, SampleType};
+148
View File
@@ -0,0 +1,148 @@
//! DDS Options: the compression and mip chain File › Save As and Save a Copy ask for before
//! writing a DirectDraw Surface texture (#1327). Photoshop has no DDS writer of its own (a plug-in
//! adds one), so this follows the texture tools' usual choices: uncompressed, or BC1/BC2/BC3
//! (DXT1/DXT3/DXT5), and whether to generate mipmaps (see `photocraft_io::DdsOptions`).
//!
//! The save is parked in [`Prompt`] while the modal is up (egui is immediate-mode) and written
//! once answered. Cancel drops the save. Scripts skip the prompt by passing `ddsCompression` or
//! `ddsMipmaps`.
use photocraft_io::{DdsCompression, DdsOptions};
use crate::{ExportSettings, PhotocraftApp};
/// A save waiting on the DDS Options answer.
pub struct Prompt {
/// The document that requested the save, even if tabs change while this is open.
doc: photocraft_doc::DocId,
/// Where the texture goes.
pub path: String,
/// The options chosen, starting uncompressed without mipmaps.
pub options: DdsOptions,
/// Save a Copy: the document keeps its path and unsaved changes.
pub copy: bool,
/// The step the answered save is recorded as (`file.saveAs` or `file.saveACopy`), so an
/// action being recorded keeps it; `file.save` when File › Save confirmed the document's own
/// texture (#3531), which plays back on the file the action is played on.
pub record: &'static str,
}
/// `true` when `path` names a DDS texture.
pub fn wants_prompt(path: &str) -> bool {
photocraft_engine::file_cmds::extension(path).and_then(|e| photocraft_codecs::from_extension(&e)) == Some(photocraft_codecs::Format::Dds)
}
/// The DDS options a command or control call asks for (`"ddsCompression"`, `"ddsMipmaps"`),
/// which skip the prompt.
pub fn options_param(params: &serde_json::Value, cmd: &str) -> Result<Option<DdsOptions>, String> {
photocraft_engine::file_cmds::dds_param(params, cmd).map_err(|e| e.to_string())
}
/// The params a save with `options` is recorded with.
pub fn record(options: DdsOptions, step: &mut serde_json::Value) {
step["ddsCompression"] = options.compression.name().into();
step["ddsMipmaps"] = options.mipmaps.into();
}
/// Parks the save behind the prompt. A prompt already waiting is never replaced.
pub fn park(app: &mut PhotocraftApp, path: String, copy: bool, record: &'static str) -> Result<(), String> {
if app.save_options_open() {
return Err(crate::SAVE_OPTIONS_OPEN.into());
}
let doc = app.active_doc_id()?;
app.dds_options = Some(Prompt { doc, path, options: DdsOptions::default(), copy, record });
Ok(())
}
/// The block compressions go by their format names, which stay untranslated like other
/// technology names.
fn label(c: DdsCompression) -> &'static str {
match c {
DdsCompression::None => tl!("None"),
DdsCompression::Bc1 => "BC1 / DXT1",
DdsCompression::Bc2 => "BC2 / DXT3",
DdsCompression::Bc3 => "BC3 / DXT5",
}
}
pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
let Some(p) = &mut app.dds_options else { return };
let (mut ok, mut cancel) = (false, false);
let mut options = p.options;
let modal = egui::Modal::new(egui::Id::new("dds-options")).show(ctx, |ui| {
ui.set_max_width(340.0);
ui.label(egui::RichText::new(tl!("DDS Options")).font(crate::theme::semibold(15.0)));
ui.add_space(4.0);
crate::widgets::hairline(ui);
ui.add_space(8.0);
crate::widgets::section_label(ui, tl!("Compression"));
for c in DdsCompression::ALL {
ui.radio_value(&mut options.compression, c, label(c));
}
ui.add_space(8.0);
crate::widgets::checkbox(ui, &mut options.mipmaps, tl!("Generate Mipmaps"));
ui.add_space(12.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.spacing_mut().item_spacing.x = 10.0;
ok = crate::widgets::primary_button(ui, tl!("OK"), 84.0).clicked();
cancel = crate::widgets::secondary_button(ui, tl!("Cancel"), 84.0).clicked();
});
});
cancel |= modal.should_close();
p.options = options;
// A focused button takes Enter as its click without consuming the key: Enter on Cancel cancels.
if ok || (!cancel && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter))) {
let Some(p) = app.dds_options.take() else { return };
let settings = ExportSettings { dds: p.options, ..app.save_settings() };
let mut step = if p.record == "file.save" { serde_json::json!({}) } else { serde_json::json!({"path": p.path}) };
record(p.options, &mut step);
let written = app.with_document(p.doc, |app| app.write_document(p.path, &settings, p.copy));
if written.is_ok() {
// An action being recorded keeps the Save As or Save a Copy with this answer.
app.session.journal.push((p.record.into(), step));
}
match written {
Err(e) => {
app.ui.status = crate::i18n::fmt(tl!("Save failed: {error}"), &[("error", &e)]);
app.ui.status_error = true;
}
Ok(Some(_)) if !p.copy => crate::discard_ui::saved_document(app, ctx, p.doc),
// A background save releases the close prompt when it finishes (`jobs_ui`).
Ok(_) => {}
}
} else if cancel {
app.dds_options = None;
app.ui.status = tl!("Save cancelled").into();
app.ui.status_error = false;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dds_names() {
assert!(wants_prompt("a.dds"));
assert!(wants_prompt("dir/sub/a.DDS"));
assert!(wants_prompt("C:\\x\\y.dds"));
assert!(!wants_prompt("a.dds.psd"));
assert!(!wants_prompt(".dds"));
assert!(!wants_prompt("a.png"));
}
#[test]
fn options_params() {
use serde_json::json;
assert_eq!(options_param(&json!({}), "file.saveAs"), Ok(None));
assert_eq!(options_param(&json!({"ddsCompression": "bc3"}), "file.saveAs"), Ok(Some(DdsOptions { compression: DdsCompression::Bc3, mipmaps: false })));
assert_eq!(options_param(&json!({"ddsMipmaps": true}), "file.saveAs"), Ok(Some(DdsOptions { compression: DdsCompression::None, mipmaps: true })));
for bad in [json!({"ddsCompression": "bc7"}), json!({"ddsCompression": 3}), json!({"ddsMipmaps": "yes"})] {
assert!(options_param(&bad, "file.saveAs").is_err(), "{bad}");
}
let mut step = json!({"path": "a.dds"});
record(DdsOptions { compression: DdsCompression::Bc1, mipmaps: true }, &mut step);
assert_eq!(step, json!({"path": "a.dds", "ddsCompression": "bc1", "ddsMipmaps": true}));
assert_eq!(options_param(&step, "file.saveAs"), Ok(Some(DdsOptions { compression: DdsCompression::Bc1, mipmaps: true })));
}
}
+2
View File
@@ -961,6 +961,8 @@ mod tests {
#[path = "jpeg_tests.rs"]
mod jpeg_tests;
#[path = "dds_tests.rs"]
mod dds_tests;
/// One frame with the window's close button pressed; the commands the guard sent.
fn window_close_commands(app: &mut PhotocraftApp) -> Vec<egui::ViewportCommand> {
@@ -0,0 +1,217 @@
//! DDS Options on Save As and Save a Copy: the compression and mipmaps it asks for, the scripted
//! `ddsCompression` and `ddsMipmaps` that skip it, and action recording and playback (#1327).
use super::*;
use crate::{FileDialogAnswer, dds_options_ui};
use egui_kittest::kittest::Queryable;
use photocraft_io::{DdsCompression, DdsOptions};
use std::{cell::RefCell, rc::Rc};
/// Each write: the path, and the DDS options the export was asked for.
type Writes = Rc<RefCell<Vec<(String, DdsOptions)>>>;
const PLAIN: DdsOptions = DdsOptions { compression: DdsCompression::None, mipmaps: false };
/// One 8×8 RGB document whose exports record their DDS options, in a harness showing DDS Options
/// and answering a save dialog with `to`.
fn dds_app(to: &str) -> (Prompted, Writes) {
let mut app = app_with_docs(1);
let writes: Writes = Rc::default();
let (asked, written) = (Rc::new(RefCell::new(PLAIN)), writes.clone());
let seen = asked.clone();
app.services.file_dialog = Some(crate::file_dialog::fake(vec![Some(FileDialogAnswer::SaveTo(to.into()))]).0);
app.services.export = Some(Box::new(move |_, _, settings| {
*seen.borrow_mut() = settings.dds;
Ok((Vec::new(), Vec::new()))
}));
app.services.write = Some(Box::new(move |path, _| {
written.borrow_mut().push((path.to_string(), *asked.borrow()));
Ok(())
}));
let h = egui_kittest::Harness::builder().with_size(egui::vec2(800.0, 600.0)).build_ui_state(
|ui, app| {
show(app, ui.ctx());
dds_options_ui::show(app, ui.ctx());
app.poll_file_dialog(ui.ctx(), None);
},
app,
);
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]);
h.ctx.set_os(egui::os::OperatingSystem::Windows);
(h, writes)
}
/// Save As to a DDS texture asks first, starting uncompressed without mipmaps, and writes only
/// what is chosen once answered.
#[test]
fn save_as_to_a_dds_texture_asks_for_its_compression() {
let (mut h, writes) = dds_app("unused.dds");
h.state_mut().save_as(Some("t.dds".into())).unwrap();
h.run_steps(2);
assert_eq!(h.state().dds_options.as_ref().map(|p| (p.options, p.copy)), Some((PLAIN, false)));
assert!(writes.borrow().is_empty(), "nothing is written before the answer");
assert!(h.state_mut().save_as(Some("other.dds".into())).is_err(), "one save at a time");
h.get_by_label("BC3 / DXT5").click();
h.run_steps(2);
h.get_by_label("Generate Mipmaps").click();
h.run_steps(2);
h.get_by_label("OK").click();
h.run_steps(3);
let chosen = DdsOptions { compression: DdsCompression::Bc3, mipmaps: true };
assert_eq!(writes.borrow().as_slice(), &[("t.dds".to_string(), chosen)]);
assert!(h.state().dds_options.is_none());
assert_eq!(h.state().session.active().unwrap().path.as_deref(), Some("t.dds"));
// Cancel writes nothing.
h.state_mut().save_as(Some("c.dds".into())).unwrap();
h.run_steps(2);
h.key_press(Key::Escape);
h.run_steps(3);
assert!(h.state().dds_options.is_none());
assert_eq!(writes.borrow().len(), 1, "cancelled: nothing more written");
assert_eq!(h.state().ui.status, "Save cancelled");
}
/// Scripts choose with `ddsCompression` or `ddsMipmaps` and are never asked; bad values write
/// nothing, and other formats aren't asked.
#[test]
fn scripted_dds_options_and_other_formats_skip_the_prompt() {
let (mut h, writes) = dds_app("unused.dds");
let ctx = h.ctx.clone();
crate::menus::invoke(h.state_mut(), &ctx, "file.saveAs", json!({"path": "s.dds", "ddsCompression": "bc1"})).unwrap();
crate::menus::invoke(h.state_mut(), &ctx, "file.saveAs", json!({"path": "m.dds", "ddsMipmaps": true})).unwrap();
assert!(h.state().dds_options.is_none());
for bad in [json!({"ddsCompression": "bc7"}), json!({"ddsMipmaps": 1})] {
let mut p = bad.clone();
p["path"] = json!("bad.dds");
assert!(crate::menus::invoke(h.state_mut(), &ctx, "file.saveAs", p).is_err(), "{bad}");
}
crate::menus::invoke(h.state_mut(), &ctx, "file.saveAs", json!({"path": "p.png"})).unwrap();
assert!(h.state().dds_options.is_none());
assert_eq!(
writes.borrow().as_slice(),
&[
("s.dds".to_string(), DdsOptions { compression: DdsCompression::Bc1, mipmaps: false }),
("m.dds".to_string(), DdsOptions { compression: DdsCompression::None, mipmaps: true }),
("p.png".to_string(), PLAIN),
]
);
}
/// Save a Copy to a DDS texture asks too, and the copy leaves the document's path and unsaved
/// state.
#[test]
fn save_a_copy_to_a_dds_texture_asks_and_keeps_the_document_unsaved() {
let (mut h, writes) = dds_app("copy.dds");
make_dirty(h.state_mut(), 0);
let ctx = h.ctx.clone();
crate::menus::invoke(h.state_mut(), &ctx, "file.saveACopy", json!({})).unwrap();
h.run_steps(3);
assert_eq!(h.state().dds_options.as_ref().map(|p| (p.options, p.copy)), Some((PLAIN, true)));
assert!(dds_options_ui::park(h.state_mut(), "other.dds".into(), false, "file.saveAs").is_err(), "the waiting prompt is kept");
h.key_press(Key::Enter);
h.run_steps(3);
assert_eq!(writes.borrow().as_slice(), &[("copy.dds".to_string(), PLAIN)]);
let st = h.state().session.active().unwrap();
assert!(st.is_dirty());
assert_eq!(st.path, None);
}
/// An action being recorded keeps a DDS Save As or Save a Copy with its options, answered in DDS
/// Options or scripted, and plays them back without asking.
#[test]
fn recorded_dds_saves_keep_their_options_and_replay_without_asking() {
let (mut h, writes) = dds_app("copy.dds");
let ctx = h.ctx.clone();
h.state_mut().run("actions.record", json!({})).unwrap();
h.state_mut().save_as(Some("t.dds".into())).unwrap();
h.run_steps(2);
h.get_by_label("BC1 / DXT1").click();
h.run_steps(2);
h.get_by_label("OK").click();
h.run_steps(3);
crate::menus::invoke(h.state_mut(), &ctx, "file.saveACopy", json!({})).unwrap();
h.run_steps(3);
h.get_by_label("Generate Mipmaps").click();
h.run_steps(2);
h.key_press(Key::Enter);
h.run_steps(3);
crate::menus::invoke(h.state_mut(), &ctx, "file.saveAs", json!({"path": "s.dds", "ddsCompression": "bc2"})).unwrap();
h.state_mut().run("actions.stop", json!({})).unwrap();
let want = [
("t.dds".to_string(), DdsOptions { compression: DdsCompression::Bc1, mipmaps: false }),
("copy.dds".to_string(), DdsOptions { compression: DdsCompression::None, mipmaps: true }),
("s.dds".to_string(), DdsOptions { compression: DdsCompression::Bc2, mipmaps: false }),
];
assert_eq!(writes.borrow().as_slice(), &want);
let steps = &h.state().session.actions.list[0].steps;
assert_eq!(
steps,
&[
("file.saveAs".into(), json!({"path": "t.dds", "ddsCompression": "bc1", "ddsMipmaps": false})),
("file.saveACopy".into(), json!({"path": "copy.dds", "ddsCompression": "none", "ddsMipmaps": true})),
("file.saveAs".into(), json!({"path": "s.dds", "ddsCompression": "bc2", "ddsMipmaps": false}))
]
);
writes.borrow_mut().clear();
let played = h.state_mut().run("actions.play", json!({"action": 0})).unwrap();
assert!(played.get("failed").is_none(), "{played}");
assert!(h.state().dds_options.is_none(), "playback doesn't ask");
assert_eq!(writes.borrow().as_slice(), &want);
}
/// File › Save confirming the document's own texture asks DDS Options, and an action being
/// recorded keeps it as a plain Save with the answer (scripted options too): played back, it
/// writes the texture the action is played on as recorded, without asking (#3531).
#[test]
fn recorded_plain_save_of_a_dds_texture_keeps_its_options() {
let (mut h, writes) = dds_app("own.dds");
let ctx = h.ctx.clone();
crate::menus::invoke(h.state_mut(), &ctx, "file.saveAs", json!({"path": "own.dds", "ddsCompression": "none"})).unwrap();
assert_eq!(h.state().session.active().unwrap().path.as_deref(), Some("own.dds"));
h.state_mut().run("actions.record", json!({})).unwrap();
crate::menus::invoke(h.state_mut(), &ctx, "file.save", json!({})).unwrap();
h.run_steps(3);
assert!(h.state().dds_options.is_some(), "confirming its own texture asks DDS Options");
h.get_by_label("BC1 / DXT1").click();
h.run_steps(2);
h.get_by_label("OK").click();
h.run_steps(3);
h.state_mut().services.file_dialog = Some(crate::file_dialog::fake(vec![Some(FileDialogAnswer::SaveTo("own.dds".into()))]).0);
crate::menus::invoke(h.state_mut(), &ctx, "file.save", json!({"ddsCompression": "bc3", "ddsMipmaps": true})).unwrap();
h.run_steps(3);
assert!(h.state().dds_options.is_none(), "scripted options don't ask");
h.state_mut().run("actions.stop", json!({})).unwrap();
let bc1 = DdsOptions { compression: DdsCompression::Bc1, mipmaps: false };
let bc3 = DdsOptions { compression: DdsCompression::Bc3, mipmaps: true };
assert_eq!(&writes.borrow()[1..], &[("own.dds".to_string(), bc1), ("own.dds".to_string(), bc3)]);
let steps = &h.state().session.actions.list[0].steps;
assert_eq!(
steps,
&[
("file.save".into(), json!({"ddsCompression": "bc1", "ddsMipmaps": false})),
("file.save".into(), json!({"ddsCompression": "bc3", "ddsMipmaps": true}))
]
);
crate::menus::invoke(h.state_mut(), &ctx, "file.saveAs", json!({"path": "other.dds", "ddsCompression": "none"})).unwrap();
writes.borrow_mut().clear();
let played = h.state_mut().run("actions.play", json!({"action": 0})).unwrap();
assert!(played.get("failed").is_none(), "{played}");
assert!(h.state().dds_options.is_none(), "playback doesn't ask");
assert_eq!(writes.borrow().as_slice(), &[("other.dds".to_string(), bc1), ("other.dds".to_string(), bc3)]);
}
/// Enter on a focused Cancel cancels: egui clicks the focused button on Enter without consuming
/// the key, so the dialog's Enter-for-OK must not save as well.
#[test]
fn enter_on_a_focused_cancel_writes_nothing() {
let (mut h, writes) = dds_app("unused.dds");
h.state_mut().save_as(Some("t.dds".into())).unwrap();
h.run_steps(2);
h.get_by_label("Cancel").focus();
h.run_steps(2);
h.key_press(Key::Enter);
h.run_steps(3);
assert!(h.state().dds_options.is_none());
assert!(writes.borrow().is_empty(), "nothing written");
assert_eq!(h.state().ui.status, "Save cancelled");
}
@@ -201,3 +201,46 @@ fn closing_after_a_targa_save_waits_for_targa_options() {
assert!(h.state().discard.is_none());
assert!(h.state().session.documents().is_empty());
}
/// File › Save confirming the document's own Targa asks Targa Options, and an action being
/// recorded keeps it as a plain Save with the bits chosen: played back, it writes the Targa the
/// action is played on with them, without asking (#3531).
#[test]
fn recorded_plain_save_of_a_targa_keeps_its_bits() {
let (mut h, writes) = targa_app(false, "own.tga");
let ctx = h.ctx.clone();
crate::menus::invoke(h.state_mut(), &ctx, "file.saveAs", json!({"path": "own.tga", "tgaBits": 24})).unwrap();
h.state_mut().run("actions.record", json!({})).unwrap();
crate::menus::invoke(h.state_mut(), &ctx, "file.save", json!({})).unwrap();
h.run_steps(3);
assert!(h.state().targa_options.is_some(), "confirming its own Targa asks Targa Options");
h.get_by_label("32 bits/pixel").click();
h.run_steps(2);
h.get_by_label("OK").click();
h.run_steps(3);
h.state_mut().run("actions.stop", json!({})).unwrap();
let steps = &h.state().session.actions.list[0].steps;
assert_eq!(steps, &[("file.save".into(), json!({"tgaBits": 32}))]);
crate::menus::invoke(h.state_mut(), &ctx, "file.saveAs", json!({"path": "other.tga", "tgaBits": 24})).unwrap();
writes.borrow_mut().clear();
let played = h.state_mut().run("actions.play", json!({"action": 0})).unwrap();
assert!(played.get("failed").is_none(), "{played}");
assert!(h.state().targa_options.is_none(), "playback doesn't ask");
assert_eq!(writes.borrow().as_slice(), &[("other.tga".to_string(), asked(Some(32)))]);
}
/// Enter on a focused Cancel cancels: egui clicks the focused button on Enter without consuming
/// the key, so the dialog's Enter-for-OK must not save as well.
#[test]
fn enter_on_a_focused_cancel_writes_nothing() {
let (mut h, writes) = targa_app(false, "unused.tga");
h.state_mut().save_as(Some("t.tga".into())).unwrap();
h.run_steps(2);
h.get_by_label("Cancel").focus();
h.run_steps(2);
h.key_press(Key::Enter);
h.run_steps(3);
assert!(h.state().targa_options.is_none());
assert!(writes.borrow().is_empty(), "nothing written");
assert_eq!(h.state().ui.status, "Save cancelled");
}
@@ -79,7 +79,7 @@ fn closing_after_tiff_save_waits_for_options_and_writes_the_requested_document()
#[test]
fn cancelling_failing_or_copying_a_tiff_keeps_the_original_unsaved() {
for outcome in ["cancel", "export error", "write error", "copy"] {
for outcome in ["cancel", "enter on cancel", "export error", "write error", "copy"] {
let (mut h, writes, saved, _) = tiff_prompt("file.close");
h.key_press(Key::Y);
h.run_steps(3);
@@ -90,6 +90,11 @@ fn cancelling_failing_or_copying_a_tiff_keeps_the_original_unsaved() {
h.get_by_label("Discard Layers and Save a Copy").click();
h.run_steps(2);
}
// Enter on a focused Cancel is its click, not OK.
"enter on cancel" => {
h.get_by_label("Cancel").focus();
h.run_steps(2);
}
_ => {}
}
h.key_press(if outcome == "cancel" { Key::Escape } else { Key::Enter });
+4 -4
View File
@@ -20,7 +20,7 @@ pub fn owns(fields: &Map<String, Value>) -> bool {
/// Opens the prompt instead of saving `doc` to its own file at `path` when another app changed
/// that file; `None` saves as usual. `flat` is a flat file confirmed in the Save dialog, which
/// is saved (and recorded) the way [`PhotocraftApp::save_dialog_flow`] does it.
pub(crate) fn intercept(app: &mut PhotocraftApp, doc: DocId, path: &str, flat: bool, choices: crate::SaveChoices) -> Option<Result<Value, String>> {
pub(crate) fn intercept(app: &mut PhotocraftApp, doc: DocId, path: &str, flat: bool, choices: crate::FormatChoices) -> Option<Result<Value, String>> {
if !app.changed_on_disk(doc, path) {
return None;
}
@@ -30,7 +30,7 @@ pub(crate) fn intercept(app: &mut PhotocraftApp, doc: DocId, path: &str, flat: b
let name = crate::file_open::display_name(path);
let message = crate::i18n::fmt(tl!("{name} was changed by another app after it was opened or saved here."), &[("name", name.as_str())]);
let mut fields = json!({MARK: true, "__label": "Save", "message": message, "document": doc.0, "path": path, "flat": flat});
// A script's TIFF, Targa or JPEG answer is kept for the save Replace writes.
// A script's Targa, DDS or JPEG answer is kept for the save Replace writes.
choices.record(&mut fields);
let id = app.ui.open_dialog(DialogKind::Command, fields.as_object().cloned().unwrap_or_default());
Some(Ok(json!({"dialog": id, "confirmationPending": true})))
@@ -47,14 +47,14 @@ pub fn confirm(app: &mut PhotocraftApp, fields: &Map<String, Value>) -> Result<V
let doc = fields.get("document").and_then(Value::as_u64).map(DocId).ok_or(tl!("The document is closed"))?;
let path = fields.get("path").and_then(Value::as_str).unwrap_or_default().to_string();
let flat = fields.get("flat").and_then(Value::as_bool) == Some(true);
let choices = crate::SaveChoices::from_step(&Value::Object(fields.clone()), "file.save")?;
let choices = crate::FormatChoices::from_step(&Value::Object(fields.clone()), "file.save")?;
app.with_document(doc, |app| {
if flat {
let r = app.save_to(path.clone(), choices)?;
app.journal_as_plain_save(&path);
Ok(r)
} else {
app.save_in_place(path)
app.save_in_place(path, crate::FormatChoices::default())
}
})
}
+4
View File
@@ -3857,3 +3857,7 @@ navigator Zoom Zvětšení
Use Použít
In Front Vpředu
Behind Vzadu
# DDS Options (#1327)
DDS Options Možnosti DDS
Generate Mipmaps Generovat mipmapy
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5
View File
@@ -3250,3 +3250,8 @@ curves Smooth Glätten
In Front Davor
Behind Dahinter
Blend Mischen
# DDS Options (#1327)
DDS Options DDS-Optionen
Compression Komprimierung
Generate Mipmaps Mipmaps erzeugen
Can't render this file because it contains an unexpected character in line 6 and column 47.
+4
View File
@@ -3949,3 +3949,7 @@ curves Smooth Εξομάλυνση
Use Χρήση
In Front Μπροστά
Behind Πίσω
# DDS Options (#1327)
DDS Options Επιλογές DDS
Generate Mipmaps Δημιουργία mipmap
Can't render this file because it contains an unexpected character in line 1030 and column 18.
+4
View File
@@ -4211,3 +4211,7 @@ navigator Zoom Zoom
Use Usar
In Front Delante
Behind Detrás
# DDS Options (#1327)
DDS Options Opciones de DDS
Generate Mipmaps Generar mipmaps
Can't render this file because it contains an unexpected character in line 4 and column 47.
+5
View File
@@ -3257,3 +3257,8 @@ curves Smooth Lisser
Use Utiliser
In Front Devant
Behind Derrière
# DDS Options (#1327)
DDS Options Options DDS
Compression Compression
Generate Mipmaps Générer les mipmaps
Can't render this file because it contains an unexpected character in line 6 and column 47.
+4
View File
@@ -4168,3 +4168,7 @@ cameraRaw Yellow Sárga
Use Használat
In Front Elöl
Behind Mögött
# DDS Options (#1327)
DDS Options DDS-beállítások
Generate Mipmaps Mipmapek létrehozása
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5
View File
@@ -3244,3 +3244,8 @@ curves Smooth Haluskan
Use Gunakan
In Front Di Depan
Behind Di Belakang
# DDS Options (#1327)
DDS Options Opsi DDS
Compression Kompresi
Generate Mipmaps Buat mipmap
Can't render this file because it contains an unexpected character in line 4 and column 47.
+4
View File
@@ -4366,3 +4366,7 @@ shape Appearance Aspetto
Use Usa
In Front Davanti
Behind Dietro
# DDS Options (#1327)
DDS Options Opzioni DDS
Generate Mipmaps Genera mipmap
Can't render this file because it contains an unexpected character in line 2379 and column 12.
+5
View File
@@ -3926,3 +3926,8 @@ curves Smooth スムーズ
Use 使用
In Front 前面
Behind 背面
# DDS Options (#1327)
DDS Options DDS オプション
Compression 圧縮
Generate Mipmaps ミップマップを生成
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5
View File
@@ -3431,3 +3431,8 @@ curves Smooth 매끄럽게
In Front 앞
Behind 뒤
Blend 혼합
# DDS Options (#1327)
DDS Options DDS 옵션
Compression 압축
Generate Mipmaps 밉맵 생성
Can't render this file because it contains an unexpected character in line 2904 and column 12.
+4
View File
@@ -4009,3 +4009,7 @@ curves Smooth Vloeiend maken
Use Gebruiken
In Front Voor
Behind Achter
# DDS Options (#1327)
DDS Options DDS-opties
Generate Mipmaps Mipmaps genereren
Can't render this file because it contains an unexpected character in line 8 and column 34.
+5
View File
@@ -3848,3 +3848,8 @@ curves Smooth Wygładź
Use Użyj
In Front Z przodu
Behind Z tyłu
# DDS Options (#1327)
DDS Options Opcje DDS
Compression Kompresja
Generate Mipmaps Generuj mipmapy
Can't render this file because it contains an unexpected character in line 6 and column 47.
+4
View File
@@ -4039,3 +4039,7 @@ layerLabel No Color Sem cor
Use Usar
In Front Na frente
Behind Atrás
# DDS Options (#1327)
DDS Options Opções de DDS
Generate Mipmaps Gerar mipmaps
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5
View File
@@ -3223,3 +3223,8 @@ curves Smooth Сгладить
Use Использовать
In Front Спереди
Behind Сзади
# DDS Options (#1327)
DDS Options Параметры DDS
Compression Сжатие
Generate Mipmaps Создать MIP-карты
Can't render this file because it contains an unexpected character in line 6 and column 47.
+4
View File
@@ -4322,3 +4322,7 @@ shape Appearance Izgled
Use Koristi
In Front Ispred
Behind Iza
# DDS Options (#1327)
DDS Options DDS opcije
Generate Mipmaps Generiši mipmape
Can't render this file because it contains an unexpected character in line 6 and column 47.
+4
View File
@@ -3979,3 +3979,7 @@ cameraData Lens Lens
Use Kullan
In Front Önde
Behind Arkada
# DDS Options (#1327)
DDS Options DDS Seçenekleri
Generate Mipmaps Mipmap oluştur
Can't render this file because it contains an unexpected character in line 8 and column 34.
+5
View File
@@ -3957,3 +3957,8 @@ curves Smooth Згладити
Use Використати
In Front Спереду
Behind Ззаду
# DDS Options (#1327)
DDS Options Параметри DDS
Compression Стиснення
Generate Mipmaps Створити MIP-карти
Can't render this file because it contains an unexpected character in line 1388 and column 18.
+5
View File
@@ -3736,3 +3736,8 @@ curves Smooth Làm mượt
Use Dùng
In Front Phía trước
Behind Phía sau
# DDS Options (#1327)
DDS Options Tùy chọn DDS
Compression Nén
Generate Mipmaps Tạo mipmap
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5
View File
@@ -2873,3 +2873,8 @@ curves Smooth 平滑
In Front 前面
Behind 后面
Blend 混合
# DDS Options (#1327)
DDS Options DDS 选项
Compression 压缩
Generate Mipmaps 生成 Mipmap
Can't render this file because it contains an unexpected character in line 2327 and column 12.
+5
View File
@@ -2858,3 +2858,8 @@ curves Smooth 平滑
In Front 前面
Behind 後面
Blend 混合
# DDS Options (#1327)
DDS Options DDS 選項
Compression 壓縮
Generate Mipmaps 產生 Mipmap
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2 -1
View File
@@ -231,7 +231,8 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
cancel |= modal.should_close();
p.quality = quality.min(MAX_QUALITY);
p.format = format;
if ok || ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter)) {
// A focused button takes Enter as its click without consuming the key: Enter on Cancel cancels.
if ok || (!cancel && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter))) {
confirm(app, ctx);
} else if cancel {
app.jpeg_options = None;
+95 -60
View File
@@ -69,6 +69,7 @@ pub mod crop_shield;
pub mod crop_size;
pub mod crop_straighten;
pub mod crop_ui;
pub mod dds_options_ui;
pub mod delete_layer_prompt;
pub mod dialog_alt_reset;
pub mod dialog_blend_ui;
@@ -254,42 +255,56 @@ pub use file_open::OsEvent;
pub use state::{Tool, UiState};
/// The error a save gets while another save waits on its format prompt.
pub(crate) const SAVE_OPTIONS_OPEN: &str = "Answer TIFF, Targa or JPEG Options before starting another save";
pub(crate) const SAVE_OPTIONS_OPEN: &str = "Answer the TIFF, Targa, DDS or JPEG Options before starting another save";
/// What a save was told beyond its path, so it skips the prompts that would ask: Targa bits
/// (`tgaBits`) and JPEG Options (`quality` 0–12, `jpegFormat`).
/// The format answers a save takes instead of asking: the Targa bits per pixel (`"tgaBits"`),
/// the DDS options (`"ddsCompression"`, `"ddsMipmaps"`) and JPEG Options (`"quality"` 0–12,
/// `"jpegFormat"`). `None` asks in Save As and takes the default in automation saves, which never
/// prompt.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct SaveChoices {
pub struct FormatChoices {
pub tga_bits: Option<photocraft_io::TgaBits>,
pub dds: Option<photocraft_io::DdsOptions>,
pub jpeg: Option<jpeg_options_ui::JpegChoice>,
}
impl SaveChoices {
/// The choices in command or control call `cmd`'s params; one it can't read is an error.
impl FormatChoices {
/// The answers in command or control call `cmd`'s params; a bad value is an error.
pub fn from_params(params: &Value, cmd: &str) -> Result<Self, String> {
Ok(SaveChoices { tga_bits: targa_options_ui::bits_param(params, cmd)?, jpeg: jpeg_options_ui::choice_param(params, cmd)? })
Ok(FormatChoices {
tga_bits: targa_options_ui::bits_param(params, cmd)?,
dds: dds_options_ui::options_param(params, cmd)?,
jpeg: jpeg_options_ui::choice_param(params, cmd)?,
})
}
/// The choices in a recorded save step, read as leniently as the engine command that may have
/// The answers in a recorded save step, read as leniently as the engine command that may have
/// recorded it (see [`jpeg_options_ui::recorded_choice`]).
fn from_step(params: &Value, cmd: &str) -> Result<Self, String> {
Ok(SaveChoices { tga_bits: targa_options_ui::bits_param(params, cmd)?, jpeg: jpeg_options_ui::recorded_choice(params, cmd)? })
pub(crate) fn from_step(params: &Value, cmd: &str) -> Result<Self, String> {
Ok(FormatChoices {
tga_bits: targa_options_ui::bits_param(params, cmd)?,
dds: dds_options_ui::options_param(params, cmd)?,
jpeg: jpeg_options_ui::recorded_choice(params, cmd)?,
})
}
/// `settings` writing these choices.
/// The export settings with these answers applied.
pub fn apply(self, settings: ExportSettings) -> ExportSettings {
let settings = ExportSettings { tga_bits: self.tga_bits.or(settings.tga_bits), ..settings };
let settings = ExportSettings { tga_bits: self.tga_bits.or(settings.tga_bits), dds: self.dds.unwrap_or(settings.dds), ..settings };
match self.jpeg {
Some(j) => j.apply(settings),
None => settings,
}
}
/// Adds the choices to a recorded save step.
fn record(self, step: &mut Value) {
/// Adds the answers to a recorded save step.
pub(crate) fn record(self, step: &mut Value) {
if let Some(bits) = self.tga_bits {
step["tgaBits"] = bits.bits().into();
}
if let Some(dds) = self.dds {
dds_options_ui::record(dds, step);
}
if let Some(j) = self.jpeg {
j.record(step);
}
@@ -318,6 +333,9 @@ pub struct ExportSettings {
/// Targa: bits per pixel from Targa Options or Export As (`None`: 32 with an alpha channel or
/// transparency, else 24).
pub tga_bits: Option<photocraft_io::TgaBits>,
/// DDS: the compression and mip chain from DDS Options (uncompressed without mipmaps unless
/// chosen).
pub dds: photocraft_io::DdsOptions,
/// PSD/PSB: embed a thumbnail (Preferences › File Handling › Image Previews; see
/// [`PhotocraftApp::save_settings`]).
pub image_previews: bool,
@@ -345,6 +363,7 @@ impl Default for ExportSettings {
tiff_layers: true,
xmp_all: true,
tga_bits: None,
dds: photocraft_io::DdsOptions::default(),
image_previews: true,
psd_maximize_compatibility: true,
embed_icc: true,
@@ -372,6 +391,7 @@ impl ExportSettings {
opts.tiff_layers = self.tiff_layers;
opts.xmp = if self.xmp_all { photocraft_io::XmpEmbed::All } else { photocraft_io::XmpEmbed::None };
opts.tga_bits = self.tga_bits;
opts.encode.dds = self.dds;
opts.image_previews = self.image_previews;
opts.psd_maximize_compatibility = self.psd_maximize_compatibility;
opts.document_path = self.document_path.clone();
@@ -890,6 +910,8 @@ pub struct PhotocraftApp {
pub(crate) targa_options: Option<targa_options_ui::Prompt>,
/// A save to a JPEG parked behind the JPEG Options prompt (see `jpeg_options_ui`).
pub(crate) jpeg_options: Option<jpeg_options_ui::Prompt>,
/// A save to a DDS texture parked behind the DDS Options prompt (see `dds_options_ui`).
pub(crate) dds_options: Option<dds_options_ui::Prompt>,
/// Files read but not opened yet: the first waits on its profile prompt, the rest behind it
/// (see `open_prompt`).
pub(crate) held_opens: std::collections::VecDeque<open_prompt::Held>,
@@ -1042,6 +1064,7 @@ impl PhotocraftApp {
disk_stamps: HashMap::new(),
targa_options: None,
jpeg_options: None,
dds_options: None,
held_opens: Default::default(),
allow_close: false,
stylus: Default::default(),
@@ -1493,20 +1516,20 @@ impl PhotocraftApp {
/// continues on a later frame, see `file_dialog`). Returns `{"path", "warnings"}` (the export
/// warnings are also shown to the user).
pub fn save_as(&mut self, path: Option<String>) -> Result<Value, String> {
self.save_as_with(path, SaveChoices::default())
self.save_as_with(path, FormatChoices::default())
}
/// [`Self::save_as`] with the format choices already made, which skip their prompts:
/// `file.save` and `file.saveAs` with `tgaBits` (no Targa Options), or `quality` or
/// `jpegFormat` (no JPEG Options).
pub fn save_as_with(&mut self, path: Option<String>, choices: SaveChoices) -> Result<Value, String> {
/// `file.save` and `file.saveAs` with `tgaBits` (no Targa Options), `ddsCompression` or
/// `ddsMipmaps` (no DDS Options), or `quality` or `jpegFormat` (no JPEG Options).
pub fn save_as_with(&mut self, path: Option<String>, choices: FormatChoices) -> Result<Value, String> {
self.save_dialog_flow(path, choices, false)
}
/// [`Self::save_as_with`]. `plain_save` marks File › Save that had to ask for a file name
/// (a flat file): confirming the document's own file is recorded as a `file.save` step,
/// which writes whichever file the action is played on, not as a Save As to a fixed path.
pub(crate) fn save_dialog_flow(&mut self, path: Option<String>, choices: SaveChoices, plain_save: bool) -> Result<Value, String> {
pub(crate) fn save_dialog_flow(&mut self, path: Option<String>, choices: FormatChoices, plain_save: bool) -> Result<Value, String> {
if self.save_options_open() {
return Err(SAVE_OPTIONS_OPEN.into());
}
@@ -1566,15 +1589,21 @@ impl PhotocraftApp {
}
/// [`Self::save_as_with`] once the path is known.
pub(crate) fn save_to(&mut self, path: String, choices: SaveChoices) -> Result<Value, String> {
// An RGB Targa asks for its bits per pixel, and a JPEG for its quality and format, unless
// the caller chose them; the save continues from the prompt (still a copy when it would
// be one without the prompt), which records the step once answered.
pub(crate) fn save_to(&mut self, path: String, choices: FormatChoices) -> Result<Value, String> {
// An RGB Targa asks for its bits per pixel, a DDS texture for its compression, and a JPEG
// for its quality and format, unless the caller chose them; the save continues from the
// prompt (still a copy when it would be one without the prompt), which records the step
// once answered.
if choices.tga_bits.is_none() && targa_options_ui::wants_prompt(self, &path) {
let copy = self.saves_copy(&path, None)?;
targa_options_ui::park(self, path.clone(), copy, "file.saveAs")?;
return Ok(serde_json::json!({"path": path, "warnings": []}));
}
if choices.dds.is_none() && dds_options_ui::wants_prompt(&path) {
let copy = self.saves_copy(&path, None)?;
dds_options_ui::park(self, path.clone(), copy, "file.saveAs")?;
return Ok(serde_json::json!({"path": path, "warnings": []}));
}
if choices.jpeg.is_none() && jpeg_options_ui::wants_prompt(self, &path) {
let copy = self.saves_copy(&path, None)?;
jpeg_options_ui::park(self, path.clone(), copy, "file.saveAs")?;
@@ -1589,29 +1618,25 @@ impl PhotocraftApp {
self.save_as_step(path, None, choices)
}
/// File › Save back to the document's own layered file, recorded as a `file.save` step.
pub(crate) fn save_in_place(&mut self, path: String) -> Result<Value, String> {
self.save_in_place_with(path, SaveChoices::default())
}
/// [`Self::save_in_place`] with JPEG Options already answered (a replayed or scripted Save),
/// recorded with them ([`jpeg_options_ui::plain_save_step`]).
pub(crate) fn save_in_place_with(&mut self, path: String, choices: SaveChoices) -> Result<Value, String> {
/// File › Save back to the document's own file, recorded as a `file.save` step with the
/// Targa, DDS or JPEG answer in `choices` (a replayed or scripted Save of a flat file brings
/// its own).
pub(crate) fn save_in_place(&mut self, path: String, choices: FormatChoices) -> Result<Value, String> {
if self.save_options_open() {
return Err(SAVE_OPTIONS_OPEN.into());
}
let r = self.write_save(path, None, choices)?;
// JPEG Options are recorded only where they change something
// ([`jpeg_options_ui::plain_save_step`]); the Targa and DDS answers as given.
let mut step = jpeg_options_ui::plain_save_step(choices.jpeg);
if let Some(bits) = choices.tga_bits {
step["tgaBits"] = bits.bits().into();
}
FormatChoices { jpeg: None, ..choices }.record(&mut step);
self.session.journal.push(("file.save".into(), step));
Ok(r)
}
/// Writes the Save As and journals it as a `file.saveAs` step with its path (and TIFF,
/// Targa or JPEG answer), so an action being recorded keeps it (#2032).
fn save_as_step(&mut self, path: String, tiff_layers: Option<bool>, choices: SaveChoices) -> Result<Value, String> {
/// Targa, DDS or JPEG answer), so an action being recorded keeps it (#2032).
fn save_as_step(&mut self, path: String, tiff_layers: Option<bool>, choices: FormatChoices) -> Result<Value, String> {
let r = self.write_save(path.clone(), tiff_layers, choices)?;
let mut step = serde_json::json!({"path": path});
if let Some(layers) = tiff_layers {
@@ -1622,30 +1647,36 @@ impl PhotocraftApp {
Ok(r)
}
/// Re-label the Save As just journaled for `path` as File › Save (see [`Self::save_dialog_flow`]).
/// A JPEG save still waiting on JPEG Options is recorded as File › Save once answered.
/// Re-label the Save As just journaled for `path` as File › Save (see [`Self::save_dialog_flow`]),
/// keeping its Targa, DDS or JPEG answer so playback writes the file the same way. A save
/// parked behind Targa, DDS or JPEG Options hasn't been journaled yet: it is recorded as
/// File › Save once answered.
pub(crate) fn journal_as_plain_save(&mut self, path: &str) {
if let Some(p) = self.jpeg_options.as_mut()
&& p.path == path
{
p.record = "file.save";
let parked = [
self.targa_options.as_mut().map(|p| (&p.path, &mut p.record)),
self.dds_options.as_mut().map(|p| (&p.path, &mut p.record)),
self.jpeg_options.as_mut().map(|p| (&p.path, &mut p.record)),
];
if let Some((_, record)) = parked.into_iter().flatten().find(|(p, record)| p.as_str() == path && **record == "file.saveAs") {
*record = "file.save";
return;
}
// Only the path goes: a script's JPEG or Targa answer stays, so a replay writes the file
// it is played on the same way. Any other answer (TIFF layers) keeps the Save As.
// Only the path goes: a script's format answer stays, so a replay writes the file it is
// played on the same way. Any other answer (TIFF layers) keeps the Save As.
if let Some(last) = self.session.journal.last_mut()
&& last.0 == "file.saveAs"
&& last.1.get("path").and_then(Value::as_str) == Some(path)
&& let Some(step) = last.1.as_object()
&& step.keys().all(|k| matches!(k.as_str(), "path" | "quality" | "jpegFormat" | "tgaBits"))
&& step.keys().all(|k| matches!(k.as_str(), "path" | "tgaBits" | "ddsCompression" | "ddsMipmaps" | "quality" | "jpegFormat"))
{
let step: serde_json::Map<String, Value> = step.iter().filter(|(k, _)| k.as_str() != "path").map(|(k, v)| (k.clone(), v.clone())).collect();
let mut step = step.clone();
step.remove("path");
*last = ("file.save".into(), Value::Object(step));
}
}
/// Replays a recorded `file.save` / `file.saveAs` / `file.saveACopy` step: no dialog or
/// prompt, and the recorded TIFF, Targa or JPEG answer (if any) stands in for its prompt.
/// prompt, and the recorded TIFF, Targa, DDS or JPEG answer (if any) stands in for its prompt.
pub(crate) fn replay_save(&mut self, id: &str, params: &Value) -> Result<Value, String> {
if self.save_options_open() {
return Err(SAVE_OPTIONS_OPEN.into());
@@ -1653,7 +1684,9 @@ impl PhotocraftApp {
if id == "file.save" {
// The file the action is played on: one that holds the whole document, or a flat
// file a recorded Save confirmed in the dialog while the document is still a plain
// raster, so it is never flattened over (#3531, #3806).
// raster, so it is never flattened over (#3531, #3806). A flat file's recorded Targa,
// DDS or JPEG answer writes the same way, without asking.
let choices = FormatChoices::from_step(params, id)?;
let st = self.session.active().ok_or("no document")?;
let flat_own = |p: &str| {
!photocraft_engine::file_cmds::saves_in_place(p)
@@ -1663,11 +1696,10 @@ impl PhotocraftApp {
let path = st.path.clone().filter(|p| photocraft_engine::file_cmds::holds_at(&st.doc, p) || flat_own(p)).ok_or(
"Save writes back only to the document's own PSD, PSB, .pcraft or OpenRaster file (OpenRaster only when it holds everything), or to its own flat file while the document is a single plain layer",
)?;
// A recorded JPEG Options answer writes the same way, without asking.
return self.save_in_place_with(path, SaveChoices::from_step(params, id)?);
return self.save_in_place(path, choices);
}
let path = params.get("path").and_then(Value::as_str).filter(|p| !p.is_empty()).ok_or("Save As needs a `path`")?;
let choices = SaveChoices::from_step(params, id)?;
let choices = FormatChoices::from_step(params, id)?;
if id == "file.saveACopy" {
// A copy: the document keeps its own file and unsaved state, as when recorded (#4373).
let doc = self.session.active().ok_or("no document")?.doc.clone();
@@ -1714,8 +1746,9 @@ impl PhotocraftApp {
/// Writes the active document to a known path. `tiff_layers` is the TIFF Options answer:
/// `Some(false)` discards the layers and saves a copy. `choices` are the Targa bits per pixel
/// (`None`: 32 with an alpha channel or transparency, else 24) and the JPEG Options.
fn write_save(&mut self, path: String, tiff_layers: Option<bool>, choices: SaveChoices) -> Result<Value, String> {
/// (`None`: 32 with an alpha channel or transparency, else 24), the DDS options and the JPEG
/// Options.
fn write_save(&mut self, path: String, tiff_layers: Option<bool>, choices: FormatChoices) -> Result<Value, String> {
let copy = self.saves_copy(&path, tiff_layers)?;
let settings = choices.apply(ExportSettings { tiff_layers: tiff_layers.unwrap_or(ExportSettings::default().tiff_layers), ..self.save_settings() });
match self.write_document(path.clone(), &settings, copy)? {
@@ -1823,12 +1856,12 @@ impl PhotocraftApp {
own == path && self.services.file_stamp.as_ref().and_then(|stamp| stamp(path)).is_some_and(|now| now != *then)
}
/// TIFF, Targa or JPEG Options is waiting on an answer before a save is written.
/// TIFF, Targa, DDS or JPEG Options is waiting on an answer before a save is written.
pub(crate) fn save_options_open(&self) -> bool {
self.tiff_options.is_some() || self.targa_options.is_some() || self.jpeg_options.is_some()
self.tiff_options.is_some() || self.targa_options.is_some() || self.dds_options.is_some() || self.jpeg_options.is_some()
}
/// Writes a save that waited on a format prompt (Targa or JPEG Options) once answered:
/// Writes a save that waited on a format prompt (Targa, DDS or JPEG Options) once answered:
/// journals `step` as its command (`file.saveAs` for Save As, `file.saveACopy` for Save a
/// Copy, `file.save` for a plain Save), so an action being recorded keeps it with this answer
/// (#2032, #4373), reports a failure in the status bar, and releases a close prompt waiting on
@@ -1863,12 +1896,13 @@ impl PhotocraftApp {
/// picker or ambient writer is reachable from this path. Returns the path
/// written and the export warnings (also shown to the user).
pub fn save_automation(&mut self, path: Option<String>) -> Result<Value, String> {
self.save_automation_with(path, SaveChoices::default())
self.save_automation_with(path, FormatChoices::default())
}
/// [`Self::save_automation`] with the Targa bits per pixel (`None`: 32 with an alpha channel
/// or transparency, else 24) and JPEG Options in `choices`. Automation saves never prompt.
pub fn save_automation_with(&mut self, path: Option<String>, choices: SaveChoices) -> Result<Value, String> {
/// [`Self::save_automation`] writing with the format `choices` given (a Targa's `None`: 32
/// with an alpha channel or transparency, else 24; a DDS texture's: uncompressed without
/// mipmaps; a JPEG's: the file's own quality, Baseline). Automation saves never prompt.
pub fn save_automation_with(&mut self, path: Option<String>, choices: FormatChoices) -> Result<Value, String> {
let target = {
let state = self.session.active().ok_or("no document")?;
// As File › Save: without `path` only a layered file is written back (#416).
@@ -2192,6 +2226,7 @@ impl eframe::App for PhotocraftApp {
tiff_options_ui::show(self, &ctx);
targa_options_ui::show(self, &ctx);
jpeg_options_ui::show(self, &ctx);
dds_options_ui::show(self, &ctx);
distort_ui::show(self, &ctx);
camera_raw_ui::show(self, &ctx);
wide_angle_ui::show(self, &ctx);
+4 -4
View File
@@ -231,7 +231,7 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id:
"file.save" => {
// Writes back only to a layered file that holds the whole document; anything else
// goes through Save As, so the original is never overwritten with less (#3806).
let choices = crate::SaveChoices::from_params(&params, id)?;
let choices = crate::FormatChoices::from_params(&params, id)?;
match params.get("path").and_then(Value::as_str) {
Some(path) => app.save_as_with(Some(path.to_string()), choices),
None => {
@@ -240,8 +240,8 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id:
Some((true, path, d)) => {
// Another app changed the file since it was opened or saved: ask first (#6596).
let doc = d.doc.id;
crate::disk_change_prompt::intercept(app, doc, &path, false, crate::SaveChoices::default())
.unwrap_or_else(|| app.save_in_place(path))
crate::disk_change_prompt::intercept(app, doc, &path, false, crate::FormatChoices::default())
.unwrap_or_else(|| app.save_in_place(path, crate::FormatChoices::default()))
}
Some((false, path, d)) if photocraft_engine::file_cmds::saves_in_place(&path) => {
// Its own file is layered but can't hold what the document has now.
@@ -289,7 +289,7 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id:
}
}
"file.saveAs" => {
let choices = crate::SaveChoices::from_params(&params, id)?;
let choices = crate::FormatChoices::from_params(&params, id)?;
app.save_as_with(params.get("path").and_then(Value::as_str).map(str::to_string), choices)
}
"view.zoomIn" | "view.zoomOut" | "view.fitOnScreen" | "view.actualPixels" => app.run(id, params),
+8 -4
View File
@@ -21,8 +21,9 @@ pub struct Prompt {
/// Save a Copy: the document keeps its path and unsaved changes.
pub copy: bool,
/// The step the answered save is recorded as, so an action being recorded keeps it:
/// `file.saveAs` for Save As (#2032, even when a flat Targa is written as a copy) and
/// `file.saveACopy` for Save a Copy (#4373).
/// `file.saveAs` for Save As (#2032, even when a flat Targa is written as a copy),
/// `file.saveACopy` for Save a Copy (#4373), and `file.save` when File › Save confirmed the
/// document's own Targa (#3531), which plays back on the file the action is played on.
pub record: &'static str,
}
@@ -76,10 +77,13 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
});
cancel |= modal.should_close();
p.bits = bits;
if ok || ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter)) {
// A focused button takes Enter as its click without consuming the key: Enter on Cancel cancels.
if ok || (!cancel && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter))) {
let Some(p) = app.targa_options.take() else { return };
let settings = ExportSettings { tga_bits: Some(p.bits), ..app.save_settings() };
let step = serde_json::json!({"path": p.path, "tgaBits": p.bits.bits()});
// A plain Save writes whichever file the action is played on (#3531): no path.
let mut step = if p.record == "file.save" { serde_json::json!({}) } else { serde_json::json!({"path": p.path}) };
step["tgaBits"] = p.bits.bits().into();
app.write_answered_save(ctx, p.doc, p.path, &settings, p.copy, (p.record, step));
} else if cancel {
app.targa_options = None;
+2 -1
View File
@@ -65,7 +65,8 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
cancel |= modal.should_close();
p.discard_layers = discard;
p.dont_ask_again = dont_ask;
if ok || ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter)) {
// A focused button takes Enter as its click without consuming the key: Enter on Cancel cancels.
if ok || (!cancel && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter))) {
let Some(p) = app.tiff_options.take() else { return };
if p.dont_ask_again {
app.session.edit_prefs(|prefs| prefs.file_handling.ask_before_saving_layered_tiff = false);
+10 -5
View File
@@ -1442,13 +1442,17 @@ fn save_a_copy(app: &mut PhotocraftApp) -> Result<Value, String> {
let suggested = format!("{stem} copy.psd");
// The copy is of the document as it was when asked.
app.pick_save(&suggested, move |app, path| {
// An RGB Targa asks for its bits per pixel first, and a JPEG for its JPEG Options; the
// copy is written from the prompt.
// An RGB Targa asks for its bits per pixel first, a DDS texture for its compression, and
// a JPEG for its JPEG Options; the copy is written from the prompt.
let parked = app.with_document(doc.id, |app| {
if crate::targa_options_ui::wants_prompt(app, &path) {
crate::targa_options_ui::park(app, path.clone(), true, "file.saveACopy")?;
return Ok(true);
}
if crate::dds_options_ui::wants_prompt(&path) {
crate::dds_options_ui::park(app, path.clone(), true, "file.saveACopy")?;
return Ok(true);
}
if crate::jpeg_options_ui::wants_prompt(app, &path) {
crate::jpeg_options_ui::park(app, path.clone(), true, "file.saveACopy")?;
return Ok(true);
@@ -1458,15 +1462,16 @@ fn save_a_copy(app: &mut PhotocraftApp) -> Result<Value, String> {
if parked {
return Ok(json!({"path": path, "warnings": []}));
}
let r = write_copy(app, &doc, &path, crate::SaveChoices::default())?;
let r = write_copy(app, &doc, &path, crate::FormatChoices::default())?;
app.session.journal.push(("file.saveACopy".into(), json!({"path": path})));
Ok(r)
})
}
/// Writes `doc` to `path` as a copy (Save a Copy, and its replayed action step): the document's
/// path and saved state are untouched. `choices` are the Targa and JPEG answers, when there are any.
pub(crate) fn write_copy(app: &mut PhotocraftApp, doc: &photocraft_doc::Document, path: &str, choices: crate::SaveChoices) -> Result<Value, String> {
/// path and saved state are untouched. `choices` are the Targa, DDS and JPEG answers, when there
/// are any.
pub(crate) fn write_copy(app: &mut PhotocraftApp, doc: &photocraft_doc::Document, path: &str, choices: crate::FormatChoices) -> Result<Value, String> {
let settings = choices.apply(app.save_settings());
let export = app.services.export.as_ref().ok_or("no exporter configured")?;
let (bytes, warnings) = export(doc, path, &settings)?;
+3 -3
View File
@@ -88,7 +88,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i
base64 PNG data; a path is relative to the automation write root. Raises the window first
(default) because occluded macOS windows stop rendering
- `ui.focus`: bring the main window to the front
- `app.open {path}` / `app.save {path?, tgaBits?, quality?, jpegFormat?}` (JPEG: `quality` on the 0–12 scale, `jpegFormat` `baseline`, `optimized` or `progressive`; automation never shows JPEG Options): relative file I/O through the configured automation roots (`app.open` reads under the read root, `app.save` writes under the write root; absolute paths, `..` and paths escaping the root are refused, and both fail closed when no root was granted). Both reply with `warnings` (import/export notes such as "adjustment layer flattened"; `[]` when none), also shown to the user in the status bar and as a notice (`notices` in `ui.inspect`); `app.open` also returns the `path` and document `name` (directory `.pcraft` — the native on-disk form — loads through the read root's capability; the single-file (zip) form takes the bytes path), `app.save` the `path` written. `app.save` without `path` writes back only to the document's own PSD, PSB or `.pcraft` file, like File › Save. `tgaBits` (24 or 32) sets a TGA's bits per pixel: 32 writes the alpha channel (or else the transparency) as its alpha, 24 none; without it, 32 with an alpha channel or transparency, else 24. Automation saves never show TGA or TIFF options. Automation opens and saves never fire script events. Use these two rather than `file.open`, `file.save`, `file.saveAs` or `file.saveACopy`, which the control channel refuses (see [Engine commands](#engine-commands))
- `app.open {path}` / `app.save {path?, tgaBits?, ddsCompression?, ddsMipmaps?, quality?, jpegFormat?}` (JPEG: `quality` on the 0–12 scale, `jpegFormat` `baseline`, `optimized` or `progressive`; automation never shows JPEG Options): relative file I/O through the configured automation roots (`app.open` reads under the read root, `app.save` writes under the write root; absolute paths, `..` and paths escaping the root are refused, and both fail closed when no root was granted). Both reply with `warnings` (import/export notes such as "adjustment layer flattened"; `[]` when none), also shown to the user in the status bar and as a notice (`notices` in `ui.inspect`); `app.open` also returns the `path` and document `name` (directory `.pcraft` — the native on-disk form — loads through the read root's capability; the single-file (zip) form takes the bytes path), `app.save` the `path` written. `app.save` without `path` writes back only to the document's own PSD, PSB or `.pcraft` file, like File › Save. `tgaBits` (24 or 32) sets a TGA's bits per pixel: 32 writes the alpha channel (or else the transparency) as its alpha, 24 none; without it, 32 with an alpha channel or transparency, else 24. `ddsCompression` (`none`, `bc1`, `bc2` or `bc3`) and `ddsMipmaps` (bool) set a DDS texture's compression and mip chain; without them, uncompressed without mipmaps. Automation saves never show TGA, DDS, JPEG or TIFF options. Automation opens and saves never fire script events. Use these two rather than `file.open`, `file.save`, `file.saveAs` or `file.saveACopy`, which the control channel refuses (see [Engine commands](#engine-commands))
- `app.quit`
## Engine commands
@@ -321,7 +321,7 @@ How each MCP tool maps onto control methods in bridge mode:
| `doc_export_layers {dir, …}` | `engine.execute {command: "file.export.layersToFiles", params}` |
| `doc_export_data_sets {dir, …}` | `engine.execute {command: "file.export.dataSetsAsFiles", params}` |
| `doc_import_data_sets {path, …}` | `engine.execute {command: "file.import.variableDataSets", params}` |
| `doc_save {path?, tgaBits?}` / `doc_export {path, tgaBits?}` | `app.save {path?, tgaBits?}` |
| `doc_save {path?, tgaBits?, ddsCompression?, ddsMipmaps?}` / `doc_export {path, tgaBits?, ddsCompression?, ddsMipmaps?}` | `app.save {path?, tgaBits?, ddsCompression?, ddsMipmaps?}` |
| `doc_render_preview {max_side?}` | `ui.screenshot`, returned directly as PNG image content |
| `session_list`, `ui_inspect` | `ui.inspect` |
| `ui_screenshot {max_side?}` | `ui.screenshot`, returned as PNG image content |
@@ -378,7 +378,7 @@ no MCP framing, no app start-up per command. Configure its file access with the
| `doc.exportLayers` | `{dir, format? (png), prefix?, visibleOnly? (true), quality?, index?}` → `{files, count}`: one file per exported layer, named like `file.export.layersToFiles` names them. `dir` resolves beneath the write root, so the ambient command stays refused. The bytes come from the command's own core, so both paths write the same files; the write capability refuses to overwrite an existing file |
| `doc.exportDataSets` | `{dir, format? (png), dataSets? (all), naming? ("{name}\|{index}\|{document}"), index?}` → `{files, count}`: the capability-scoped `file.export.dataSetsAsFiles`. The batch is planned first and colliding names are refused before any file is written, as in the command |
| `doc.importDataSets` | `{path, delimiter? (,), index?}` → `{imported}`: the capability-scoped `file.import.variableDataSets`; the CSV is read beneath the read root |
| `doc.save` | `{path?, format?, quality?, index?, tiffLayers?, tgaBits?}` (`.pcraft` native, else export by extension; set `tiffLayers: true` to preserve TIFF layers rather than exporting a flattened TIFF; `tgaBits` 24 or 32 sets a TGA's bits per pixel, as `app.save` does). Without `path` only a PSD, PSB or `.pcraft` document is written back to its own file, in its own format; anything else is an error and the file is left unchanged. A successful layered save (PSD, PSB or `.pcraft`) records the current revision as saved and makes the file the document's path, so `session.list` reports `dirty: false`; a flat export is a copy and leaves the document dirty |
| `doc.save` | `{path?, format?, quality?, index?, tiffLayers?, tgaBits?, ddsCompression?, ddsMipmaps?}` (`.pcraft` native, else export by extension; set `tiffLayers: true` to preserve TIFF layers rather than exporting a flattened TIFF; `tgaBits` 24 or 32 sets a TGA's bits per pixel, and `ddsCompression`/`ddsMipmaps` a DDS texture's compression and mip chain, as `app.save` does). Without `path` only a PSD, PSB or `.pcraft` document is written back to its own file, in its own format; anything else is an error and the file is left unchanged. A successful layered save (PSD, PSB or `.pcraft`) records the current revision as saved and makes the file the document's path, so `session.list` reports `dirty: false`; a flat export is a copy and leaves the document dirty |
| `doc.inspect` | `{index?}`: same JSON as `document.inspect` |
| `doc.render` | `{index?, maxSide? (1024; 0 = full), path?}`: PNG to `path`, else `{mime, base64}` |
| `doc.select` / `doc.close` | `{index}` / `{index?}` |
+4 -2
View File
@@ -1,6 +1,6 @@
# File-format parity
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-19 · **Change:** minor (JPEG Options on Save As and Save a Copy, FILE-215-19; PNG `sTER` stereo pairing kept on save, #6063; earlier: PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-19 · **Change:** minor (JPEG Options on Save As and Save a Copy, FILE-215-19; earlier: DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PNG `sTER` stereo pairing kept on save, #6063; earlier: PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026
Every format Photoshop 2026 (27.11.0) reads or writes, with PhotoCraft's support, fidelity and
tests. Photoshop's list comes from its `Info.plist` `CFBundleDocumentTypes`, the PiPL read/write
@@ -90,7 +90,7 @@ D5100 colour profile. Counts and the G14 estimate are unchanged.
| Paint.NET `.pdn` | yes | no | [pdn.md](pdn.md) |
| OpenRaster `.ora` | yes | yes | [ora.md](ora.md) |
| ICO, QOI | yes | yes | |
| DirectDraw Surface `.dds` | yes | no | Top mip level of a 2D texture: uncompressed RGB, RGBA or luminance, BC1–BC3 (DXT1/3/5), legacy or DX10 header. Cubemaps, arrays, volumes and BC4–BC7 are refused (#1327). Photoshop needs a plug-in |
| DirectDraw Surface `.dds` | yes | yes | Opens the top mip level of a 2D texture: uncompressed RGB, RGBA or luminance, BC1–BC3 (DXT1/3/5), legacy or DX10 header. Saves a 2D texture uncompressed (BGRA, BGRX, luminance with or without alpha) or BC1–BC3 with the legacy header, optionally with its full mip chain (box filter); Save As and Save a Copy ask (DDS Options), scripts pass `ddsCompression` and `ddsMipmaps` (`--dds-compression`, `--dds-mipmaps` on the CLI). Cubemaps, arrays, volumes and BC4–BC7 are refused (#1327). Photoshop needs a plug-in |
| Krita `.kra`, GIMP `.xcf`, Procreate | no | no | Not in Photoshop either |
### Affinity import notes (moved from `roadmap.md`)
@@ -143,7 +143,9 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings.
| Date | Change | Summary |
|---|---|---|
| 2026-10-19 | minor | JPEG Options on Save As and Save a Copy (FILE-215-19) |
| 2026-10-15 | minor | DDS textures save (#1327): uncompressed or BC1/BC2/BC3 (DXT1/3/5) from a clean-room block encoder, optional box-filtered mip chain, legacy header; DDS Options in Save As and Save a Copy, `ddsCompression`/`ddsMipmaps` for scripts, the CLI and MCP |
| 2026-10-15 | minor | PNG: a stereo PNG's `sTER` pairing (PNG Extensions 1.4.0 §3.6) is read on open and written back on save, so the file still says it is a stereo pair (#6063) |
| 2026-10-15 | minor | PSD guides: image resource 1092 (guides v2) gives guides their own colours on import; 1092 and 1097 (guide GUIDs) are written back only while they still describe the document's guides, so an edit no longer leaves Photoshop a stale second guide list (#5966) |
| 2026-10-14 | minor | PSD smart filter masks: Photoshop stores a smart object's filtered pixels without its filter mask and mixes the unfiltered ones (`FEid`) back in; import now mixes them and export writes them that way, so sf-filter-mask-hard, -gradient and sf-stack-blend-and-mask pass; Photoshop-oracle floor 185 → 188 of 258 |
| 2026-10-13 | minor | Lossy DNG (compression 34892) decodes: 8-bit baseline JPEG tiles and strips, CFA and LinearRaw, partial edge tiles; the stored values take the normal levels and LinearizationTable (FILE-215-11, lossy DNG part) |
| 2026-10-14 | minor | Corpus floors raised to the measured results of the CMYK, Lab, 32-bit, Levels, Threshold, Posterize, feather and pass-through fixes landed together: Photoshop-authored 165 → 185 of 258, io 146 → 148 of 170, psd-tools 242 → 249 of 309 |
+46
View File
@@ -0,0 +1,46 @@
# PSD pixel buffer allocation
Write PSD interleaved pixels directly into the final buffer. Direct RLE output is already on main. The unrelated export-side sample-encoder inlining hint is removed locally.
Whole-photo imports improve by about 4–7%, and the authored designs by about 20–26%. An added export-side inlining hint accompanied a separately confirmed 2.3% float PSD export slowdown (2440.168 → 2497.423 ms). Removing that one line leaves export source equal to main; follow-up float export is +0.4% and 16-bit export +0.9%, with both paired intervals crossing zero. These results supersede the much larger previous interleave-only percentages.
Measured on 2026-10-19 against main `d95fdf3181c01ad41e95456091a394b105a9afb2`, independently of the other splits. Apple M4 Max, 16 CPUs, 128 GiB, macOS arm64, rustc 1.99.1; release O3, LTO off, 16 codegen units, debug off. Builds used nice 10 and warm APFS-cloned caches; measurements used default priority without concurrent builds or profiling. Five alternating paired samples follow a warmup; confirmation and fix runs use nine pairs. Time change compares the two medians; intervals resample paired ratios. Negative time change means faster. Small differences are not automatically established improvements or regressions.
The full-resolution CC0 Pixls photographs are P1000475.RW2 (24 MP foliage centre crop, no resampling) and IMG_4059.CR2 (12 MP city), each converted to U8/U16/F32 with the ProPhoto RGB profile. F32 derives from 16-bit development, not HDR capture. MIT psd-tools masks.psd supplies a 640 x 1394 web layout with 22 imported layers and three masks; mask-index.psd supplies a 1000 x 867 illustrated card with a mask. The sources and hashes are pinned by the existing corpus. These are the available authored layered files, not a large layered-photo corpus. No generated noise, repeating ramps, enlarged charts or duplicated photo layers are used.
The shared harness times in-memory operations. Input loading, image conversion, thumbnail rendering, hashes and equivalence checks are outside timers. Every save uses a fresh PcraftWriter and a real rendered thumbnail. Common reads use main-encoded files; own-output reads are separate. PSD exports use ZIP merged images at U8/U16, Raw merged images at F32, and normal RLE layer channels at U8/U16 / ZIP prediction at F32. Results do not measure filesystem throughput or browser performance.
| Document | Operation | Main ms | Branch ms | Time change | Paired 95% interval | Main / branch bytes |
|---|---|---:|---:|---:|---:|---:|
| panasonic-32 | psd-export (fixed) | 2471.456 | 2481.877 | +0.4% | -0.1..+1.7% | 461,042,978 / 461,042,978 |
| panasonic-16 | psd-export (fixed) | 2649.246 | 2673.071 | +0.9% | -5.2..+1.6% | 274,409,683 / 274,409,683 |
| panasonic-8 | psd-import | 275.828 | 262.237 | -4.9% | -5.9..-4.1% | 98,823,859 / 98,823,859 |
| panasonic-8 | psd-export | 2937.854 | 2915.929 | -0.7% | -11.5..+5.6% | 98,823,859 / 98,823,859 |
| panasonic-16 | psd-import | 365.901 | 349.827 | -4.4% | -4.4..-2.6% | 274,409,683 / 274,409,683 |
| panasonic-32 | psd-import | 360.706 | 334.838 | -7.2% | -12.6..-4.4% | 461,042,978 / 461,042,978 |
| canon-8 | psd-import | 141.331 | 135.878 | -3.9% | -5.1..-3.6% | 56,209,597 / 56,209,597 |
| canon-8 | psd-export | 1052.421 | 1054.472 | +0.2% | -0.6..+1.3% | 56,209,597 / 56,209,597 |
| canon-16 | psd-import | 177.327 | 169.952 | -4.2% | -5.5..-1.7% | 140,673,004 / 140,673,004 |
| canon-16 | psd-export | 1248.449 | 1239.159 | -0.7% | -1.6..+0.6% | 140,673,004 / 140,673,004 |
| canon-32 | psd-import | 175.721 | 163.271 | -7.1% | -9.3..-4.9% | 228,234,238 / 228,234,238 |
| canon-32 | psd-export | 1008.772 | 1005.356 | -0.3% | -1.8..+0.7% | 228,234,238 / 228,234,238 |
| layout-masks | psd-import | 12.509 | 9.981 | -20.2% | -20.8..-14.5% | 493,006 / 493,006 |
| layout-masks | psd-export | 55.113 | 54.714 | -0.7% | -2.3..+1.2% | 493,006 / 493,006 |
| layout-mask-index | psd-import | 3.299 | 2.443 | -25.9% | -30.8..-23.4% | 411,435 / 411,435 |
| layout-mask-index | psd-export | 18.200 | 18.397 | +1.1% | -0.1..+1.7% | 411,435 / 411,435 |
Real PSD exports remain byte-identical and imported layer/mask float samples match main. The committed literal previous-interleave oracle covers depths, bands, fills, truncation and inversion.
Current local checks: affected `pixels::` unit tests (five passed, one ignored manual benchmark); strict all-target IO Clippy; affected IO wasm32 check. All passed. Follow-up exports at float and 16-bit depth remain byte-identical to main; no synthetic timing test was run.
Local raw evidence and the shared harness: `.local/perf-real-261019/` in the repository checkout (ignored). This comparison supersedes the earlier synthetic performance table. Small synthetic unit fixtures remain correctness checks; ignored synthetic timing tests were not run.
## Earlier split validation
These checks passed when the split was prepared; they were not all rerun for this benchmark review. Current affected checks are listed above where production code changed.
- `cargo test -p photocraft-io --locked`: passed (617 passed, 6 ignored).
- `cargo clippy -p photocraft-io --locked --all-targets -- -D warnings`: passed.
- `cargo xtask layers`: passed.
- `cargo check -p photocraft-io --locked --target wasm32-unknown-unknown`: passed.
- `cargo xtask test-corpus -p io`: passed (646 passed, 6 ignored).
+45
View File
@@ -0,0 +1,45 @@
# Sparse raster reads
Skip absent zero-default tiles and avoid temporary default-pixel allocations and copies. ZIP CRC acceleration is already on main.
A roughly 4% median dense 16-bit city-photo read slowdown repeated, with paired intervals crossing zero. The local fix shares the existing contiguous row loop between allocating and reusable-buffer reads, removes duplicated code, and preserves zero-filled allocation for allocating reads. Final measurements include all photo depths and authored designs; all outputs match bit for bit.
Measured on 2026-10-19 against main `d95fdf3181c01ad41e95456091a394b105a9afb2`, independently of the other splits. Apple M4 Max, 16 CPUs, 128 GiB, macOS arm64, rustc 1.99.1; release O3, LTO off, 16 codegen units, debug off. Builds used nice 10 and warm APFS-cloned caches; measurements used default priority without concurrent builds or profiling. Five alternating paired samples follow a warmup; confirmation and fix runs use nine pairs. Time change compares the two medians; intervals resample paired ratios. Negative time change means faster. Small differences are not automatically established improvements or regressions.
The full-resolution CC0 Pixls photographs are P1000475.RW2 (24 MP foliage centre crop, no resampling) and IMG_4059.CR2 (12 MP city), each converted to U8/U16/F32 with the ProPhoto RGB profile. F32 derives from 16-bit development, not HDR capture. MIT psd-tools masks.psd supplies a 640 x 1394 web layout with 22 imported layers and three masks; mask-index.psd supplies a 1000 x 867 illustrated card with a mask. The sources and hashes are pinned by the existing corpus. These are the available authored layered files, not a large layered-photo corpus. No generated noise, repeating ramps, enlarged charts or duplicated photo layers are used.
The shared harness times in-memory operations. Input loading, image conversion, thumbnail rendering, hashes and equivalence checks are outside timers. Every save uses a fresh PcraftWriter and a real rendered thumbnail. Common reads use main-encoded files; own-output reads are separate. PSD exports use ZIP merged images at U8/U16, Raw merged images at F32, and normal RLE layer channels at U8/U16 / ZIP prediction at F32. Results do not measure filesystem throughput or browser performance.
| Document | Operation | Main ms | Branch ms | Time change | Paired 95% interval | Main / branch bytes |
|---|---|---:|---:|---:|---:|---:|
| panasonic-16 | read (fixed) | 54.689 | 41.140 | -24.8% | -28.0..-23.8% | — |
| panasonic-16 | render (fixed) | 38.492 | 38.345 | -0.4% | -1.8..+2.0% | — |
| canon-16 | read (fixed) | 27.634 | 19.236 | -30.4% | -34.0..-23.3% | — |
| panasonic-8 | read (fixed) | 50.429 | 20.210 | -59.9% | -62.2..-56.7% | — |
| panasonic-8 | render (fixed) | 30.953 | 30.670 | -0.9% | -3.4..+1.5% | — |
| panasonic-32 | read (fixed) | 91.120 | 69.922 | -23.3% | -23.6..-20.7% | — |
| panasonic-32 | render (fixed) | 39.436 | 39.867 | +1.1% | -4.5..+3.7% | — |
| canon-8 | read (fixed) | 24.899 | 10.543 | -57.7% | -58.5..-54.9% | — |
| canon-8 | render (fixed) | 16.925 | 16.901 | -0.1% | -5.6..+5.5% | — |
| canon-16 | render (fixed) | 21.569 | 21.716 | +0.7% | -2.2..+2.4% | — |
| canon-32 | read (fixed) | 45.494 | 35.200 | -22.6% | -23.5..-21.5% | — |
| canon-32 | render (fixed) | 22.056 | 22.180 | +0.6% | -1.8..+6.9% | — |
| layout-masks | read (fixed) | 35.240 | 3.438 | -90.2% | -90.7..-89.9% | — |
| layout-masks | render (fixed) | 8.167 | 7.461 | -8.6% | -10.6..-8.3% | — |
| layout-mask-index | read (fixed) | 2.314 | 1.051 | -54.6% | -61.7..-50.0% | — |
| layout-mask-index | render (fixed) | 1.926 | 1.827 | -5.1% | -13.0..+0.6% | — |
All real read-region and render float bits match main. The committed previous-read-region oracle also covers sparse boundaries, negative coordinates, signed zero, defaults and eight pixel formats.
Current local checks: `cargo test -p photocraft-raster --locked --lib` (27 passed, one ignored manual benchmark); strict all-target Clippy; affected wasm32 check. All passed. The existing previous-read-path oracle covers the shared loop.
Local raw evidence and the shared harness: `.local/perf-real-261019/` in the repository checkout (ignored). This comparison supersedes the earlier synthetic performance table. Small synthetic unit fixtures remain correctness checks; ignored synthetic timing tests were not run.
## Earlier split validation
These checks passed when the split was prepared; they were not all rerun for this benchmark review. Current affected checks are listed above where production code changed.
- `cargo test -p photocraft-raster --locked`: passed (27 passed, 1 ignored).
- `cargo clippy -p photocraft-raster --locked --all-targets -- -D warnings`: passed.
- `cargo xtask layers`: passed.
- `cargo check -p photocraft-raster --locked --target wasm32-unknown-unknown`: passed.
+63
View File
@@ -0,0 +1,63 @@
# Text rasterization: what a type render costs
Measured 2026-10-17 on Windows 11, `i5-10300H` (4 cores / 8 threads, 16 GB), release build,
`rustc 1.98.1`, with a probe that renders one paragraph layer through
`photocraft_text::render::rasterize_warped` and times each stage of it.
Document: 300 dpi, a 2400 × 1600 px paragraph box holding 4000 characters at 14 pt — the worst
case a type edit has (a whole page of text at print resolution; 1851 laid-out glyphs, 2329 × 1610 px
of ink = 3.75 MP).
| Stage | ms | What it does |
|---|---:|---|
| ink rectangle (`ink_rect_warped`) | 4.1 | its own pass over every glyph, to size the surface |
| draw (`draw_pass` into `Coverage`) | 18.6–22.9 | glyph outlines, curve flattening (0.03 px tolerance), winding scanlines |
| prefix sum (`Coverage::finish`) | 7.0–8.1 | resolves the winding accumulator to coverage 0…1, one running sum over 3.75 M pixels |
| composite | 22.6–33.4 | each colour's coverage over the premultiplied accumulator |
| unpremultiply | 11.2–14.0 | divides the accumulator back to straight colour |
| `Surface::write_region` | 23.8 | converts to the document format and fills the tiles |
| `Surface::prune` | 3.1 | drops tiles that came back the layer's default |
| **total** | **86–104** | |
So the glyph geometry — what one would expect to dominate — is under a quarter of it. The rest is
scalar work over the pixel buffers, and every one of those passes is per pixel: a pixel's maths
reads and writes only its own slots.
## What runs in parallel now
`composite`, `unpremultiply` and the tile conversion run on all cores (rayon, native only; the web
build keeps the serial loops, since wasm has no threads). The rasterizer writes its tiles with
`Surface::write_region_par`; `Surface::write_region` stays serial, because mask builds call it inside
a `OnceLock` initializer, which must not enter rayon. Nothing about them
is order-dependent, so each pixel still sees the same operations on the same inputs — the pixels
are identical, not merely close.
| | before | after |
|---|---:|---:|
| the 3.75 MP case above | 98.3–103.9 ms | 60.3–61.8 ms |
`crates/text/src/tests.rs::parallel_raster_matches_a_single_thread` renders the shapes the pipeline
can take (plain, faux bold italic, no antialias, wide gamut, a wrapping paragraph box; 8/16/32-bit)
on a one-thread pool and on the default pool and compares the surfaces; `crates/raster`'s existing
`region_writes_match_scalar_bytes_for_every_format` compares `write_region` and `write_region_par`
against a scalar reference implementation, tile for tile.
## What stays on one thread, and why
- **draw** (19–23 ms): the winding accumulator carries across scanlines and rows — a pixel's value
depends on the rows above it, and one scanline can spill into the next row's first pixel. A band
split can be exact (bands keep absolute coordinates, carry the spill in band order, and are
assembled before the prefix sum), but each band re-traverses every glyph — the traversal is not
free, so the win is bounded by the band count. Worth doing when the draw matters more than it does
now.
- **prefix sum** (7–8 ms): a running f32 sum over the whole image. Splitting it per row needs the
row's starting sum, which is that same sequential chain — the only exactly-known starting sums are
the ones the chain computes.
- **ink rectangle** (4.1 ms): a second full traversal into a bounds sink. Per-glyph bounds reduce by
min/max, which is associative and exact, so this is a candidate for the same treatment as the
stages above (a glyph-range split, not a band split).
## Related
Byte-identical output matters here beyond taste: text pixels reach a PSD's saved cache and the
round-trip corpora. `cargo xtask test-corpus` and the Photoshop oracle tests are the gate.
+5
View File
@@ -168,6 +168,9 @@ pub fn parse_budgets(text: &str) -> Result<Budgets, String> {
if !(b.settings.regression_pct.is_finite() && b.settings.regression_pct > 0.0) {
return Err(format!("{BUDGETS}: settings.regression_pct must be > 0"));
}
if !(b.settings.regression_min_ms.is_finite() && b.settings.regression_min_ms >= 0.0) {
return Err(format!("{BUDGETS}: settings.regression_min_ms must be finite and >= 0"));
}
Ok(b)
}
@@ -980,6 +983,8 @@ not_measurable = "needs a frame harness"
.is_err()
);
assert!(parse_budgets("[settings]\nregression_pct = 0\n").is_err());
assert!(parse_budgets("[settings]\nregression_min_ms = -1\n").is_err());
assert!(parse_budgets("[settings]\nregression_min_ms = 0\n").is_ok());
assert!(parse_budgets("bogus = 1\n").is_err());
assert!(parse_budgets("not toml [").is_err());
}