mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-14 22:24:37 +00:00
Merge origin/main into docs/scorecard-stale-rows-2
Conflicts in docs/gaps.md, docs/target-app-parity.md and docs/ui-parity.md: kept main's status lines and rows and this branch's Type, selection-modifier and Alt-wheel evidence. docs/scorecard.md regenerated. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
402d005e05
417 files changed
+34766
-2086
No files matched your search
@@ -17,6 +17,10 @@ on:
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUST_BACKTRACE: 1
|
||||
# No test or fuzz run may print a page: the engine refuses to spool, and if something ever
|
||||
# bypasses that, lp fails loudly on a printer that does not exist.
|
||||
PHOTOCRAFT_NO_PRINT: "1"
|
||||
LPDEST: photocraft-no-such-printer
|
||||
# Debug and test builds keep line tables (file:line in panic backtraces) but drop the rest of the
|
||||
# debug info: faster codegen and links (Windows PDBs most of all), and a target dir small enough
|
||||
# for the Linux job's second, craft-fonts build (it ran out of disk with full debug info).
|
||||
|
||||
+5
-1
@@ -10,6 +10,7 @@ next to them.
|
||||
|
||||
| Path | Title | Author | Source | License |
|
||||
|---|---|---|---|---|
|
||||
| `crates/codecs/src/codecs/xpm/x11_colors.rs` | X11 `rgb.txt` color-name lookup table (normalized X.Org RGB values) | X.Org Foundation and X11 contributors | <https://cgit.freedesktop.org/xorg/app/rgb/tree/rgb.txt> | MIT (X.Org app/rgb) |
|
||||
| `crates/ui-egui/src/i18n/el.tsv` | Greek UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/de.tsv` | German UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/it.tsv` | Italian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
@@ -22,9 +23,10 @@ next to them.
|
||||
| `crates/ui-egui/src/i18n/hu.tsv` | Hungarian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/tr.tsv` | Turkish UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/sr.tsv` | Serbian (Latin, Ekavian) UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | <https://github.com/rsms/inter> | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) |
|
||||
| `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | <https://github.com/JetBrains/JetBrainsMono> | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) |
|
||||
| `assets/icons/*.svg` except `slice-knife.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/*.svg` except `slice-knife.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/{check,chevron-down,chevron-left,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,link-2,lock,minus,monitor,moon,move,navigation,plus,search,square,trash,triangle,type,x,zoom-in}.svg` | Lucide icons derived from Feather (subset of the row above) | Cole Bemis (Feather), Lucide Contributors | <https://github.com/feathericons/feather> via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/slice-knife.svg` | Slice tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/sampler.svg` | Color Sampler tool glyph: a target reticle marking a sampled pixel | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
@@ -36,6 +38,7 @@ next to them.
|
||||
| `assets/icons/clip-below.svg`, `assets/icons/clip-release.svg` | Clipping-mask cursors (⌥ over the line between two layers): a hooked arrow onto the layer below; the same with a small "no" badge to release | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/layer-mask.svg`, `assets/icons/adjustment-layer.svg` | Layers panel footer glyphs: Add a mask (a frame around a solid disc) and New Fill or Adjustment Layer (a circle, one half solid), drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/frame-placeholder.svg` | Frame tool glyph: an empty placeholder frame, crossed corner to corner | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/gradient.svg` | Gradient tool glyph: a rounded rectangle filled with a left-to-right linear ramp, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/eraser-background.svg`, `assets/icons/eraser-magic.svg` | Background Eraser and Magic Eraser tool glyphs: Lucide `eraser` plus added marks | Lucide Icons and Contributors; marks by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/eraser.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/brush-replace.svg` | Color Replacement tool glyph: Lucide `brush` plus two swap arrows | Lucide Icons and Contributors; arrows by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/brush.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/bandage-sparkle.svg` | Remove tool glyph: Lucide `bandage` moved down, plus a sparkle mark | Lucide Icons and Contributors; mark by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/bandage.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
@@ -51,6 +54,7 @@ next to them.
|
||||
| `packaging/macos/dmg/` (all files) | PhotoCraft DMG window background and layout | @XusBadia, from the PhotoCraft app icon | Original work, see [`packaging/macos/dmg/README.md`](packaging/macos/dmg/README.md) | MIT OR Apache-2.0, like the code |
|
||||
| `crates/ui-egui/src/theme.rs` (Solarized Dark palette) | Solarized colour scheme used by the Solarized Dark theme | Ethan Schoonover | <https://ethanschoonover.com/solarized/> | MIT, [`LICENSE-solarized.txt`](crates/ui-egui/src/LICENSE-solarized.txt) |
|
||||
| `crates/ui-egui/src/theme.rs` (Breeze Light and Breeze Dark themes) | The Breeze Light and Breeze Dark themes are original colour values on PhotoCraft's Studio layout, chosen to look close to KDE Plasma's Breeze (cool blue-greys, blue accent, 5 px frame radius) so the app blends into a Plasma desktop. None of Breeze's colour values, files or code are copied; the values are a few levels away from its schemes and tuned for text contrast | PhotoCraft contributors | Inspired by the look of KDE Breeze, <https://invent.kde.org/plasma/breeze> (its colour schemes are LGPL-2.0-or-later) | Original work |
|
||||
| `crates/ui-egui/src/theme.rs` (Soft Dark and Soft Light themes) | Original colour values and radii for two low-contrast themes on PhotoCraft's Studio layout; no third-party palette, file or code is used | PhotoCraft contributors | Original | Original work |
|
||||
|
||||
### Optional build input: craft-fonts (not files in this repo)
|
||||
|
||||
|
||||
Generated
+2
@@ -4220,12 +4220,14 @@ dependencies = [
|
||||
"photocraft-raster",
|
||||
"photocraft-tablet",
|
||||
"photocraft-ui-egui",
|
||||
"resvg",
|
||||
"rfd",
|
||||
"ron",
|
||||
"serde_json",
|
||||
"winit",
|
||||
"winresource",
|
||||
"winsafe",
|
||||
"wl-clipboard-rs",
|
||||
"x11rb",
|
||||
"xcap",
|
||||
"zbus",
|
||||
|
||||
@@ -199,7 +199,7 @@ Every screenshot here is the real app at work on public-domain art, rendered off
|
||||
</td>
|
||||
<td width="33%" valign="top">
|
||||
<h4>🗂️ Formats</h4>
|
||||
PSD and PSB, layered TIFF (Photoshop's layer data in the TIFF, read and written in either byte order), <a href="docs/pdn.md">Paint.NET PDN3 (read, with editable layers and all 14 blend modes)</a>, <a href="docs/ora.md">OpenRaster (read and write, with layers, groups and blend modes, for Krita, MyPaint and GIMP)</a>, SVG (opens as shape layers, places as a vector Smart Object), plus flat PNG, JPEG, TIFF, WebP (lossy and lossless), GIF, BMP, TGA, ICO, QOI, PNM, OpenEXR, Radiance HDR and FITS (astronomy images: 8/16-bit and 32-bit float, gray or RGB, header kept), and the native <code>.pcraft</code> format. Flat formats retain the depths they support: PNG keeps 8/16-bit samples, TIFF keeps 8/16-bit and 32-bit float samples, and OpenEXR keeps half/float samples; JPEG, WebP and the other 8-bit formats reduce higher-depth samples with an export warning. See the <a href="crates/codecs/README.md#capability-matrix-default-build">codec capability matrix</a> for each format. HEIC photos from iPhone and Mac are read-only (included in official builds; optional <code>--features heif</code> for source builds). AVIF is write-only with the optional <code>avif</code> build feature; AVIF decoding and JPEG XL are unavailable. Affinity documents (<code>.af</code>, <code>.afdesign</code>, <code>.afphoto</code>, <code>.afpub</code>) open natively, read only, with artboards, layers, vectors, text, images and masks; effects, adjustments and other unsupported parts are listed in a warning, and Affinity export is unavailable.
|
||||
PSD and PSB, layered TIFF (Photoshop's layer data in the TIFF, read and written in either byte order), <a href="docs/pdn.md">Paint.NET PDN3 (read, with editable layers and all 14 blend modes)</a>, <a href="docs/ora.md">OpenRaster (read and write, with layers, groups and blend modes, for Krita, MyPaint and GIMP)</a>, <a href="docs/kra.md">Krita .kra (read, with paint layers, groups, blend modes and transparency masks)</a>, SVG (opens as shape layers, places as a vector Smart Object), plus flat PNG, JPEG, TIFF, WebP (lossy and lossless), GIF, BMP, TGA, ICO, QOI, PNM, OpenEXR, Radiance HDR and FITS (astronomy images: 8/16-bit and 32-bit float, gray or RGB, header kept), and the native <code>.pcraft</code> format. Flat formats retain the depths they support: PNG keeps 8/16-bit samples, TIFF keeps 8/16-bit and 32-bit float samples, and OpenEXR keeps half/float samples; JPEG, WebP and the other 8-bit formats reduce higher-depth samples with an export warning. See the <a href="crates/codecs/README.md#capability-matrix-default-build">codec capability matrix</a> for each format. HEIC photos from iPhone and Mac are read-only (included in official builds; optional <code>--features heif</code> for source builds). AVIF is write-only with the optional <code>avif</code> build feature; AVIF decoding and JPEG XL are unavailable. Affinity documents (<code>.af</code>, <code>.afdesign</code>, <code>.afphoto</code>, <code>.afpub</code>) open natively, read only, with artboards, layers, vectors, text, images and masks; effects, adjustments and other unsupported parts are listed in a warning, and Affinity export is unavailable.
|
||||
</td>
|
||||
<td width="33%" valign="top">
|
||||
<h4>🪄 The everyday essentials</h4>
|
||||
|
||||
@@ -15,18 +15,19 @@ 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>]
|
||||
photocraft-cli convert <in> <out> [--format <ext>] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>]
|
||||
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.
|
||||
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>]
|
||||
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>]
|
||||
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>]
|
||||
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>]
|
||||
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
|
||||
@@ -68,17 +69,17 @@ struct Subcommand {
|
||||
}
|
||||
|
||||
const SUBCOMMANDS: &[Subcommand] = &[
|
||||
Subcommand { name: "convert", values: &["--format", "--quality", "--tga-bits"], bare: &["--tiff-layers"], run: convert },
|
||||
Subcommand { name: "convert", values: &["--format", "--quality", "--tga-bits", "--dpx-bits"], bare: &["--tiff-layers"], 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"],
|
||||
values: &["--new", "--cmd", "--params", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits"],
|
||||
bare: &["--tiff-layers"],
|
||||
run: run_cmds,
|
||||
},
|
||||
Subcommand {
|
||||
name: "batch",
|
||||
values: &["--actions", "--action", "--in", "--out", "--format", "--quality", "--tga-bits"],
|
||||
values: &["--actions", "--action", "--in", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits"],
|
||||
bare: &["--in-place", "--tiff-layers"],
|
||||
run: batch,
|
||||
},
|
||||
@@ -214,6 +215,10 @@ fn export_opts(a: &Args) -> Result<ExportOptions, String> {
|
||||
if let Some(b) = a.get("--tga-bits") {
|
||||
o.tga_bits = Some(b.parse().ok().and_then(photocraft_io::TgaBits::from_bits).ok_or_else(|| format!("bad --tga-bits `{b}`: expected 24 or 32"))?);
|
||||
}
|
||||
if let Some(b) = a.get("--dpx-bits") {
|
||||
o.encode.dpx_bits =
|
||||
Some(b.parse().ok().and_then(photocraft_io::DpxBits::from_bits).ok_or_else(|| format!("bad --dpx-bits `{b}`: expected 8, 10, 12 or 16"))?);
|
||||
}
|
||||
Ok(o)
|
||||
}
|
||||
|
||||
@@ -433,7 +438,7 @@ fn same_dir(a: &Path, b: &Path) -> bool {
|
||||
|
||||
fn is_input(p: &Path) -> bool {
|
||||
let known = |e: &str| {
|
||||
matches!(e, "pcraft" | "psd" | "psb" | "pdn" | "ora" | "af" | "afdesign" | "afphoto" | "afpub")
|
||||
matches!(e, "pcraft" | "psd" | "psb" | "pdn" | "ora" | "kra" | "krz" | "af" | "afdesign" | "afphoto" | "afpub")
|
||||
|| photocraft_codecs::from_extension(e).is_some_and(|f| photocraft_codecs::caps(f).read)
|
||||
};
|
||||
p.is_file() && p.extension().is_some_and(|e| known(&e.to_string_lossy().to_ascii_lowercase()))
|
||||
@@ -669,6 +674,11 @@ mod missing_font_warning_tests {
|
||||
let (code, _, stderr) = invoke(&["convert", &ora, &png]);
|
||||
assert_eq!(code, 0, "{stderr}");
|
||||
assert!(is_input(std::path::Path::new(&ora)));
|
||||
// Krita documents are batch inputs too (import-only).
|
||||
for name in ["sketch.kra", "old.KRZ"] {
|
||||
std::fs::write(folder.join(name), b"").unwrap();
|
||||
assert!(is_input(&folder.join(name)), "{name}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&folder);
|
||||
}
|
||||
|
||||
|
||||
@@ -455,6 +455,40 @@ fn quality_outside_1_to_100_is_refused() {
|
||||
assert!(sizes[0] <= sizes[1] && sizes[1] <= sizes[2], "{sizes:?}");
|
||||
}
|
||||
|
||||
/// DPX converts both ways, `info` reports its size, mode and depth, and `--dpx-bits` picks the
|
||||
/// bits per sample (byte 803 of the header); anything but 8, 10, 12 or 16 is refused (#1049).
|
||||
#[test]
|
||||
fn dpx_converts_both_ways_with_the_bits_asked_for() {
|
||||
let d = tmp("dpx");
|
||||
let a = d.join("a.png");
|
||||
write_png(&a, 8, 4, 3);
|
||||
for (bits, want) in [(None, 8), (Some("10"), 10), (Some("12"), 12), (Some("16"), 16)] {
|
||||
let out = d.join("a.dpx");
|
||||
let mut convert = bin();
|
||||
convert.arg("convert").arg(&a).arg(&out);
|
||||
if let Some(b) = bits {
|
||||
convert.args(["--dpx-bits", b]);
|
||||
}
|
||||
ok(&mut convert);
|
||||
let bytes = std::fs::read(&out).unwrap();
|
||||
assert_eq!((&bytes[..4], bytes[803]), (&b"SDPX"[..], want), "--dpx-bits {bits:?}");
|
||||
}
|
||||
let (info, _) = ok(bin().arg("info").arg(d.join("a.dpx")).arg("--compact"));
|
||||
let v: Value = serde_json::from_str(&info).unwrap();
|
||||
assert_eq!((v["width"].as_u64(), v["height"].as_u64()), (Some(8), Some(4)), "{v}");
|
||||
assert!(info.contains("16") && info.to_lowercase().contains("rgb"), "{info}");
|
||||
let back = d.join("back.png");
|
||||
ok(bin().arg("convert").arg(d.join("a.dpx")).arg(&back));
|
||||
assert!(back.exists());
|
||||
for bad in ["24", "0", "abc"] {
|
||||
let out = d.join("bad.dpx");
|
||||
let o = bin().arg("convert").arg(&a).arg(&out).args(["--dpx-bits", bad]).output().unwrap();
|
||||
assert_eq!(o.status.code(), Some(1), "--dpx-bits {bad}");
|
||||
assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("bad --dpx-bits `{bad}`: expected 8, 10, 12 or 16")), "--dpx-bits {bad}");
|
||||
assert!(!out.exists(), "--dpx-bits {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]
|
||||
|
||||
@@ -14,12 +14,12 @@ use wasm_bindgen::JsCast as _;
|
||||
|
||||
type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
|
||||
|
||||
/// Everything File › Open reads: PhotoCraft, Photoshop, OpenRaster and Affinity documents, flat images, and
|
||||
/// Everything File › Open reads: PhotoCraft, Photoshop, OpenRaster, Krita and Affinity documents, flat images, and
|
||||
/// Photoshop brushes (.abr), gradients (.grd) and swatches (.aco, .ase), which go to the preset libraries.
|
||||
const OPEN_EXTS: &[&str] = &[
|
||||
"pcraft", "pdn", "ora", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm",
|
||||
"ppm", "pam", "pfm", "fits", "fit", "fts", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd",
|
||||
"svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub",
|
||||
"pcraft", "pdn", "ora", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr",
|
||||
"pbm", "pgm", "ppm", "pam", "pfm", "dds", "fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf",
|
||||
"rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub",
|
||||
];
|
||||
const SVG_EXTS: &[&str] = &["svg", "svgz"];
|
||||
const CANVAS_ID: &str = "photocraft_canvas";
|
||||
@@ -441,6 +441,7 @@ fn mime_for(name: &str) -> &'static str {
|
||||
Some("gif") => "image/gif",
|
||||
Some("psd" | "psb") => "image/vnd.adobe.photoshop",
|
||||
Some("ora") => "image/openraster",
|
||||
Some("kra" | "krz") => "application/x-krita",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,8 +66,14 @@ dispatch2 = { version = "=0.3.1", default-features = false, features = ["std"] }
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
photocraft-cms = { workspace = true }
|
||||
x11rb = { version = "=0.13.2", default-features = false, features = ["image", "randr", "xkb"] }
|
||||
# Clipboard images in types other than PNG on Wayland (`services.rs`, #2546): arboard asks for
|
||||
# image/png only. Already in Cargo.lock (arboard's wayland-data-control); pinned exactly.
|
||||
wl-clipboard-rs = { version = "=0.9.4", default-features = false }
|
||||
# Wayland desktop color selection through the existing XDG portal transport.
|
||||
zbus = "=5.19.0"
|
||||
# Caption button glyphs from the desktop's icon theme (`caption_style.rs`): SVG paths only, no
|
||||
# text or images. Already in Cargo.lock (photocraft-io); pinned exactly.
|
||||
resvg = { version = "=0.45.1", default-features = false }
|
||||
# Already used by the portal stack; bounded waits let cancelling close the OS request.
|
||||
futures-lite = "=2.6.1"
|
||||
async-io = "=2.6.0"
|
||||
|
||||
@@ -6,7 +6,7 @@ const PORTAL_NAMESPACE: &str = "org.freedesktop.appearance";
|
||||
const PORTAL_KEY: &str = "color-scheme";
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn portal_proxy() -> Option<zbus::blocking::Proxy<'static>> {
|
||||
pub(crate) fn portal_proxy() -> Option<zbus::blocking::Proxy<'static>> {
|
||||
let conn = zbus::blocking::connection::Builder::session().ok()?.method_timeout(std::time::Duration::from_secs(2)).build().ok()?;
|
||||
zbus::blocking::Proxy::new(&conn, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.Settings").ok()
|
||||
}
|
||||
@@ -14,7 +14,7 @@ fn portal_proxy() -> Option<zbus::blocking::Proxy<'static>> {
|
||||
#[cfg(target_os = "linux")]
|
||||
fn portal_theme(proxy: &zbus::blocking::Proxy<'_>) -> Option<egui::Theme> {
|
||||
let value: zbus::zvariant::OwnedValue = proxy.call("Read", &(PORTAL_NAMESPACE, PORTAL_KEY)).ok()?;
|
||||
decode(portal_code(value)?)
|
||||
portal_code(value).and_then(decode).or_else(gtk_theme)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -39,8 +39,8 @@ fn decode(code: u32) -> Option<egui::Theme> {
|
||||
}
|
||||
}
|
||||
|
||||
/// One-shot fallback for desktops without the settings portal. It runs once at start-up, by
|
||||
/// absolute path, and is never repeated.
|
||||
/// GNOME fallback when the portal has no preference. Read only on the appearance worker,
|
||||
/// using an absolute path rather than the shell's PATH.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn gtk_theme() -> Option<egui::Theme> {
|
||||
let exe = ["/usr/bin/gsettings", "/bin/gsettings", "/usr/local/bin/gsettings"].into_iter().find(|p| std::path::Path::new(p).is_file())?;
|
||||
@@ -55,7 +55,7 @@ fn gtk_theme() -> Option<egui::Theme> {
|
||||
fn parse_gtk_scheme(s: &str) -> Option<egui::Theme> {
|
||||
match s.trim().trim_matches('\'') {
|
||||
"prefer-dark" => Some(egui::Theme::Dark),
|
||||
"prefer-light" => Some(egui::Theme::Light),
|
||||
"prefer-light" | "default" => Some(egui::Theme::Light),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -97,7 +97,7 @@ pub fn service() -> Option<photocraft_ui_egui::SystemThemeFn> {
|
||||
if namespace != PORTAL_NAMESPACE || key != PORTAL_KEY {
|
||||
continue;
|
||||
}
|
||||
publisher.publish(portal_code(changed).and_then(decode));
|
||||
publisher.publish(portal_code(changed).and_then(decode).or_else(gtk_theme));
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -184,7 +184,10 @@ mod tests {
|
||||
assert_eq!(super::portal_code(zbus::zvariant::OwnedValue::try_from(nested).unwrap()), Some(2));
|
||||
assert_eq!(super::parse_gtk_scheme("'prefer-light'\n"), Some(egui::Theme::Light));
|
||||
assert_eq!(super::parse_gtk_scheme("'prefer-dark'\n"), Some(egui::Theme::Dark));
|
||||
assert_eq!(super::parse_gtk_scheme("'default'\n"), None);
|
||||
// GNOME's "default" appearance is light, even though the portal reports no preference.
|
||||
assert_eq!(super::parse_gtk_scheme("'default'\n"), Some(egui::Theme::Light));
|
||||
assert_eq!(super::parse_gtk_scheme("default"), Some(egui::Theme::Light));
|
||||
assert_eq!(super::parse_gtk_scheme("'not-a-scheme'"), None);
|
||||
}
|
||||
|
||||
/// #2798: a first read that misses the start-up wait still wakes the idle UI.
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
//! Linux: which desktop the custom title bar's caption buttons look like, and which buttons go on
|
||||
//! which side (`photocraft_ui_egui::CaptionStyle`). GNOME gets libadwaita's buttons in the order of
|
||||
//! its `button-layout` setting (read through the settings portal, and followed as it changes); KDE
|
||||
//! gets Breeze's in KWin's order (`kwinrc`); other desktops get plain round buttons, or Adwaita's
|
||||
//! or Breeze's when their GTK theme is one of those, in GTK's `gtk-decoration-layout` order.
|
||||
//!
|
||||
//! `PHOTOCRAFT_CAPTION_STYLE` (`windows`, `adwaita`, `breeze`, `generic`) and
|
||||
//! `PHOTOCRAFT_CAPTION_LAYOUT` (a GTK layout such as `close,minimize:`) override what is found.
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
use photocraft_ui_egui::{CaptionFamily, CaptionIcon, CaptionIcons, CaptionStyle};
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
enum Desktop {
|
||||
Gnome,
|
||||
Kde,
|
||||
Other,
|
||||
}
|
||||
|
||||
/// The desktop, from the first of its variables that is set: `ubuntu:GNOME`, `Budgie:GNOME` and
|
||||
/// the like count as GNOME.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn desktop(var: &dyn Fn(&str) -> Option<String>) -> Desktop {
|
||||
for key in ["XDG_CURRENT_DESKTOP", "XDG_SESSION_DESKTOP", "DESKTOP_SESSION"] {
|
||||
let Some(v) = var(key).map(|v| v.to_ascii_lowercase()).filter(|v| !v.trim().is_empty()) else { continue };
|
||||
return if v.contains("gnome") {
|
||||
Desktop::Gnome
|
||||
} else if v.contains("kde") || v.contains("plasma") {
|
||||
Desktop::Kde
|
||||
} else {
|
||||
Desktop::Other
|
||||
};
|
||||
}
|
||||
if var("KDE_FULL_SESSION").is_some() {
|
||||
Desktop::Kde
|
||||
} else if var("GNOME_DESKTOP_SESSION_ID").is_some() {
|
||||
Desktop::Gnome
|
||||
} else {
|
||||
Desktop::Other
|
||||
}
|
||||
}
|
||||
|
||||
/// GNOME's apps are libadwaita's whatever the GTK theme, and KWin draws Breeze; elsewhere the GTK
|
||||
/// theme says (Adwaita, adw-gtk3, Yaru, Breeze), and any other theme gets the plain buttons.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn family(desktop: Desktop, gtk_theme: Option<&str>) -> CaptionFamily {
|
||||
match desktop {
|
||||
Desktop::Gnome => CaptionFamily::Adwaita,
|
||||
Desktop::Kde => CaptionFamily::Breeze,
|
||||
Desktop::Other => {
|
||||
let theme = gtk_theme.unwrap_or_default().trim().to_ascii_lowercase();
|
||||
if ["adwaita", "adw-gtk3", "yaru"].iter().any(|p| theme.starts_with(p)) {
|
||||
CaptionFamily::Adwaita
|
||||
} else if theme.starts_with("breeze") {
|
||||
CaptionFamily::Breeze
|
||||
} else {
|
||||
CaptionFamily::Generic
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The value of `key` in `[section]` of an INI-style file (GTK's `settings.ini`, KDE's `kwinrc`).
|
||||
#[cfg(target_os = "linux")]
|
||||
fn ini_value(text: &str, section: &str, key: &str) -> Option<String> {
|
||||
let mut inside = false;
|
||||
for line in text.lines().map(str::trim) {
|
||||
if let Some(name) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
|
||||
inside = name == section;
|
||||
} else if inside
|
||||
&& let Some((k, v)) = line.split_once('=')
|
||||
&& k.trim() == key
|
||||
{
|
||||
return Some(v.trim().to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// KWin's button order (`[org.kde.kdecoration2]` `ButtonsOnLeft` / `ButtonsOnRight`, one letter a
|
||||
/// button: `I` minimize, `A` maximize, `X` close), as a GTK layout. Missing keys keep KWin's
|
||||
/// defaults: the window menu on the left, minimize, maximize and close on the right.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn kwin_layout(kwinrc: &str) -> String {
|
||||
let side = |key: &str, default: &str| {
|
||||
let letters = ini_value(kwinrc, "org.kde.kdecoration2", key).unwrap_or_else(|| default.to_string());
|
||||
letters
|
||||
.chars()
|
||||
.filter_map(|c| match c {
|
||||
'I' => Some("minimize"),
|
||||
'A' => Some("maximize"),
|
||||
'X' => Some("close"),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(",")
|
||||
};
|
||||
format!("{}:{}", side("ButtonsOnLeft", "MS"), side("ButtonsOnRight", "HIAX"))
|
||||
}
|
||||
|
||||
/// What the style is worked out from.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[derive(Clone, Default)]
|
||||
struct Found {
|
||||
gtk_theme: Option<String>,
|
||||
/// A GTK decoration layout.
|
||||
layout: Option<String>,
|
||||
icon_theme: Option<String>,
|
||||
}
|
||||
|
||||
/// The icon theme the glyphs come from: `PHOTOCRAFT_CAPTION_ICONS` (`none` draws PhotoCraft's own),
|
||||
/// the desktop's setting, or its default theme.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn icon_theme_name(desktop: Desktop, found: &Found, var: &dyn Fn(&str) -> Option<String>) -> Option<String> {
|
||||
match var("PHOTOCRAFT_CAPTION_ICONS") {
|
||||
Some(name) if name.trim().eq_ignore_ascii_case("none") => None,
|
||||
Some(name) => Some(name.trim().to_string()),
|
||||
None => Some(found.icon_theme.clone().unwrap_or_else(|| if desktop == Desktop::Kde { "breeze" } else { "Adwaita" }.into())),
|
||||
}
|
||||
}
|
||||
|
||||
/// The style from what was found; `icons` loads a theme's glyphs.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn resolve(desktop: Desktop, found: &Found, var: &dyn Fn(&str) -> Option<String>, icons: &dyn Fn(&str) -> Option<CaptionIcons>) -> CaptionStyle {
|
||||
let family = var("PHOTOCRAFT_CAPTION_STYLE").and_then(|s| CaptionFamily::parse(&s)).unwrap_or_else(|| family(desktop, found.gtk_theme.as_deref()));
|
||||
// GNOME's own default shows Close only; GTK's shows all three.
|
||||
let default = if desktop == Desktop::Gnome { "appmenu:close" } else { "menu:minimize,maximize,close" };
|
||||
let layout = var("PHOTOCRAFT_CAPTION_LAYOUT").or_else(|| found.layout.clone()).unwrap_or_else(|| default.to_string());
|
||||
let mut style = CaptionStyle::from_gtk_layout(family, &layout);
|
||||
style.icons = icon_theme_name(desktop, found, var).and_then(|name| icons(&name));
|
||||
style
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn read_text(path: &std::path::Path) -> Option<String> {
|
||||
crate::icon_theme::read_small(path).map(|b| String::from_utf8_lossy(&b).into_owned())
|
||||
}
|
||||
|
||||
/// The themes and layout from the desktop's files: `kwinrc` and `kdeglobals` on KDE, GTK 3's
|
||||
/// `settings.ini` elsewhere (where GNOME's portal answers, read later, replace them).
|
||||
#[cfg(target_os = "linux")]
|
||||
fn from_files(desktop: Desktop, var: &dyn Fn(&str) -> Option<String>) -> Found {
|
||||
let config = var("XDG_CONFIG_HOME")
|
||||
.filter(|d| std::path::Path::new(d).is_absolute())
|
||||
.map(std::path::PathBuf::from)
|
||||
.or_else(|| var("HOME").map(|h| std::path::Path::new(&h).join(".config")));
|
||||
let Some(config) = config else { return Found::default() };
|
||||
let gtk = read_text(&config.join("gtk-3.0").join("settings.ini")).unwrap_or_default();
|
||||
let gtk_theme = ini_value(>k, "Settings", "gtk-theme-name");
|
||||
if desktop == Desktop::Kde {
|
||||
let layout = Some(kwin_layout(&read_text(&config.join("kwinrc")).unwrap_or_default()));
|
||||
let icon_theme = ini_value(&read_text(&config.join("kdeglobals")).unwrap_or_default(), "Icons", "Theme");
|
||||
Found { gtk_theme, layout, icon_theme }
|
||||
} else {
|
||||
let layout = ini_value(>k, "Settings", "gtk-decoration-layout");
|
||||
Found { gtk_theme, layout, icon_theme: ini_value(>k, "Settings", "gtk-icon-theme-name") }
|
||||
}
|
||||
}
|
||||
|
||||
/// The caption glyphs of icon theme `theme` (`window-*-symbolic.svg`), read now and rendered at
|
||||
/// whatever size the bar asks for. `None` when the theme (with its parents) has none of them.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn load_icons(theme: &str) -> Option<CaptionIcons> {
|
||||
use crate::icon_theme::{IconTheme, base_dirs, rasterize_mask, read_small};
|
||||
let found = IconTheme::load(theme, &base_dirs(&|k: &str| std::env::var(k).ok()))?;
|
||||
let svg = |name: &str| found.find_svg(name).and_then(|p| read_small(&p)).map(std::sync::Arc::<[u8]>::from);
|
||||
let files = [svg("window-minimize-symbolic"), svg("window-maximize-symbolic"), svg("window-restore-symbolic"), svg("window-close-symbolic")];
|
||||
if files.iter().all(Option::is_none) {
|
||||
return None;
|
||||
}
|
||||
let render = move |icon: CaptionIcon, px: u32| {
|
||||
let i = match icon {
|
||||
CaptionIcon::Minimize => 0,
|
||||
CaptionIcon::Maximize => 1,
|
||||
CaptionIcon::Restore => 2,
|
||||
CaptionIcon::Close => 3,
|
||||
};
|
||||
files.get(i)?.as_deref().and_then(|svg| rasterize_mask(svg, px))
|
||||
};
|
||||
Some(CaptionIcons { theme: theme.to_string(), render: std::sync::Arc::new(render) })
|
||||
}
|
||||
|
||||
/// [`load_icons`] for the last theme asked for, kept, so a layout change doesn't reread it.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[derive(Default)]
|
||||
struct IconCache(std::sync::Mutex<Option<(String, Option<CaptionIcons>)>>);
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
impl IconCache {
|
||||
fn get(&self, theme: &str) -> Option<CaptionIcons> {
|
||||
let mut last = self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if let Some((name, icons)) = last.as_ref()
|
||||
&& name == theme
|
||||
{
|
||||
return icons.clone();
|
||||
}
|
||||
let icons = load_icons(theme);
|
||||
*last = Some((theme.to_string(), icons.clone()));
|
||||
icons
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
const WM_NAMESPACE: &str = "org.gnome.desktop.wm.preferences";
|
||||
#[cfg(target_os = "linux")]
|
||||
const LAYOUT_KEY: &str = "button-layout";
|
||||
#[cfg(target_os = "linux")]
|
||||
const INTERFACE_NAMESPACE: &str = "org.gnome.desktop.interface";
|
||||
#[cfg(target_os = "linux")]
|
||||
const ICONS_KEY: &str = "icon-theme";
|
||||
|
||||
/// A string setting from the portal, unwrapping the variants some portals nest it in.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn portal_string(value: zbus::zvariant::OwnedValue) -> Option<String> {
|
||||
let mut value: zbus::zvariant::Value<'_> = value.into();
|
||||
for _ in 0..4 {
|
||||
match value {
|
||||
zbus::zvariant::Value::Value(inner) => value = *inner,
|
||||
zbus::zvariant::Value::Str(s) => return Some(s.to_string()),
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The caption style now, then with the icon theme's glyphs and (GNOME) the portal's settings,
|
||||
/// following the button layout and icon theme as they change. A worker does the file and D-Bus
|
||||
/// work and wakes the UI only when the style changes; the first answer usually lands before the
|
||||
/// first frame.
|
||||
pub fn service() -> Option<photocraft_ui_egui::CaptionStyleFn> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use std::sync::{Arc, Mutex, OnceLock, PoisonError};
|
||||
let var = |k: &str| std::env::var(k).ok();
|
||||
let desktop = desktop(&var);
|
||||
let mut found = from_files(desktop, &var);
|
||||
// Icons need the theme read, which takes tens of milliseconds: the worker brings them.
|
||||
let current = Arc::new(Mutex::new(resolve(desktop, &found, &var, &|_: &str| None)));
|
||||
let wake: Arc<OnceLock<egui::Context>> = Arc::new(OnceLock::new());
|
||||
let (shared, waker) = (Arc::clone(¤t), Arc::clone(&wake));
|
||||
let cache = IconCache::default();
|
||||
let publish = move |found: &Found| {
|
||||
let style = resolve(desktop, found, &|k: &str| std::env::var(k).ok(), &|t: &str| cache.get(t));
|
||||
let mut cur = shared.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if *cur != style {
|
||||
*cur = style;
|
||||
if let Some(ctx) = waker.get() {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
};
|
||||
let (ready, wait) = std::sync::mpsc::channel();
|
||||
let spawned = std::thread::Builder::new().name("caption-style".into()).spawn(move || {
|
||||
let proxy = if desktop == Desktop::Gnome { crate::appearance::portal_proxy() } else { None };
|
||||
let Some(proxy) = proxy else {
|
||||
publish(&found);
|
||||
let _ = ready.send(());
|
||||
return;
|
||||
};
|
||||
// Subscribe before the first read, so a change during start-up isn't lost.
|
||||
let signals = proxy.receive_signal("SettingChanged").ok();
|
||||
let read = |namespace: &str, key: &str| {
|
||||
let value: Option<zbus::zvariant::OwnedValue> = proxy.call("Read", &(namespace, key)).ok();
|
||||
value.and_then(portal_string)
|
||||
};
|
||||
found.layout = read(WM_NAMESPACE, LAYOUT_KEY).or(found.layout.take());
|
||||
found.icon_theme = read(INTERFACE_NAMESPACE, ICONS_KEY).or(found.icon_theme.take());
|
||||
publish(&found);
|
||||
let _ = ready.send(());
|
||||
for message in signals.into_iter().flatten() {
|
||||
let Ok((namespace, key, value)) = message.body().deserialize::<(String, String, zbus::zvariant::OwnedValue)>() else { continue };
|
||||
match (namespace.as_str(), key.as_str()) {
|
||||
(WM_NAMESPACE, LAYOUT_KEY) => found.layout = portal_string(value),
|
||||
(INTERFACE_NAMESPACE, ICONS_KEY) => found.icon_theme = portal_string(value),
|
||||
_ => continue,
|
||||
}
|
||||
publish(&found);
|
||||
}
|
||||
});
|
||||
// A fast desktop answers before the first frame; a slow one updates the bar later.
|
||||
if spawned.is_ok() {
|
||||
let _ = wait.recv_timeout(std::time::Duration::from_millis(150));
|
||||
}
|
||||
Some(Box::new(move |ctx| {
|
||||
let _ = wake.set(ctx.clone());
|
||||
current.lock().unwrap_or_else(PoisonError::into_inner).clone()
|
||||
}))
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, target_os = "linux"))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use photocraft_ui_egui::Caption::{Close, Maximize, Minimize};
|
||||
|
||||
fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> + use<> {
|
||||
let pairs: Vec<(String, String)> = pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
|
||||
move |k| pairs.iter().find(|(key, _)| key == k).map(|(_, v)| v.clone())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_desktop_comes_from_the_first_variable_set() {
|
||||
assert_eq!(desktop(&env(&[("XDG_CURRENT_DESKTOP", "ubuntu:GNOME")])), Desktop::Gnome);
|
||||
assert_eq!(desktop(&env(&[("XDG_CURRENT_DESKTOP", "KDE")])), Desktop::Kde);
|
||||
assert_eq!(desktop(&env(&[("XDG_CURRENT_DESKTOP", "X-Cinnamon"), ("KDE_FULL_SESSION", "true")])), Desktop::Other);
|
||||
assert_eq!(desktop(&env(&[("XDG_CURRENT_DESKTOP", " "), ("DESKTOP_SESSION", "plasma")])), Desktop::Kde);
|
||||
assert_eq!(desktop(&env(&[("KDE_FULL_SESSION", "true")])), Desktop::Kde);
|
||||
assert_eq!(desktop(&env(&[("GNOME_DESKTOP_SESSION_ID", "this-is-deprecated")])), Desktop::Gnome);
|
||||
assert_eq!(desktop(&env(&[])), Desktop::Other);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_family_follows_the_desktop_then_the_gtk_theme() {
|
||||
assert_eq!(family(Desktop::Gnome, Some("Arc")), CaptionFamily::Adwaita);
|
||||
assert_eq!(family(Desktop::Kde, None), CaptionFamily::Breeze);
|
||||
assert_eq!(family(Desktop::Other, Some("adw-gtk3-dark")), CaptionFamily::Adwaita);
|
||||
assert_eq!(family(Desktop::Other, Some("Yaru-blue")), CaptionFamily::Adwaita);
|
||||
assert_eq!(family(Desktop::Other, Some("Breeze-Dark")), CaptionFamily::Breeze);
|
||||
assert_eq!(family(Desktop::Other, Some("Mint-Y")), CaptionFamily::Generic);
|
||||
assert_eq!(family(Desktop::Other, None), CaptionFamily::Generic);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kwin_button_letters_become_a_gtk_layout() {
|
||||
assert_eq!(kwin_layout(""), ":minimize,maximize,close", "KWin's defaults");
|
||||
let rc = "[Desktops]\nButtonsOnLeft=X\n[org.kde.kdecoration2]\nButtonsOnLeft=XIA\nButtonsOnRight=\nlibrary=org.kde.breeze\n";
|
||||
assert_eq!(kwin_layout(rc), "close,minimize,maximize:");
|
||||
assert_eq!(ini_value(rc, "org.kde.kdecoration2", "library").as_deref(), Some("org.kde.breeze"));
|
||||
assert_eq!(ini_value(rc, "nope", "library"), None);
|
||||
// Garbage is just no buttons on that side, never a panic.
|
||||
assert_eq!(kwin_layout("[org.kde.kdecoration2]\nButtonsOnRight=ÿ€=X\n"), ":close");
|
||||
}
|
||||
|
||||
fn no_icons(_: &str) -> Option<CaptionIcons> {
|
||||
None
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_icon_theme_comes_from_the_override_the_setting_or_the_desktop() {
|
||||
let found = Found { icon_theme: Some("Papirus".into()), ..Found::default() };
|
||||
assert_eq!(icon_theme_name(Desktop::Gnome, &found, &env(&[])).as_deref(), Some("Papirus"));
|
||||
assert_eq!(icon_theme_name(Desktop::Gnome, &Found::default(), &env(&[])).as_deref(), Some("Adwaita"));
|
||||
assert_eq!(icon_theme_name(Desktop::Kde, &Found::default(), &env(&[])).as_deref(), Some("breeze"));
|
||||
assert_eq!(icon_theme_name(Desktop::Kde, &found, &env(&[("PHOTOCRAFT_CAPTION_ICONS", " None ")])), None);
|
||||
assert_eq!(icon_theme_name(Desktop::Kde, &found, &env(&[("PHOTOCRAFT_CAPTION_ICONS", "Tela")])).as_deref(), Some("Tela"));
|
||||
// The style carries what the loader returns for that name.
|
||||
let s = resolve(Desktop::Gnome, &found, &env(&[]), &|t| {
|
||||
(t == "Papirus").then(|| CaptionIcons { theme: t.into(), render: std::sync::Arc::new(|_, _| None) })
|
||||
});
|
||||
assert_eq!(s.icons.map(|i| i.theme).as_deref(), Some("Papirus"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layouts_and_overrides_resolve_to_a_style() {
|
||||
let none = env(&[]);
|
||||
let gnome = resolve(Desktop::Gnome, &Found::default(), &none, &no_icons);
|
||||
assert_eq!((gnome.family, gnome.left, gnome.right), (CaptionFamily::Adwaita, vec![], vec![Close]), "GNOME's default layout");
|
||||
let kde = resolve(Desktop::Kde, &Found { layout: Some(kwin_layout("")), ..Found::default() }, &none, &no_icons);
|
||||
assert_eq!((kde.family, kde.right), (CaptionFamily::Breeze, vec![Minimize, Maximize, Close]));
|
||||
let found = Found { gtk_theme: Some("Mint-Y".into()), layout: Some("close:".into()), icon_theme: None };
|
||||
let other = resolve(Desktop::Other, &found, &none, &no_icons);
|
||||
assert_eq!((other.family, other.left, other.right), (CaptionFamily::Generic, vec![Close], vec![]));
|
||||
let forced = env(&[("PHOTOCRAFT_CAPTION_STYLE", "Breeze"), ("PHOTOCRAFT_CAPTION_LAYOUT", "minimize,close:maximize")]);
|
||||
let s = resolve(Desktop::Gnome, &found, &forced, &no_icons);
|
||||
assert_eq!((s.family, s.left, s.right), (CaptionFamily::Breeze, vec![Minimize, Close], vec![Maximize]));
|
||||
// An unknown override name falls back to what was found.
|
||||
assert_eq!(resolve(Desktop::Kde, &found, &env(&[("PHOTOCRAFT_CAPTION_STYLE", "aqua")]), &no_icons).family, CaptionFamily::Breeze);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portal_strings_unwrap_nested_variants() {
|
||||
use zbus::zvariant::{OwnedValue, Value};
|
||||
let nested = Value::Value(Box::new(Value::Value(Box::new(Value::from("appmenu:close")))));
|
||||
assert_eq!(portal_string(OwnedValue::try_from(nested).unwrap()).as_deref(), Some("appmenu:close"));
|
||||
assert_eq!(portal_string(OwnedValue::try_from(Value::U32(7)).unwrap()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_files_are_read_from_the_config_dir() {
|
||||
let dir = std::env::temp_dir().join(format!("photocraft-caption-{}", std::process::id()));
|
||||
std::fs::create_dir_all(dir.join("gtk-3.0")).unwrap();
|
||||
std::fs::write(dir.join("gtk-3.0/settings.ini"), "[Settings]\ngtk-theme-name=Yaru\ngtk-decoration-layout=close,maximize:\n").unwrap();
|
||||
std::fs::write(dir.join("kwinrc"), "[org.kde.kdecoration2]\nButtonsOnRight=AX\n").unwrap();
|
||||
std::fs::write(dir.join("kdeglobals"), "[Icons]\nTheme=breeze-dark\n").unwrap();
|
||||
let var = env(&[("XDG_CONFIG_HOME", dir.to_str().unwrap())]);
|
||||
let other = from_files(Desktop::Other, &var);
|
||||
assert_eq!((other.gtk_theme.as_deref(), other.layout.as_deref()), (Some("Yaru"), Some("close,maximize:")));
|
||||
let kde = from_files(Desktop::Kde, &var);
|
||||
assert_eq!((kde.layout.as_deref(), kde.icon_theme.as_deref()), (Some(":maximize,close"), Some("breeze-dark")));
|
||||
// A relative XDG_CONFIG_HOME is ignored, as the XDG spec says; no HOME: nothing found.
|
||||
assert_eq!(from_files(Desktop::Other, &env(&[("XDG_CONFIG_HOME", "rel")])).layout, None);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ use photocraft_automation::security::{
|
||||
ConnectionLimiter, LineRead, MAX_CONNECTIONS, MAX_REQUEST_BYTES, authentication_reply, configure_stream, read_bounded_line, repeat_auth_reply,
|
||||
};
|
||||
use photocraft_ui_egui::ControlRequest;
|
||||
use photocraft_ui_egui::control::{REPLY_DEADLINE, timeout_error};
|
||||
use photocraft_ui_egui::control::REPLY_DEADLINE;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
pub fn start(port: u16, token: String, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
@@ -93,12 +93,13 @@ fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::
|
||||
let deadline = Instant::now() + REPLY_DEADLINE;
|
||||
let (mut req, rrx) = ControlRequest::new(method, params);
|
||||
req.deadline = Some(deadline);
|
||||
let dispatch = req.dispatch.clone();
|
||||
if tx.send(req).is_err() {
|
||||
break;
|
||||
}
|
||||
ctx.request_repaint();
|
||||
let mut r =
|
||||
rrx.recv_timeout(deadline.saturating_duration_since(Instant::now())).unwrap_or_else(|_| json!({"ok": false, "error": timeout_error()}));
|
||||
rrx.recv_timeout(deadline.saturating_duration_since(Instant::now())).unwrap_or_else(|_| json!({"ok": false, "error": dispatch.give_up()}));
|
||||
if let Some(o) = r.as_object_mut() {
|
||||
o.insert("id".into(), id);
|
||||
}
|
||||
|
||||
@@ -672,7 +672,7 @@ mod tests {
|
||||
} else {
|
||||
s.finish();
|
||||
}
|
||||
let (prev, _s) = Sentinel::begin(&dir);
|
||||
let (prev, _s) = begin_when_unlocked(&dir);
|
||||
assert_eq!(prev, Previous::Clean);
|
||||
assert_eq!(plan(Auto, prev.crashed(), None, false, Os::Other).backend, Auto);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
@@ -684,6 +684,22 @@ mod tests {
|
||||
d
|
||||
}
|
||||
|
||||
/// `Sentinel::begin`, called again up to 50 times, 10 ms apart, while it returns
|
||||
/// `Previous::Busy`. Other tests in this binary spawn processes: between fork and exec a child
|
||||
/// briefly shares the marker's flock, so `begin` can see the lock as busy just after a test
|
||||
/// drops its sentinel.
|
||||
fn begin_when_unlocked(dir: &Path) -> (Previous, Option<Sentinel>) {
|
||||
let (mut prev, mut s) = Sentinel::begin(dir);
|
||||
for _ in 0..50 {
|
||||
if prev != Previous::Busy {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
(prev, s) = Sentinel::begin(dir);
|
||||
}
|
||||
(prev, s)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sentinel_detects_a_crash_and_clears_on_success() {
|
||||
let dir = temp_dir("cycle");
|
||||
@@ -699,16 +715,7 @@ mod tests {
|
||||
assert!(none.is_none());
|
||||
// The process dies in the driver: the lock goes, the marker stays.
|
||||
drop(s);
|
||||
// Another test in this binary spawns a process: between its fork and exec the child briefly
|
||||
// shares this file's flock, so `begin` can see the lock as busy. Retry for a moment.
|
||||
let (mut prev, mut s) = Sentinel::begin(&dir);
|
||||
for _ in 0..50 {
|
||||
if prev != Previous::Busy {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
(prev, s) = Sentinel::begin(&dir);
|
||||
}
|
||||
let (prev, s) = begin_when_unlocked(&dir);
|
||||
let m = prev.crashed().cloned().expect("crash detected");
|
||||
assert_eq!((m.adapter.as_str(), m.tried()), ("GPU", Some(Vulkan)));
|
||||
// This start succeeds: the next one finds nothing.
|
||||
@@ -721,6 +728,29 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn begin_when_unlocked_waits_for_a_briefly_held_lock() {
|
||||
let dir = temp_dir("shared-lock");
|
||||
let (_, s) = Sentinel::begin(&dir);
|
||||
let mut s = s.expect("sentinel");
|
||||
s.write(Marker { backend: "vulkan".into(), ..Default::default() }).unwrap();
|
||||
// After the start dies, a second handle on the marker holds the lock for 50 ms, as a
|
||||
// spawned child's copy of the descriptor does until the child calls exec.
|
||||
let held = File::open(dir.join(MARKER_FILE)).unwrap();
|
||||
drop(s);
|
||||
held.lock().unwrap();
|
||||
assert_eq!(Sentinel::begin(&dir).0, Previous::Busy);
|
||||
let release = std::thread::spawn(move || {
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
held.unlock().unwrap();
|
||||
});
|
||||
let (prev, s) = begin_when_unlocked(&dir);
|
||||
release.join().unwrap();
|
||||
assert_eq!(prev.crashed().map(|m| m.backend.as_str()), Some("vulkan"));
|
||||
s.expect("sentinel").finish();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returned_non_graphics_errors_do_not_trigger_backend_recovery() {
|
||||
let cases = [
|
||||
@@ -738,7 +768,7 @@ mod tests {
|
||||
} else {
|
||||
s.finish();
|
||||
}
|
||||
let (previous, s) = Sentinel::begin(&dir);
|
||||
let (previous, s) = begin_when_unlocked(&dir);
|
||||
assert_eq!(previous.crashed().is_some(), keep, "{name}: {result:?}");
|
||||
if !keep {
|
||||
assert_eq!(plan(Vulkan, previous.crashed(), None, false, Os::Other).backend, Vulkan);
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
//! Linux: icons from the desktop's freedesktop icon theme (the Icon Theme Specification), for the
|
||||
//! caption buttons' glyphs (`caption_style.rs`). A theme is searched, then the themes it inherits,
|
||||
//! then `hicolor`; within each, its directories from the best fit for a 16 px icon down. Only SVG
|
||||
//! files are used: symbolic icons are drawn as a coverage mask in the button's ink colour.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Bounds on what a theme's files can make us do: themes in an inheritance chain, directories a
|
||||
/// theme lists, and the bytes read from one file.
|
||||
const MAX_THEMES: usize = 32;
|
||||
const MAX_DIRS: usize = 4096;
|
||||
/// How deep an index's unlisted directories are looked for (`symbolic/ui`, `16x16@2/actions`).
|
||||
const SCAN_DEPTH: usize = 3;
|
||||
const MAX_FILE: u64 = 1 << 20;
|
||||
/// The size the caption glyphs are drawn at, in the spec's units.
|
||||
const ICON_SIZE: i64 = 16;
|
||||
|
||||
/// A theme or icon name that stays inside its directory.
|
||||
pub fn valid_name(name: &str) -> bool {
|
||||
!name.is_empty() && name != "." && name != ".." && !name.contains(['/', '\\', '\0'])
|
||||
}
|
||||
|
||||
/// At most [`MAX_FILE`] bytes of a file (settings, theme indexes and icons are small).
|
||||
pub fn read_small(path: &Path) -> Option<Vec<u8>> {
|
||||
use std::io::Read;
|
||||
let mut bytes = vec![];
|
||||
std::fs::File::open(path).ok()?.take(MAX_FILE).read_to_end(&mut bytes).ok()?;
|
||||
Some(bytes)
|
||||
}
|
||||
|
||||
/// Where themes live, in the spec's order: `~/.icons`, `$XDG_DATA_HOME/icons`, then each
|
||||
/// `$XDG_DATA_DIRS/icons`. Relative paths in the variables are ignored, as the XDG spec says.
|
||||
pub fn base_dirs(var: &dyn Fn(&str) -> Option<String>) -> Vec<PathBuf> {
|
||||
let absolute = |p: &String| Path::new(p).is_absolute();
|
||||
let home = var("HOME").filter(absolute).map(PathBuf::from);
|
||||
let mut dirs = vec![];
|
||||
if let Some(home) = &home {
|
||||
dirs.push(home.join(".icons"));
|
||||
}
|
||||
if let Some(data) = var("XDG_DATA_HOME").filter(absolute).map(PathBuf::from).or_else(|| home.map(|h| h.join(".local").join("share"))) {
|
||||
dirs.push(data.join("icons"));
|
||||
}
|
||||
let data_dirs = var("XDG_DATA_DIRS").filter(|d| !d.trim().is_empty()).unwrap_or_else(|| "/usr/local/share:/usr/share".into());
|
||||
dirs.extend(data_dirs.split(':').filter(|d| Path::new(d).is_absolute()).map(|d| Path::new(d).join("icons")));
|
||||
dirs
|
||||
}
|
||||
|
||||
/// One `[section]` of an `index.theme`, as (key, value) pairs.
|
||||
fn section<'a>(index: &'a str, name: &str) -> Vec<(&'a str, &'a str)> {
|
||||
let mut inside = false;
|
||||
let mut pairs = vec![];
|
||||
for line in index.lines().map(str::trim) {
|
||||
if let Some(s) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
|
||||
if inside {
|
||||
break;
|
||||
}
|
||||
inside = s == name;
|
||||
} else if inside && let Some((k, v)) = line.split_once('=') {
|
||||
pairs.push((k.trim(), v.trim()));
|
||||
}
|
||||
}
|
||||
pairs
|
||||
}
|
||||
|
||||
fn list(value: Option<&str>) -> impl Iterator<Item = &str> {
|
||||
value.unwrap_or_default().split(',').map(str::trim).filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
/// How far a theme directory is from a 16 px icon at scale 1 (the spec's
|
||||
/// `DirectorySizeDistance`), then its scale: 0 is an exact fit.
|
||||
fn distance(keys: &[(&str, &str)]) -> (i64, i64) {
|
||||
let get = |k: &str| keys.iter().find(|(key, _)| *key == k).map(|(_, v)| *v);
|
||||
let num = |k: &str, default: i64| get(k).and_then(|v| v.parse::<i64>().ok()).unwrap_or(default).clamp(0, 1 << 16);
|
||||
let size = num("Size", 0);
|
||||
let scale = num("Scale", 1).max(1);
|
||||
let d = match get("Type").unwrap_or("Threshold") {
|
||||
"Fixed" => (size * scale - ICON_SIZE).abs(),
|
||||
"Scalable" => {
|
||||
let (min, max) = (num("MinSize", size) * scale, num("MaxSize", size) * scale);
|
||||
(min - ICON_SIZE).max(ICON_SIZE - max).max(0)
|
||||
}
|
||||
_ => ((size * scale - ICON_SIZE).abs() - num("Threshold", 2) * scale).max(0),
|
||||
};
|
||||
(d, scale)
|
||||
}
|
||||
|
||||
/// Every directory under `root`, at most [`SCAN_DEPTH`] levels down and [`MAX_DIRS`] in all, in
|
||||
/// path order: scalable and symbolic ones first, as icons there fit any size.
|
||||
fn subdirectories(root: &Path) -> Vec<PathBuf> {
|
||||
let mut found = vec![];
|
||||
let mut level = vec![root.to_path_buf()];
|
||||
for _ in 0..SCAN_DEPTH {
|
||||
let mut next = vec![];
|
||||
for dir in &level {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else { continue };
|
||||
for entry in entries.flatten().take(MAX_DIRS) {
|
||||
let path = entry.path();
|
||||
if found.len() + next.len() < MAX_DIRS && path.is_dir() {
|
||||
next.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
found.extend(next.iter().cloned());
|
||||
level = next;
|
||||
}
|
||||
let vector = |p: &PathBuf| !p.components().any(|c| matches!(c.as_os_str().to_str(), Some("scalable" | "symbolic")));
|
||||
found.sort_by_key(|p| (vector(p), p.clone()));
|
||||
found
|
||||
}
|
||||
|
||||
/// One theme of a chain: its listed directories, best fit first, in every base directory that has
|
||||
/// the theme, and (looked for only when an icon isn't in those) the directories its index forgot.
|
||||
struct Theme {
|
||||
roots: Vec<PathBuf>,
|
||||
listed: Vec<PathBuf>,
|
||||
unlisted: std::cell::OnceCell<Vec<PathBuf>>,
|
||||
}
|
||||
|
||||
impl Theme {
|
||||
/// The directories the index forgot to list: GTK still finds icons there, and custom themes
|
||||
/// often add `symbolic/ui` without listing it.
|
||||
fn unlisted(&self) -> &[PathBuf] {
|
||||
self.unlisted.get_or_init(|| {
|
||||
let listed: std::collections::HashSet<&PathBuf> = self.listed.iter().collect();
|
||||
self.roots.iter().flat_map(|r| subdirectories(r)).filter(|d| !listed.contains(d)).collect()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A theme and the themes it inherits, in search order.
|
||||
pub struct IconTheme {
|
||||
themes: Vec<Theme>,
|
||||
}
|
||||
|
||||
impl IconTheme {
|
||||
/// `name` and the themes it inherits, then `hicolor`. `None` when no theme in the chain has an
|
||||
/// `index.theme`.
|
||||
pub fn load(name: &str, bases: &[PathBuf]) -> Option<Self> {
|
||||
let mut chain: Vec<String> = vec![name.to_string()];
|
||||
let mut themes = vec![];
|
||||
let mut i = 0;
|
||||
loop {
|
||||
let Some(theme) = chain.get(i).cloned() else {
|
||||
// Every chain ends in hicolor, whether or not the themes before it exist.
|
||||
if chain.iter().any(|t| t == "hicolor") {
|
||||
break;
|
||||
}
|
||||
chain.push("hicolor".into());
|
||||
continue;
|
||||
};
|
||||
i += 1;
|
||||
if !valid_name(&theme) {
|
||||
continue;
|
||||
}
|
||||
let roots: Vec<PathBuf> = bases.iter().map(|b| b.join(&theme)).filter(|r| r.is_dir()).collect();
|
||||
// The first index wins; a theme can still be spread over several base directories.
|
||||
let Some(index) = roots.iter().find_map(|r| read_small(&r.join("index.theme"))) else { continue };
|
||||
let index = String::from_utf8_lossy(&index);
|
||||
let head = section(&index, "Icon Theme");
|
||||
let get = |k: &str| head.iter().find(|(key, _)| *key == k).map(|(_, v)| *v);
|
||||
let mut subdirs: Vec<((i64, i64), &str)> = list(get("Directories"))
|
||||
.chain(list(get("ScaledDirectories")))
|
||||
.filter(|d| !d.split('/').any(|part| part == ".." || part.is_empty()))
|
||||
.take(MAX_DIRS)
|
||||
.map(|d| (distance(§ion(&index, d)), d))
|
||||
.collect();
|
||||
subdirs.sort_by_key(|(key, _)| *key);
|
||||
// Not checked for existence: a theme lists hundreds, and the search stops at a hit.
|
||||
let listed = subdirs.iter().flat_map(|(_, sub)| roots.iter().map(move |r| r.join(sub))).collect();
|
||||
for parent in list(get("Inherits")) {
|
||||
if chain.len() < MAX_THEMES && !chain.iter().any(|t| t == parent) {
|
||||
chain.push(parent.to_string());
|
||||
}
|
||||
}
|
||||
themes.push(Theme { roots, listed, unlisted: std::cell::OnceCell::new() });
|
||||
}
|
||||
(!themes.is_empty()).then_some(Self { themes })
|
||||
}
|
||||
|
||||
/// The first `<icon>.svg` in search order: each theme's listed directories, then its unlisted
|
||||
/// ones, before its parent.
|
||||
pub fn find_svg(&self, icon: &str) -> Option<PathBuf> {
|
||||
if !valid_name(icon) {
|
||||
return None;
|
||||
}
|
||||
let file = format!("{icon}.svg");
|
||||
let hit = |dirs: &[PathBuf]| dirs.iter().map(|d| d.join(&file)).find(|p| p.is_file());
|
||||
self.themes.iter().find_map(|t| hit(&t.listed).or_else(|| hit(t.unlisted())))
|
||||
}
|
||||
}
|
||||
|
||||
/// An SVG's coverage (alpha) as a `px` × `px` mask, the drawing fitted and centred. `None` for an
|
||||
/// unreadable SVG or an absurd size.
|
||||
pub fn rasterize_mask(svg: &[u8], px: u32) -> Option<Vec<u8>> {
|
||||
use resvg::{tiny_skia, usvg};
|
||||
if !(1..=256).contains(&px) {
|
||||
return None;
|
||||
}
|
||||
// Glyphs are paths: no `<image>` is loaded, so an icon can't make us read another file
|
||||
// (`/dev/zero`, a huge picture) or nest an SVG.
|
||||
let opts = usvg::Options {
|
||||
image_href_resolver: usvg::ImageHrefResolver { resolve_data: Box::new(|_, _, _| None), resolve_string: Box::new(|_, _| None) },
|
||||
..usvg::Options::default()
|
||||
};
|
||||
let tree = usvg::Tree::from_data(svg, &opts).ok()?;
|
||||
let size = tree.size();
|
||||
let side = size.width().max(size.height());
|
||||
if !side.is_finite() || side <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let s = px as f32 / side;
|
||||
let (dx, dy) = ((px as f32 - size.width() * s) / 2.0, (px as f32 - size.height() * s) / 2.0);
|
||||
let mut pixmap = tiny_skia::Pixmap::new(px, px)?;
|
||||
resvg::render(&tree, tiny_skia::Transform::from_scale(s, s).post_translate(dx, dy), &mut pixmap.as_mut());
|
||||
Some(pixmap.data().as_chunks::<4>().0.iter().map(|[_, _, _, a]| *a).collect())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A throwaway icon tree: `files` are (path under the base, contents).
|
||||
fn tree(tag: &str, files: &[(&str, &str)]) -> PathBuf {
|
||||
let base = std::env::temp_dir().join(format!("photocraft-icons-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
for (path, text) in files {
|
||||
let p = base.join(path);
|
||||
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
|
||||
std::fs::write(p, text).unwrap();
|
||||
}
|
||||
base
|
||||
}
|
||||
|
||||
const SQUARE: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path d="M4 4h8v8H4z" fill="#2e3436"/></svg>"##;
|
||||
|
||||
#[test]
|
||||
fn icons_come_from_the_theme_then_its_parents_then_hicolor_best_size_first() {
|
||||
let base = tree(
|
||||
"chain",
|
||||
&[
|
||||
("Child/index.theme", "[Icon Theme]\nInherits=Parent, Child\nDirectories=48,16\n[48]\nSize=48\nType=Fixed\n[16]\nSize=16\nType=Fixed\n"),
|
||||
("Child/48/a-symbolic.svg", "big"),
|
||||
// Not in the index: still found, and before the parent's copy.
|
||||
("Child/symbolic/ui/d-symbolic.svg", SQUARE),
|
||||
("Parent/ui/d-symbolic.svg", SQUARE),
|
||||
("Child/16/a-symbolic.svg", SQUARE),
|
||||
("Parent/index.theme", "[Icon Theme]\nDirectories=ui\n[ui]\nSize=16\nMinSize=8\nMaxSize=512\nType=Scalable\n"),
|
||||
("Parent/ui/b-symbolic.svg", SQUARE),
|
||||
("hicolor/index.theme", "[Icon Theme]\nDirectories=s\n[s]\nSize=16\nType=Scalable\n"),
|
||||
("hicolor/s/c-symbolic.svg", SQUARE),
|
||||
],
|
||||
);
|
||||
let theme = IconTheme::load("Child", std::slice::from_ref(&base)).expect("the theme loads");
|
||||
assert_eq!(theme.find_svg("a-symbolic"), Some(base.join("Child/16/a-symbolic.svg")), "16 px before 48 px");
|
||||
assert_eq!(theme.find_svg("b-symbolic"), Some(base.join("Parent/ui/b-symbolic.svg")));
|
||||
assert_eq!(theme.find_svg("c-symbolic"), Some(base.join("hicolor/s/c-symbolic.svg")));
|
||||
assert_eq!(theme.find_svg("d-symbolic"), Some(base.join("Child/symbolic/ui/d-symbolic.svg")), "unlisted, but the theme's own");
|
||||
assert_eq!(theme.find_svg("missing"), None);
|
||||
assert_eq!(theme.find_svg("../Parent/ui/b-symbolic"), None);
|
||||
assert!(IconTheme::load("Nowhere", std::slice::from_ref(&base)).is_some(), "hicolor still answers");
|
||||
assert!(IconTheme::load("Nowhere", &[base.join("empty")]).is_none());
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// Hostile theme files: cycles, escapes and junk sizes end the search, never hang or panic.
|
||||
#[test]
|
||||
fn cyclic_or_hostile_indexes_are_harmless() {
|
||||
let base = tree(
|
||||
"hostile",
|
||||
&[
|
||||
(
|
||||
"A/index.theme",
|
||||
"[Icon Theme]\nInherits=B,../etc,\nDirectories=../../x,,/abs,ok\n[ok]\nSize=99999999999999999999\nScale=-3\nType=Threshold\n",
|
||||
),
|
||||
("A/ok/x.svg", SQUARE),
|
||||
("B/index.theme", "[Icon Theme]\nInherits=A\n"),
|
||||
],
|
||||
);
|
||||
let theme = IconTheme::load("A", std::slice::from_ref(&base)).expect("loads");
|
||||
assert_eq!(theme.find_svg("x"), Some(base.join("A/ok/x.svg")));
|
||||
assert!(IconTheme::load("..", std::slice::from_ref(&base)).is_none());
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distance_follows_the_spec() {
|
||||
assert_eq!(distance(&[("Size", "16"), ("Type", "Fixed")]), (0, 1));
|
||||
assert_eq!(distance(&[("Size", "22"), ("Type", "Fixed")]), (6, 1));
|
||||
assert_eq!(distance(&[("Size", "16"), ("Scale", "2"), ("Type", "Fixed")]), (16, 2));
|
||||
assert_eq!(distance(&[("Size", "32"), ("MinSize", "8"), ("MaxSize", "512"), ("Type", "Scalable")]), (0, 1));
|
||||
assert_eq!(distance(&[("Size", "48"), ("Type", "Scalable")]), (32, 1));
|
||||
assert_eq!(distance(&[("Size", "18")]), (0, 1), "Threshold 2 by default");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masks_are_square_coverage() {
|
||||
let mask = rasterize_mask(SQUARE.as_bytes(), 32).expect("renders");
|
||||
assert_eq!(mask.len(), 32 * 32);
|
||||
assert_eq!(mask[16 * 32 + 16], 255, "inside the square");
|
||||
assert_eq!(mask[0], 0, "outside it");
|
||||
assert!(rasterize_mask(b"not svg", 16).is_none());
|
||||
assert!(rasterize_mask(SQUARE.as_bytes(), 0).is_none());
|
||||
assert!(rasterize_mask(SQUARE.as_bytes(), 100_000).is_none());
|
||||
}
|
||||
|
||||
/// An icon's `<image>` is never loaded: not a device file, not an embedded picture.
|
||||
#[test]
|
||||
fn masks_load_no_images() {
|
||||
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16">
|
||||
<image href="/dev/zero" width="16" height="16"/>
|
||||
<image xlink:href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16'><rect width='16' height='16'/></svg>" width="16" height="16"/>
|
||||
</svg>"##;
|
||||
let mask = rasterize_mask(svg.as_bytes(), 16).expect("renders");
|
||||
assert!(mask.iter().all(|a| *a == 0), "nothing drawn");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base_dirs_follow_xdg() {
|
||||
let var = |k: &str| match k {
|
||||
"HOME" => Some("/home/u".to_string()),
|
||||
"XDG_DATA_DIRS" => Some("/a:rel:/b".to_string()),
|
||||
_ => None,
|
||||
};
|
||||
let dirs = base_dirs(&var);
|
||||
let want: Vec<PathBuf> = ["/home/u/.icons", "/home/u/.local/share/icons", "/a/icons", "/b/icons"].iter().map(PathBuf::from).collect();
|
||||
assert_eq!(dirs, want);
|
||||
assert_eq!(base_dirs(&|_| None), vec![PathBuf::from("/usr/local/share/icons"), PathBuf::from("/usr/share/icons")]);
|
||||
}
|
||||
}
|
||||
@@ -26,11 +26,14 @@ mod appearance;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod apple_events;
|
||||
mod caps_lock;
|
||||
mod caption_style;
|
||||
mod control_server;
|
||||
mod crash_guard;
|
||||
mod cursor;
|
||||
mod file_dialog_probe;
|
||||
mod gpu_startup;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod icon_theme;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod mac_menu;
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -492,6 +495,10 @@ fn main() -> eframe::Result {
|
||||
// The custom title bar's undecorated window must stay borderless, or its caption and
|
||||
// border offset the content and the pointer on some Windows systems (#2246).
|
||||
app.services.window_frame = window_frame::service(cc, custom_titlebar);
|
||||
// Linux: the desktop's caption buttons (GNOME's, KDE's) on the sides its layout puts them.
|
||||
if custom_titlebar {
|
||||
app.services.caption_style = caption_style::service();
|
||||
}
|
||||
// Caps Lock state (X11/Windows/macOS; `None` on native Wayland): the canvas shows the
|
||||
// precise crosshair for painting tools while it is toggled on (#1758).
|
||||
app.services.caps_lock = caps_lock::service(cc);
|
||||
|
||||
+177
-12
@@ -16,13 +16,13 @@ use std::sync::Arc;
|
||||
#[cfg(test)]
|
||||
mod recovery_tests;
|
||||
|
||||
/// Everything File › Open reads: PhotoCraft, Photoshop, OpenRaster and Affinity documents, flat images, and
|
||||
/// Everything File › Open reads: PhotoCraft, Photoshop, OpenRaster, Krita and Affinity documents, flat images, and
|
||||
/// Photoshop brushes (.abr), gradients (.grd), swatches (.aco, .ase) and keyboard shortcut sets
|
||||
/// (.kys), which go to the preset libraries and Edit › Keyboard Shortcuts.
|
||||
const OPEN_EXTS: &[&str] = &[
|
||||
"pcraft", "pdn", "ora", "kra", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm",
|
||||
"pgm", "ppm", "pam", "pfm", "pcx", "fits", "fit", "fts", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf",
|
||||
"abr", "grd", "svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub",
|
||||
"pcraft", "pdn", "ora", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr",
|
||||
"pbm", "pgm", "ppm", "pam", "pfm", "pcx", "dds", "xpm", "fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw",
|
||||
"pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub",
|
||||
];
|
||||
const SVG_EXTS: &[&str] = &["svg", "svgz"];
|
||||
|
||||
@@ -43,7 +43,7 @@ fn open_filters() -> Vec<(&'static str, Vec<String>)> {
|
||||
("All Formats", open_filter_extensions(OPEN_EXTS)),
|
||||
("PhotoCraft", open_filter_extensions(&["pcraft"])),
|
||||
("OpenRaster", open_filter_extensions(&["ora"])),
|
||||
("Krita", open_filter_extensions(&["kra"])),
|
||||
("Krita", open_filter_extensions(&["kra", "krz"])),
|
||||
("SVG", open_filter_extensions(SVG_EXTS)),
|
||||
]
|
||||
}
|
||||
@@ -60,8 +60,13 @@ const SAVE_FILTERS: &[(&str, &[&str])] = &[
|
||||
("WebP", &["webp"]),
|
||||
("TIFF", &["tif"]),
|
||||
("Targa", &["tga"]),
|
||||
// Also in Photoshop's Save As list; ICO is PhotoCraft's own (#4357): up to 256×256, with alpha.
|
||||
("BMP", &["bmp"]),
|
||||
("GIF", &["gif"]),
|
||||
("ICO", &["ico"]),
|
||||
("OpenEXR", &["exr"]),
|
||||
("FITS", &["fits", "fit"]),
|
||||
("DPX", &["dpx"]),
|
||||
];
|
||||
|
||||
/// Non-document files the shell saves (Swatches panel exports): offered alone, so the dialog
|
||||
@@ -77,8 +82,11 @@ fn save_filters(suggested: &str) -> Vec<(String, Vec<String>)> {
|
||||
let mut v: Vec<(String, Vec<String>)> = SAVE_FILTERS.iter().map(|(name, exts)| (name.to_string(), exts.iter().map(|e| e.to_string()).collect())).collect();
|
||||
match v.iter().position(|(_, exts)| exts.contains(&ext)) {
|
||||
Some(i) => {
|
||||
let f = v.remove(i);
|
||||
v.insert(0, f);
|
||||
let (name, mut exts) = v.remove(i);
|
||||
// Windows adds the first type's first extension to a bare name: `.psb`, not `.psd`.
|
||||
exts.retain(|e| *e != ext);
|
||||
exts.insert(0, ext);
|
||||
v.insert(0, (name, exts));
|
||||
}
|
||||
None if !ext.is_empty() => v.insert(0, (ext.to_ascii_uppercase(), vec![ext])),
|
||||
None => {}
|
||||
@@ -86,6 +94,21 @@ fn save_filters(suggested: &str) -> Vec<(String, Vec<String>)> {
|
||||
v
|
||||
}
|
||||
|
||||
/// The name the save dialog starts with. On Windows a visible extension stays put when the user
|
||||
/// picks another type (with File name extensions shown, `image.psd` saved as PhotoCraft stayed
|
||||
/// `image.psd`, #4394), while a bare name gets the chosen type's extension, so there the dialog
|
||||
/// gets the stem and the suggested type (listed first by [`save_filters`]) supplies `.psd`. A
|
||||
/// stem that still has a dot (`scan.jpg.psd`: Windows could keep `scan.jpg`) or an upper-case
|
||||
/// extension (the user's spelling) keeps the whole name. Elsewhere the whole name is kept: the
|
||||
/// macOS panel has no type list, and on Linux rfd doesn't report which type was picked.
|
||||
fn dialog_file_name(suggested: &str, windows: bool) -> Option<String> {
|
||||
let path = Path::new(suggested);
|
||||
let name = path.file_name()?.to_string_lossy().into_owned();
|
||||
let (Some(stem), Some(ext)) = (path.file_stem(), path.extension().and_then(|e| e.to_str())) else { return Some(name) };
|
||||
let bare = windows && Path::new(stem).extension().is_none() && !ext.is_empty() && ext == ext.to_ascii_lowercase();
|
||||
Some(if bare { stem.to_string_lossy().into_owned() } else { name })
|
||||
}
|
||||
|
||||
/// Shows an Open or Save dialog without blocking the event loop (#673, #574): rfd's async dialog
|
||||
/// is created here, on the main thread, with the window as its parent (a sheet on macOS, an owned
|
||||
/// modal window elsewhere), and a helper thread waits for it and hands the answer to the app.
|
||||
@@ -127,8 +150,8 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>,
|
||||
for (name, exts) in save_filters(&suggested) {
|
||||
dialog = dialog.add_filter(name, &exts);
|
||||
}
|
||||
if let Some(name) = Path::new(&suggested).file_name() {
|
||||
dialog = dialog.set_file_name(name.to_string_lossy());
|
||||
if let Some(name) = dialog_file_name(&suggested, cfg!(windows)) {
|
||||
dialog = dialog.set_file_name(name);
|
||||
}
|
||||
if let Some(dir) = Path::new(&suggested).parent().filter(|p| !p.as_os_str().is_empty()) {
|
||||
dialog = dialog.set_directory(dir);
|
||||
@@ -325,6 +348,43 @@ fn image_from_files(paths: &[PathBuf]) -> Option<(u32, u32, Vec<u8>)> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Largest clipboard image payload read on Wayland (1 GiB, a 16K × 16K RGBA BMP), so no real
|
||||
/// screenshot is turned away, while a broken or hostile source can't make us allocate without end.
|
||||
#[cfg(target_os = "linux")]
|
||||
const MAX_CLIPBOARD_IMAGE_BYTES: u64 = 4 * 16384 * 16384;
|
||||
|
||||
/// The clipboard image arboard can't read on Wayland (#2546): arboard 3.6 asks for `image/png`
|
||||
/// only, so a screenshot tool or clipboard manager that offers just `image/jpeg`, `image/bmp` or
|
||||
/// `image/webp` pasted nothing. Reads the other `image/*` types through the same data-control
|
||||
/// protocol arboard uses, and decodes them with our own codecs. `None` off Wayland.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn wayland_clipboard_image() -> Option<(u32, u32, Vec<u8>)> {
|
||||
use std::io::Read;
|
||||
use wl_clipboard_rs::paste::{ClipboardType, MimeType, Seat, get_contents, get_mime_types_ordered};
|
||||
let offers = get_mime_types_ordered(ClipboardType::Regular, Seat::Unspecified).ok()?;
|
||||
image_from_offers(&offers, |mime| {
|
||||
let (pipe, _) = get_contents(ClipboardType::Regular, Seat::Unspecified, MimeType::Specific(mime)).ok()?;
|
||||
let mut bytes = Vec::new();
|
||||
pipe.take(MAX_CLIPBOARD_IMAGE_BYTES + 1).read_to_end(&mut bytes).ok()?;
|
||||
(bytes.len() as u64 <= MAX_CLIPBOARD_IMAGE_BYTES).then_some(bytes)
|
||||
})
|
||||
}
|
||||
|
||||
/// The first clipboard offer, in the source's order, that `fetch` returns as a decodable image,
|
||||
/// upright as RGBA8. Only `image/*` types are fetched, so text offers are never transferred.
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
fn image_from_offers(offers: &[String], mut fetch: impl FnMut(&str) -> Option<Vec<u8>>) -> Option<(u32, u32, Vec<u8>)> {
|
||||
offers.iter().filter(|mime| mime.starts_with("image/")).find_map(|mime| {
|
||||
let bytes = fetch(mime)?;
|
||||
// TGA has no magic number (see `image_from_files`), so trust it only when the type says so.
|
||||
if photocraft_codecs::detect(&bytes)? == photocraft_codecs::Format::Tga && !mime.contains("tga") {
|
||||
return None;
|
||||
}
|
||||
let img = photocraft_codecs::decode(&bytes).ok()?;
|
||||
Some((img.width(), img.height(), img.to_rgba8()))
|
||||
})
|
||||
}
|
||||
|
||||
/// The shell command that starts this install under XWayland, where winit delivers file drops
|
||||
/// (winit 0.30 has none on Wayland, #386). A Flatpak gets the X11 socket only without the Wayland
|
||||
/// one, an AppImage relaunches by its own path, and anything else is `photocraft` on the PATH.
|
||||
@@ -425,8 +485,13 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
|
||||
if let Some(img) = cb.get().file_list().ok().and_then(|paths| image_from_files(&paths)) {
|
||||
return Some(img);
|
||||
}
|
||||
let img = cb.get_image().ok()?;
|
||||
Some((img.width as u32, img.height as u32, img.bytes.into_owned()))
|
||||
if let Ok(img) = cb.get_image() {
|
||||
return Some((img.width as u32, img.height as u32, img.bytes.into_owned()));
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
return wayland_clipboard_image();
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
None
|
||||
})
|
||||
}),
|
||||
load_prefs: Some(Box::new(|| std::fs::read_to_string(prefs_file()?).ok())),
|
||||
@@ -664,11 +729,66 @@ mod tests {
|
||||
.join(" ");
|
||||
assert_eq!(exts, want.split(' ').collect::<Vec<_>>());
|
||||
for (name, ext) in asked.iter().zip(exts) {
|
||||
assert!(save_filters(name)[0].1.iter().any(|e| e == ext), "{name}: {:?}", save_filters(name)[0]);
|
||||
// The first type's first extension is the one Windows adds to the bare name.
|
||||
assert_eq!(save_filters(name)[0].1.first().map(String::as_str), Some(ext), "{name}: {:?}", save_filters(name)[0]);
|
||||
}
|
||||
assert_eq!(save_filters("Untitled")[0].0, "PSD Document", "no extension keeps the default");
|
||||
}
|
||||
|
||||
/// Every flat format Save As offers is one PhotoCraft writes, and ICO is among them (#4357).
|
||||
#[test]
|
||||
fn save_as_offers_only_writable_formats_including_ico() {
|
||||
let layered = ["psd", "psb", "pcraft", "ora"];
|
||||
for (name, exts) in SAVE_FILTERS {
|
||||
for ext in exts.iter().filter(|e| !layered.contains(e)) {
|
||||
let format = photocraft_codecs::from_extension(ext).unwrap_or_else(|| panic!("{name}: .{ext} is no known format"));
|
||||
assert!(format.caps().write, "{name}: .{ext} can't be written");
|
||||
}
|
||||
}
|
||||
for ext in ["ico", "gif", "bmp"] {
|
||||
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");
|
||||
// What Save As writes there: an icon up to 256×256, and a clear error past that.
|
||||
let doc = |side: u32| {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": side, "height": side})).unwrap();
|
||||
s.active().unwrap().doc.clone()
|
||||
};
|
||||
let opts = export_options(&photocraft_ui_egui::ExportSettings::default());
|
||||
let icon = photocraft_io::export(&doc(256), "icon.ico", &opts).unwrap();
|
||||
assert_eq!(icon.bytes[..4], [0, 0, 1, 0], "an ICO header");
|
||||
let err = photocraft_io::export(&doc(300), "icon.ico", &opts).map(|_| ()).unwrap_err().to_string();
|
||||
assert!(err.contains("256"), "{err}");
|
||||
}
|
||||
|
||||
/// #4394: with File name extensions shown, Windows kept `image.psd` when the type was changed to
|
||||
/// PhotoCraft. Given the bare `image`, it adds the extension of whichever type is picked
|
||||
/// (checked by hand with rfd 0.17.2 on Windows 11), defaulting to the first type's.
|
||||
#[test]
|
||||
fn the_windows_save_dialog_gets_a_bare_name_so_the_type_picks_the_extension() {
|
||||
for (suggested, name, first) in [
|
||||
("/pics/image.psd", "image", "psd"),
|
||||
("/pics/image.psb", "image", "psb"),
|
||||
("/pics/image.pcraft", "image", "pcraft"),
|
||||
("/pics/sky.fit", "sky", "fit"),
|
||||
("cat.jpeg", "cat", "jpeg"),
|
||||
("swatches.aco", "swatches", "aco"),
|
||||
] {
|
||||
assert_eq!(dialog_file_name(suggested, true).as_deref(), Some(name), "{suggested}");
|
||||
assert_eq!(save_filters(suggested)[0].1[0], first, "{suggested}");
|
||||
// Elsewhere the dialogs keep the whole name (see `dialog_file_name`).
|
||||
let whole = Path::new(suggested).file_name().unwrap().to_string_lossy().into_owned();
|
||||
assert_eq!(dialog_file_name(suggested, false), Some(whole), "{suggested}");
|
||||
}
|
||||
// Kept whole: a second dot (Windows could keep `scan.jpg`), the user's upper case, no
|
||||
// extension, and a dotfile.
|
||||
for name in ["scan.jpg.psd", "my.photo.psd", "transparent.PNG", "Untitled", ".psd"] {
|
||||
assert_eq!(dialog_file_name(name, true).as_deref(), Some(name));
|
||||
}
|
||||
assert_eq!(dialog_file_name("", true), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_on_waits_for_a_wake_from_another_thread() {
|
||||
let (tx, rx) = std::sync::mpsc::channel::<std::task::Waker>();
|
||||
@@ -887,6 +1007,51 @@ mod tests {
|
||||
assert!(image_from_files(&[bad]).is_none());
|
||||
}
|
||||
|
||||
fn encoded(format: photocraft_codecs::Format, w: u32, h: u32) -> Vec<u8> {
|
||||
let img = Image::from_u8(w, h, ChannelLayout::Rgba, [255u8, 0, 0, 255].repeat((w * h) as usize)).unwrap();
|
||||
photocraft_codecs::encode(&img, format, &EncodeOptions::default()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_png_clipboard_images_paste() {
|
||||
// #2546: a screenshot tool that offers only image/bmp or image/jpeg pasted nothing.
|
||||
let bmp = encoded(photocraft_codecs::Format::Bmp, 3, 2);
|
||||
let offers = vec!["text/plain".to_string(), "image/bmp".to_string()];
|
||||
let mut asked = Vec::new();
|
||||
let got = image_from_offers(&offers, |mime| {
|
||||
asked.push(mime.to_string());
|
||||
Some(bmp.clone())
|
||||
});
|
||||
assert_eq!(got, Some((3, 2, [255u8, 0, 0, 255].repeat(6))));
|
||||
assert_eq!(asked, ["image/bmp"], "text offers are never fetched");
|
||||
|
||||
let jpeg = encoded(photocraft_codecs::Format::Jpeg, 16, 8);
|
||||
let offers = vec!["image/jpeg".to_string()];
|
||||
assert_eq!(image_from_offers(&offers, |_| Some(jpeg.clone())).map(|(w, h, _)| (w, h)), Some((16, 8)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undecodable_clipboard_offers_fall_through_to_the_next() {
|
||||
let bmp = encoded(photocraft_codecs::Format::Bmp, 2, 2);
|
||||
let cut = encoded(photocraft_codecs::Format::Png, 8, 8)[..20].to_vec();
|
||||
let offers: Vec<String> = ["image/webp", "image/png", "image/x-tga", "image/bmp"].map(String::from).to_vec();
|
||||
let got = image_from_offers(&offers, |mime| match mime {
|
||||
"image/webp" => None,
|
||||
"image/png" => Some(cut.clone()),
|
||||
"image/x-tga" => Some(b"not an image".to_vec()),
|
||||
_ => Some(bmp.clone()),
|
||||
});
|
||||
assert_eq!(got.map(|(w, h, _)| (w, h)), Some((2, 2)));
|
||||
assert!(image_from_offers(&offers[..3], |_| Some(cut.clone())).is_none());
|
||||
assert!(image_from_offers(&[], |_| Some(bmp.clone())).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipboard_tga_is_trusted_only_by_its_type() {
|
||||
assert_eq!(image_from_offers(&["image/x-tga".to_string()], |_| Some(tga_1x1())), Some((1, 1, vec![255, 0, 0, 255])));
|
||||
assert!(image_from_offers(&["image/x-unknown".to_string()], |_| Some(tga_1x1())).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tga_is_trusted_only_with_its_extension() {
|
||||
let dir = temp("tga");
|
||||
|
||||
@@ -10,5 +10,5 @@
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 18a6 6 0 0 0 0-12v12z" />
|
||||
<path d="M12 2a10 10 0 0 0 0 20z" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 285 B After Width: | Height: | Size: 317 B |
@@ -0,0 +1,19 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="g" x1="3" y1="0" x2="20" y2="0" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="currentColor" stop-opacity="0.9" />
|
||||
<stop offset="100%" stop-color="currentColor" stop-opacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="18" height="16" x="3" y="4" rx="2" fill="url(#g)" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 549 B |
@@ -20,6 +20,15 @@ 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.
|
||||
|
||||
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
|
||||
that exact profile. Untagged composites use and embed PhotoCraft's synthetic coated CMYK profile;
|
||||
invalid, non-CMYK or unusable embedded profiles are rejected before compositing. TIFF retains
|
||||
8/16/32f depth and straight alpha; JPEG reduces to 8-bit with a warning and mattes transparency
|
||||
over zero-ink white. A plain single raster layer retains its native samples. RGB-only formats use
|
||||
the RGB composite directly. This RGB composite roundtrip does **not** preserve original ink
|
||||
separations, black generation or native CMYK blending, and is not a claim of Photoshop parity.
|
||||
|
||||
## Implemented decode limits
|
||||
|
||||
`DecodeOptions::default()` applies `Limits` before decoded pixel allocation:
|
||||
|
||||
@@ -133,3 +133,24 @@ sync_github_name = true
|
||||
|
||||
[people.lucAmbr0]
|
||||
sync_github_name = true
|
||||
|
||||
[people.Hevarh1]
|
||||
sync_github_name = true
|
||||
|
||||
[people.dotCooCoo]
|
||||
real_name = "Robert Lee"
|
||||
sync_github_name = true
|
||||
|
||||
[people.deadmeu]
|
||||
sync_github_name = true
|
||||
|
||||
[people.megaDon101]
|
||||
display_name = "megadon"
|
||||
sync_github_name = false
|
||||
|
||||
[people.sssehnsuchttt]
|
||||
sync_github_name = true
|
||||
|
||||
[people.VanTan666]
|
||||
display_name = "VanTan"
|
||||
sync_github_name = false
|
||||
@@ -16,7 +16,7 @@ Affinity files.
|
||||
| Artboards (legacy `ABEn`, or current `.af` `phrp`/`aprp` properties, including converted curves), with name, solid background, any transform | artboards (nested ones as groups with a vector mask; rotated/curved boards use their bounding rectangle) |
|
||||
| Layers (`Scop`), groups (`Grup`), pass-through or isolated | groups (pass-through or Normal) |
|
||||
| Curves (`PCrv`): cubic subpaths, closed flags, live corners (`CnrD`) | shape layers (multi-subpath curves fill even-odd) |
|
||||
| Rectangles with corner radii (relative or absolute), ellipses, polygons (smooth too), stars, square stars, pies, triangles, trapezoids, clouds, hearts, cogs, rounded and elliptical callouts, arrows, double stars, tears, crescents, diamonds and circular segments | shape layers |
|
||||
| Rectangles with corner radii (relative or absolute, locked or per corner) and round or straight corners, ellipses, polygons (smooth too), stars, square stars, pies, triangles, trapezoids, clouds, hearts, cogs, rounded and elliptical callouts, arrows, double stars, tears, crescents, diamonds and circular segments | shape layers |
|
||||
| Compound shapes (`Comp`, add and subtract) | one even-odd shape |
|
||||
| A shape or curve with children clips them | group with the outline as vector mask, the shape at its bottom |
|
||||
| Vector masks (`AdCh` curves and shapes) | vector masks |
|
||||
@@ -34,7 +34,7 @@ Affinity files.
|
||||
Everything else is reported in the import warnings, one line per kind with a count, never
|
||||
dropped silently: layer effects, adjustment layers and live filters, brush and pressure strokes,
|
||||
transparency gradients, fill opacity, bitmap fills, master pages, unrecognized
|
||||
parametric shapes (imported as their bounding ellipses), corner types other than round, stars with
|
||||
parametric shapes (imported as their bounding ellipses), corner types other than round or straight, stars with
|
||||
rounded points, several fills or strokes on one object (one of each is used), strokes behind the
|
||||
fill, outlined or scaled text, text fields such as page numbers, frames with columns or a curved
|
||||
outline, grey/Lab/32-bit pixels, CMYK and Lab colour (converted to RGB without the document's
|
||||
|
||||
@@ -51,28 +51,44 @@ pub(crate) fn shape(r: &mut Reader, shape: ObjId, b: [f64; 4], world: Affine) ->
|
||||
let class = r.s.class(shape)?;
|
||||
let sub = match &class.0.to_be_bytes() {
|
||||
b"ShNR" | b"ShRR" => {
|
||||
let rounded = matches!(last(r, shape, b"CTyp"), Some(Value::Array(a)) if !a.is_empty());
|
||||
let radius = match last(r, shape, b"ShCR") {
|
||||
Some(Value::Floats(v)) => v.first().copied().filter(|f| f.is_finite() && *f > 0.0),
|
||||
_ => None,
|
||||
let types = match last(r, shape, b"CTyp") {
|
||||
Some(Value::Array(a)) => a.iter().map(|t| if let Value::Enum { id, .. } = t { Some(*id) } else { None }).collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
if let (true, Some(f)) = (rounded, radius) {
|
||||
if let Some(Value::Array(types)) = last(r, shape, b"CTyp")
|
||||
&& types.iter().any(|t| !matches!(t, Value::Enum { id: 0 | 4, .. }))
|
||||
{
|
||||
r.warn("rectangles with straight, concave or other corner types (imported with round corners)");
|
||||
}
|
||||
let (crx, cry) = if r.s.bool(shape, b"AbSz") == Some(true) {
|
||||
// An absolute radius is in document pixels, whatever the node's scale.
|
||||
let [a, b2, c, d, ..] = world.0;
|
||||
let (sx, sy) = ((a * a + b2 * b2).sqrt(), (c * c + d * d).sqrt());
|
||||
if sx > 0.0 && sy > 0.0 { (f / sx, f / sy) } else { (0.0, 0.0) }
|
||||
} else {
|
||||
(f * w.min(h), f * w.min(h))
|
||||
};
|
||||
rounded_rect(b, crx.min(w / 2.0), cry.min(h / 2.0))
|
||||
} else {
|
||||
let radii = match last(r, shape, b"ShCR") {
|
||||
Some(Value::Floats(v)) => v.iter().map(|f| if f.is_finite() && *f > 0.0 { *f } else { 0.0 }).collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
// Corners follow the first one (top left) unless they were unlocked; then each has
|
||||
// its own radius and type, clockwise from the top left.
|
||||
let locked = !matches!(last(r, shape, b"Lock"), Some(Value::Bool(false)));
|
||||
let corners: [(f64, Option<u16>); 4] = std::array::from_fn(|i| {
|
||||
let i = if locked { 0 } else { i };
|
||||
(radii.get(i).copied().unwrap_or(0.0), types.get(i).copied().flatten())
|
||||
});
|
||||
if types.is_empty() || corners.iter().all(|(f, _)| *f == 0.0) {
|
||||
polygon([pt(x0, y0), pt(x1, y0), pt(x1, y1), pt(x0, y1)])?
|
||||
} else {
|
||||
if corners.iter().any(|(f, t)| *f > 0.0 && !matches!(t, Some(0 | 1 | 4))) {
|
||||
r.warn("rectangles with concave or other corner types (imported with round corners)");
|
||||
}
|
||||
let absolute = r.s.bool(shape, b"AbSz") == Some(true);
|
||||
let [a, b2, c, d, ..] = world.0;
|
||||
let (sx, sy) = ((a * a + b2 * b2).sqrt(), (c * c + d * d).sqrt());
|
||||
cornered_rect(
|
||||
b,
|
||||
corners.map(|(f, t)| {
|
||||
let (crx, cry) = if !absolute {
|
||||
(f * w.min(h), f * w.min(h))
|
||||
} else if sx > 0.0 && sy > 0.0 {
|
||||
// An absolute radius is in document pixels, whatever the node's scale.
|
||||
(f / sx, f / sy)
|
||||
} else {
|
||||
(0.0, 0.0)
|
||||
};
|
||||
(crx, cry, t == Some(1))
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
b"ShpE" => ellipse(cx, cy, rx, ry),
|
||||
@@ -115,13 +131,21 @@ pub(crate) fn shape(r: &mut Reader, shape: ObjId, b: [f64; 4], world: Affine) ->
|
||||
}))?
|
||||
}
|
||||
b"ShPi" => {
|
||||
let start = num(r, shape, b"AngS").unwrap_or(0.0);
|
||||
let mut end = num(r, shape, b"AngE").unwrap_or(TAU);
|
||||
while end < start {
|
||||
end += TAU;
|
||||
}
|
||||
let raw_start = num(r, shape, b"AngS").unwrap_or(0.0);
|
||||
let raw_end = num(r, shape, b"AngE").unwrap_or(TAU);
|
||||
// Normalize in constant time. Repeatedly adding TAU can hang on finite values
|
||||
// large enough that floating-point addition no longer changes the endpoint.
|
||||
let start = raw_start.rem_euclid(TAU);
|
||||
let delta = raw_end - raw_start;
|
||||
let sweep = if raw_end >= raw_start {
|
||||
delta.min(TAU)
|
||||
} else if delta.is_finite() {
|
||||
delta.rem_euclid(TAU)
|
||||
} else {
|
||||
(raw_end.rem_euclid(TAU) - start).rem_euclid(TAU)
|
||||
};
|
||||
let inner = num(r, shape, b"IRad").unwrap_or(0.0).clamp(0.0, 1.0);
|
||||
pie(cx, cy, rx, ry, start, end.min(start + TAU), inner)
|
||||
pie(cx, cy, rx, ry, start, start + sweep, inner)
|
||||
}
|
||||
b"ShpT" => {
|
||||
let p = num(r, shape, b"Pos ").unwrap_or(0.5);
|
||||
@@ -346,22 +370,31 @@ fn ellipse(cx: f64, cy: f64, rx: f64, ry: f64) -> SubPath {
|
||||
}
|
||||
}
|
||||
|
||||
fn rounded_rect([x0, y0, x1, y1]: [f64; 4], rx: f64, ry: f64) -> SubPath {
|
||||
let (kx, ky) = ((1.0 - KAPPA) * rx, (1.0 - KAPPA) * ry);
|
||||
SubPath {
|
||||
start: pt(x0 + rx, y0),
|
||||
segments: vec![
|
||||
[pt(x1 - rx, y0), pt(x1 - rx, y0), pt(x1 - rx, y0)],
|
||||
[pt(x1 - kx, y0), pt(x1, y0 + ky), pt(x1, y0 + ry)],
|
||||
[pt(x1, y1 - ry), pt(x1, y1 - ry), pt(x1, y1 - ry)],
|
||||
[pt(x1, y1 - ky), pt(x1 - kx, y1), pt(x1 - rx, y1)],
|
||||
[pt(x0 + rx, y1), pt(x0 + rx, y1), pt(x0 + rx, y1)],
|
||||
[pt(x0 + kx, y1), pt(x0, y1 - ky), pt(x0, y1 - ry)],
|
||||
[pt(x0, y0 + ry), pt(x0, y0 + ry), pt(x0, y0 + ry)],
|
||||
[pt(x0, y0 + ky), pt(x0 + kx, y0), pt(x0 + rx, y0)],
|
||||
],
|
||||
closed: true,
|
||||
/// A rectangle whose corners (top left, top right, bottom right, bottom left) are each rounded,
|
||||
/// or cut straight across, by their (horizontal, vertical) radii. Two radii that don't fit along
|
||||
/// one side shrink in proportion until they do.
|
||||
fn cornered_rect([x0, y0, x1, y1]: [f64; 4], corners: [(f64, f64, bool); 4]) -> SubPath {
|
||||
let fit = |r: f64, pair: f64, side: f64| if pair > side { side * (r / pair) } else { r };
|
||||
let [tl, tr, br, bl] = corners;
|
||||
let (w, h) = (x1 - x0, y1 - y0);
|
||||
let rx = [fit(tl.0, tl.0 + tr.0, w), fit(tr.0, tl.0 + tr.0, w), fit(br.0, bl.0 + br.0, w), fit(bl.0, bl.0 + br.0, w)];
|
||||
let ry = [fit(tl.1, tl.1 + bl.1, h), fit(tr.1, tr.1 + br.1, h), fit(br.1, tr.1 + br.1, h), fit(bl.1, tl.1 + bl.1, h)];
|
||||
let (kx, ky) = (rx.map(|r| (1.0 - KAPPA) * r), ry.map(|r| (1.0 - KAPPA) * r));
|
||||
// (where the corner leaves the previous side, its two handles, where it joins the next side)
|
||||
let arcs = [
|
||||
[pt(x1 - rx[1], y0), pt(x1 - kx[1], y0), pt(x1, y0 + ky[1]), pt(x1, y0 + ry[1])],
|
||||
[pt(x1, y1 - ry[2]), pt(x1, y1 - ky[2]), pt(x1 - kx[2], y1), pt(x1 - rx[2], y1)],
|
||||
[pt(x0 + rx[3], y1), pt(x0 + kx[3], y1), pt(x0, y1 - ky[3]), pt(x0, y1 - ry[3])],
|
||||
[pt(x0, y0 + ry[0]), pt(x0, y0 + ky[0]), pt(x0 + kx[0], y0), pt(x0 + rx[0], y0)],
|
||||
];
|
||||
let mut segments = Vec::with_capacity(8);
|
||||
for ([s, c1, c2, e], straight) in arcs.into_iter().zip([tr.2, br.2, bl.2, tl.2]) {
|
||||
segments.push([s, s, s]);
|
||||
if s != e {
|
||||
segments.push(if straight { [e, e, e] } else { [c1, c2, e] });
|
||||
}
|
||||
}
|
||||
SubPath { start: pt(x0 + rx[0], y0), segments, closed: true }
|
||||
}
|
||||
|
||||
/// Elliptical arc from angle `a0` to `a1` (radians, measured y-up as Affinity's pie angles are),
|
||||
|
||||
@@ -349,3 +349,113 @@ mod archive {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mod shapes {
|
||||
use crate::container::Limits;
|
||||
use crate::model::{Document, Kind, Path, Point, SubPath};
|
||||
use crate::stream::Tag;
|
||||
use crate::synth::{self, F, Method, tag};
|
||||
|
||||
fn shape(id: u32, class: &[u8; 4], parameters: Vec<(Tag, F)>) -> F {
|
||||
F::Def(
|
||||
id,
|
||||
vec![tag(b"ShpN")],
|
||||
vec![(tag(b"ShpB"), F::F64s(vec![0.0, 0.0, 10.0, 20.0])), (tag(b"Shpe"), F::Def(id + 10_000, vec![tag(class)], parameters))],
|
||||
)
|
||||
}
|
||||
|
||||
/// Reads a document whose one page holds the object `top` from `definitions`, on a thread of
|
||||
/// its own, and fails the test when reading takes more than 20 s.
|
||||
fn read(definitions: Vec<F>, top: u32) -> Document {
|
||||
let data = synth::stream(&[
|
||||
(tag(b"Defs"), F::Shared(definitions)),
|
||||
(
|
||||
tag(b"DocR"),
|
||||
F::Def(
|
||||
1,
|
||||
vec![tag(b"DocN")],
|
||||
vec![(
|
||||
tag(b"Chld"),
|
||||
F::Shared(vec![F::Def(
|
||||
2,
|
||||
vec![tag(b"Sprd")],
|
||||
vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 400.0, 300.0])), (tag(b"Chld"), F::Shared(vec![F::Ref(top)]))],
|
||||
)]),
|
||||
)],
|
||||
),
|
||||
),
|
||||
]);
|
||||
let bytes = synth::container(&[("doc.dat", &data, Method::Zlib)], None);
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || tx.send(crate::model::read(&bytes, Limits::default())).ok());
|
||||
rx.recv_timeout(std::time::Duration::from_secs(20)).unwrap_or_else(|_| panic!("reading the document did not finish")).unwrap()
|
||||
}
|
||||
|
||||
fn path(document: &Document) -> &Path {
|
||||
match &document.spreads[0].nodes[0].kind {
|
||||
Kind::Shape { path, .. } => path,
|
||||
other => panic!("expected shape, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pie_angles_with_huge_finite_values_do_not_loop_or_emit_nonfinite_points() {
|
||||
let document = read(vec![shape(100, b"ShPi", vec![(tag(b"AngS"), F::F64(1.0e308)), (tag(b"AngE"), F::F64(-1.0e308))])], 100);
|
||||
for subpath in &path(&document).subpaths {
|
||||
assert!(subpath.start.x.is_finite() && subpath.start.y.is_finite());
|
||||
assert!(subpath.segments.len() <= 20);
|
||||
assert!(subpath.segments.iter().flatten().all(|point| point.x.is_finite() && point.y.is_finite()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrapped_pie_angles_keep_the_same_geometry_as_the_positive_span() {
|
||||
let make = |end| read(vec![shape(100, b"ShPi", vec![(tag(b"AngS"), F::F64(0.0)), (tag(b"AngE"), F::F64(end))])], 100);
|
||||
assert_eq!(path(&make(-std::f64::consts::FRAC_PI_2)), path(&make(3.0 * std::f64::consts::FRAC_PI_2)));
|
||||
}
|
||||
|
||||
/// A 10×20 rectangle (`ShNR`) with the given parameters, read back as its outline.
|
||||
fn rectangle(parameters: Vec<(Tag, F)>) -> (SubPath, Vec<String>) {
|
||||
let node =
|
||||
F::Def(3, vec![tag(b"ShpN")], vec![(tag(b"ShpB"), F::F64s(vec![0.0, 0.0, 10.0, 20.0])), (tag(b"Shpe"), F::Def(4, vec![tag(b"ShNR")], parameters))]);
|
||||
let spread = F::Def(2, vec![tag(b"Sprd")], vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 400.0, 300.0])), (tag(b"Chld"), F::Shared(vec![node]))]);
|
||||
let data = synth::stream(&[(tag(b"DocR"), F::Def(1, vec![tag(b"DocN")], vec![(tag(b"Chld"), F::Shared(vec![spread]))]))]);
|
||||
let doc = crate::read(&synth::container(&[("doc.dat", &data, Method::Zlib)], None), Limits::default()).unwrap();
|
||||
let Kind::Shape { path, .. } = &doc.spreads[0].nodes[0].kind else { panic!("expected a shape") };
|
||||
(path.subpaths[0].clone(), doc.warnings)
|
||||
}
|
||||
|
||||
/// `CTyp`: an array of corner-type enums (top left, top right, bottom right, bottom left).
|
||||
fn corner_types(ids: [u16; 4]) -> F {
|
||||
let mut raw = vec![0xaa];
|
||||
raw.extend(tag(b"CTyp").0.to_le_bytes());
|
||||
raw.extend(4u32.to_le_bytes());
|
||||
raw.extend(0u16.to_le_bytes());
|
||||
raw.extend(ids.iter().flat_map(|id| id.to_le_bytes()));
|
||||
F::Raw(raw)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unlocked_rectangle_corners_keep_their_own_radius_and_type() {
|
||||
let p = |x: f64, y: f64| Point { x, y };
|
||||
// Relative radii scale by 10. Round 5, round 1, straight 3, none.
|
||||
let corners = |lock: Option<bool>| {
|
||||
let mut fields = vec![(tag(b"ShCR"), F::F64s(vec![0.5, 0.1, 0.3, 0.0])), (tag(b"CTyp"), corner_types([0, 0, 1, 4]))];
|
||||
fields.extend(lock.map(|l| (tag(b"Lock"), F::Bool(l))));
|
||||
rectangle(fields)
|
||||
};
|
||||
|
||||
let (sub, warnings) = corners(Some(false));
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
assert_eq!(sub.start, p(5.0, 0.0));
|
||||
let ends: Vec<Point> = sub.segments.iter().map(|s| s[2]).collect();
|
||||
assert_eq!(ends, [p(9.0, 0.0), p(10.0, 1.0), p(10.0, 17.0), p(7.0, 20.0), p(0.0, 20.0), p(0.0, 5.0), p(5.0, 0.0)]);
|
||||
assert_eq!(sub.segments[3], [p(7.0, 20.0); 3], "the straight corner is a line");
|
||||
assert_ne!(sub.segments[1][0], p(9.0, 0.0), "the round corner is a curve");
|
||||
|
||||
// Locked (the default): every corner follows the top left one.
|
||||
let (sub, _) = corners(None);
|
||||
assert_eq!(sub.segments.len(), 8);
|
||||
assert_eq!(sub.segments[3][2], p(5.0, 20.0));
|
||||
}
|
||||
}
|
||||
@@ -427,7 +427,7 @@ pub(crate) fn effect_reach(e: &GalleryEffect) -> i32 {
|
||||
F::Crosshatch => (g("strokeLength") / 3.0) as i32 + gs(1.0) + 2,
|
||||
F::DarkStrokes => 9,
|
||||
F::InkOutlines => (g("strokeLength") / 2.0) as i32 + 3,
|
||||
F::Spatter => g("sprayRadius").ceil() as i32 + 2,
|
||||
F::Spatter => (g("sprayRadius") * 1.5).ceil() as i32 + 2,
|
||||
F::SprayedStrokes => g("sprayRadius").ceil() as i32 + (g("strokeLength") / 2.0) as i32 + 3,
|
||||
F::SumiE => gs(g("strokeWidth") * 0.2) + (g("strokeWidth") / 2.0) as i32 + 2,
|
||||
F::Glass => (g("distortion") * 2.0).ceil() as i32 + 2,
|
||||
@@ -768,9 +768,26 @@ fn apply(e: &GalleryEffect, win: &mut Win, ctx: &Ctx) {
|
||||
win.c[i] = map(win.c[i], |v| (v + (1.0 - v) * lt) * (1.0 - dk) * (1.0 - clamp01(ink)));
|
||||
}
|
||||
}
|
||||
F::Spatter | F::SprayedStrokes => {
|
||||
F::Spatter => {
|
||||
// Smoothness smooths the random displacement: a larger feature size and, as with any
|
||||
// averaged noise, a smaller reach. Low smoothness sprays specks out to the full
|
||||
// radius; high smoothness leaves a torn but attached, hard edge (what Photoshop
|
||||
// shows at Smoothness 15).
|
||||
let rad = g("sprayRadius");
|
||||
let sc = if e.filter == F::Spatter { 0.6 + g("smoothness") * 0.25 } else { 1.2 };
|
||||
let feature = 1.0 + g("smoothness") * 0.35;
|
||||
let reach = (rad * 1.2 / feature).min(rad);
|
||||
let src = Win { r: win.r, w, h, c: win.c.clone() };
|
||||
for i in 0..win.c.len() {
|
||||
let (x, y) = win.xy(i);
|
||||
let (fx, fy) = (x as f32 / feature, y as f32 / feature);
|
||||
let dx = (fbm(fx, fy, 91) - 0.5) * 3.0 * reach;
|
||||
let dy = (fbm(fx, fy, 92) - 0.5) * 3.0 * reach;
|
||||
win.c[i] = src.sample(x as f32 + 0.5 + dx, y as f32 + 0.5 + dy);
|
||||
}
|
||||
}
|
||||
F::SprayedStrokes => {
|
||||
let rad = g("sprayRadius");
|
||||
let sc = 1.2;
|
||||
let src = Win { r: win.r, w, h, c: win.c.clone() };
|
||||
for i in 0..win.c.len() {
|
||||
let (x, y) = win.xy(i);
|
||||
@@ -779,9 +796,7 @@ fn apply(e: &GalleryEffect, win: &mut Win, ctx: &Ctx) {
|
||||
let dy = (vnoise(fx, fy, 92) - 0.5) * 2.0 * rad;
|
||||
win.c[i] = src.sample(x as f32 + 0.5 + dx, y as f32 + 0.5 + dy);
|
||||
}
|
||||
if e.filter == F::SprayedStrokes {
|
||||
line_blur3(&mut win.c, w, h, direction(e.choice("strokeDirection")), (g("strokeLength") / 2.0) as usize);
|
||||
}
|
||||
line_blur3(&mut win.c, w, h, direction(e.choice("strokeDirection")), (g("strokeLength") / 2.0) as usize);
|
||||
}
|
||||
F::SumiE => {
|
||||
let sw = g("strokeWidth");
|
||||
|
||||
@@ -662,6 +662,26 @@ mod tests {
|
||||
assert!(err < 0.12, "mean error {err}");
|
||||
}
|
||||
|
||||
/// A vertical sky gradient (each row one colour) filled at every adaptation level stays the
|
||||
/// gradient: no tinted blotch where the copied rows' colours were shifted per channel.
|
||||
#[test]
|
||||
fn color_adaptation_leaves_a_smooth_gradient_untinted() {
|
||||
let (w, h) = (120, 60);
|
||||
let row = |y: usize| {
|
||||
let t = y as f32 / (h - 1) as f32;
|
||||
[0.27 + 0.45 * t, 0.53 + 0.32 * t, 0.86 + 0.1 * t]
|
||||
};
|
||||
let img: Vec<f32> = (0..w * h).flat_map(|i| row(i / w)).collect();
|
||||
let hole: Vec<bool> = (0..w * h).map(|i| (50..70).contains(&(i % w)) && (20..40).contains(&(i / w))).collect();
|
||||
let src = vec![true; w * h];
|
||||
for level in ["none", "default", "high", "veryHigh"] {
|
||||
let out = fill(w, h, 3, &img, &hole, &src, &FillOptions { color_adaptation: color_level(level), ..Default::default() });
|
||||
let (out, img) = (&out, &img);
|
||||
let worst = (0..w * h).filter(|i| hole[*i]).flat_map(|i| (0..3).map(move |c| (out[i * 3 + c] - img[i * 3 + c]).abs())).fold(0.0f32, f32::max);
|
||||
assert!(worst < 3.0 / 255.0, "{level}: off by {}", worst * 255.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// I.i.d. noise: every value occurs once, so a filled pixel equal to an image pixel was copied.
|
||||
fn noise(w: usize, h: usize, seed: u64) -> Vec<f32> {
|
||||
let mut z = seed;
|
||||
|
||||
@@ -18,7 +18,8 @@ pub fn magic_erase(target: &mut Surface, region: &Region, amount: f32, selection
|
||||
let amount = if amount.is_finite() { amount.clamp(0.0, 1.0) } else { 0.0 };
|
||||
let bbox = region.bbox;
|
||||
let bw = bbox.width() as usize;
|
||||
if bbox.is_empty() || amount <= 0.0 || region.mask.len() != bw * bbox.height() as usize {
|
||||
let Some(pixels) = bw.checked_mul(bbox.height() as usize) else { return Rect::EMPTY };
|
||||
if bbox.is_empty() || amount <= 0.0 || region.mask.len() != pixels {
|
||||
return Rect::EMPTY;
|
||||
}
|
||||
let fmt = target.format();
|
||||
@@ -147,6 +148,16 @@ mod tests {
|
||||
assert_eq!(l.rgba(5, 5), [1.0, 1.0, 1.0, 1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_region_mask_is_rejected_without_overflow() {
|
||||
let mut s = Surface::new(PixelFormat::RGBA8);
|
||||
s.fill_rect(Rect::new(0, 0, 1, 1), &[1.0, 0.0, 0.0, 1.0]);
|
||||
let before = s.pixel(0, 0);
|
||||
let oversized = Region { bbox: Rect::new(i32::MIN, i32::MIN, i32::MAX, i32::MAX), mask: vec![255] };
|
||||
assert_eq!(magic_erase(&mut s, &oversized, 1.0, None, None), Rect::EMPTY);
|
||||
assert_eq!(s.pixel(0, 0), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn degenerate_inputs_do_nothing() {
|
||||
let mut s = Surface::new(PixelFormat::RGBA8);
|
||||
|
||||
@@ -508,6 +508,9 @@ struct Solver<'a> {
|
||||
ctl: photocraft_raster::Interrupt<'a>,
|
||||
}
|
||||
|
||||
/// A channel deviation under which a patch counts as flat for [`fit`] (about 5 levels of 255).
|
||||
const FLAT: f32 = 0.02;
|
||||
|
||||
/// Gain and bias of one channel for target moments `(Σt, Σt²)` and source moments `(Σs, Σs²)`
|
||||
/// over `n` samples, within the limits.
|
||||
#[inline]
|
||||
@@ -516,7 +519,20 @@ pub(crate) fn fit(a: Adapt, n: f32, st: f32, stt: f32, ss: f32, sss: f32) -> (f3
|
||||
let vt = (stt / n - mt * mt).max(0.0);
|
||||
let vs = (sss / n - ms * ms).max(0.0);
|
||||
let g = if vs > 1e-10 { (vt / vs).sqrt() } else { 1.0 };
|
||||
let g = g.clamp(a.gain.0, a.gain.1);
|
||||
let (mut lo, mut hi) = a.gain;
|
||||
// Nearly flat patches of about the same colour (their means within the bias of each other):
|
||||
// the ratio of their deviations is noise, so match the mean first, keeping the gain where the
|
||||
// bias can still bring the source's mean to the target's. Gained about zero, a gain at its
|
||||
// limit moved bright means further than the bias could bring back, and each channel by a
|
||||
// different amount: a tinted blotch in a smooth gradient. Textured patches, and flat ones
|
||||
// whose colours differ more (Content-Aware Move's Color), are fitted as before.
|
||||
if vt.max(vs) < FLAT * FLAT && (mt - ms).abs() <= a.bias && ms > 1e-6 {
|
||||
let (glo, ghi) = ((mt - a.bias) / ms, (mt + a.bias) / ms);
|
||||
if glo.max(lo) <= ghi.min(hi) {
|
||||
(lo, hi) = (glo.max(lo), ghi.min(hi));
|
||||
}
|
||||
}
|
||||
let g = g.clamp(lo, hi);
|
||||
let b = (mt - g * ms).clamp(-a.bias, a.bias);
|
||||
(g, b)
|
||||
}
|
||||
@@ -1959,6 +1975,21 @@ fn min_cut(across: usize, along: usize, err: impl Fn(usize, usize) -> f32) -> Ve
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The mean is matched first: on a nearly flat bright patch with a noisy deviation ratio the
|
||||
/// gain stays where the bias can still reach the target's mean.
|
||||
#[test]
|
||||
fn fit_matches_the_mean_before_the_contrast() {
|
||||
let a = Adapt { gain: (0.9, 1.1), bias: 0.04 };
|
||||
// Target mean 0.70, source mean 0.70; deviations 0.01 vs 0.004 (ratio 2.5).
|
||||
let n = 25.0;
|
||||
let (mt, ms, sdt, sds) = (0.70f32, 0.70f32, 0.01f32, 0.004f32);
|
||||
let (g, b) = fit(a, n, mt * n, (sdt * sdt + mt * mt) * n, ms * n, (sds * sds + ms * ms) * n);
|
||||
assert!((g * ms + b - mt).abs() < 1e-5, "mean {} vs {mt} (g {g}, b {b})", g * ms + b);
|
||||
// Where the mean can't be reached anyway, the limits hold as before.
|
||||
let (g, b) = fit(a, n, 0.2 * n, 0.04 * n, 0.9 * n, 0.81 * n);
|
||||
assert!((a.gain.0..=a.gain.1).contains(&g) && b.abs() <= a.bias + 1e-6);
|
||||
}
|
||||
|
||||
/// Smooth gradient plus deterministic fine noise.
|
||||
fn texture(w: usize, h: usize) -> Vec<f32> {
|
||||
let mut rng = Rng::new(7);
|
||||
|
||||
@@ -740,7 +740,7 @@ pub fn kernel(params: &FilterParams, src: &Image, out: Rect, ctx: &Ctx) -> Vec<f
|
||||
FilterParams::Mosaic { cell_size } => stylize::mosaic(src, out, ctx, *cell_size),
|
||||
FilterParams::Emboss { angle, height, amount } => stylize::emboss(src, out, ctx, *angle, *height, *amount),
|
||||
FilterParams::FindEdges => stylize::find_edges(src, out, ctx),
|
||||
FilterParams::Solarize => stylize::per_pixel(src, out, ctx, |v| if v > 0.5 { 1.0 - v } else { v }),
|
||||
FilterParams::Solarize => stylize::solarize(src, out, ctx),
|
||||
FilterParams::Invert => stylize::per_pixel(src, out, ctx, |v| 1.0 - v),
|
||||
FilterParams::Desaturate => stylize::desaturate(src, out, ctx),
|
||||
FilterParams::Twirl { angle } => distort::twirl(src, out, ctx, *angle),
|
||||
@@ -1206,9 +1206,12 @@ fn apply_box_blur(
|
||||
/// otherwise hidden colours leak into feathered edges and alpha-removing filters darken them.
|
||||
fn mix_selection(data: &mut [f32], t: Rect, sel: &Surface, orig: &Image, alpha: bool) {
|
||||
let w = t.width() as usize;
|
||||
// The selection over `t` read once, not a lookup and an allocation per pixel (#4533).
|
||||
let mask = sel.read_region(t);
|
||||
let sc = sel.channels().max(1);
|
||||
for (i, px) in data.chunks_exact_mut(orig.ch.max(1)).enumerate() {
|
||||
let (x, y) = (t.x0 + (i % w) as i32, t.y0 + (i / w) as i32);
|
||||
let k = sel.pixel(x, y)[0].clamp(0.0, 1.0);
|
||||
let k = mask.get(i * sc).copied().unwrap_or(0.0).clamp(0.0, 1.0);
|
||||
if k >= 1.0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -128,12 +128,32 @@ pub(crate) fn edge_of(u: UndefinedAreas) -> Edge {
|
||||
/// Shifts the bounds' content by whole pixels.
|
||||
pub(crate) fn offset(src: &Image, out: Rect, ctx: &Ctx, dx: i32, dy: i32, undefined: UndefinedAreas) -> Vec<f32> {
|
||||
let n = src.ch;
|
||||
let edge = edge_of(undefined);
|
||||
// Subtraction can leave i32 even for valid command parameters. Apply the edge
|
||||
// policy before narrowing: saturating first would change the wrapped pixel.
|
||||
let coordinate = |position: i32, distance: i32, start: i32, end: i32| -> Option<i32> {
|
||||
if start >= end {
|
||||
return None;
|
||||
}
|
||||
let p = i64::from(position) - i64::from(distance);
|
||||
let (start, end) = (i64::from(start), i64::from(end));
|
||||
let mapped = match undefined {
|
||||
UndefinedAreas::Wrap => start + (p - start).rem_euclid(end - start),
|
||||
UndefinedAreas::Repeat => p.clamp(start, end - 1),
|
||||
UndefinedAreas::Transparent if p >= start && p < end => p,
|
||||
UndefinedAreas::Transparent => return None,
|
||||
};
|
||||
i32::try_from(mapped).ok()
|
||||
};
|
||||
let mut res = Vec::with_capacity(out.width() as usize * out.height() as usize * n);
|
||||
for y in out.y0..out.y1 {
|
||||
let sy = coordinate(y, dy, ctx.bounds.y0, ctx.bounds.y1);
|
||||
for x in out.x0..out.x1 {
|
||||
let sx = coordinate(x, dx, ctx.bounds.x0, ctx.bounds.x1);
|
||||
for c in 0..n {
|
||||
res.push(src.get_edge(x - dx, y - dy, c, edge, ctx.bounds));
|
||||
res.push(match (sx, sy) {
|
||||
(Some(sx), Some(sy)) => src.get(sx, sy, c),
|
||||
_ => 0.0,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -186,3 +206,61 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod offset_overflow_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn offset_limits_match_equivalent_small_offsets_for_every_edge_policy() {
|
||||
for bounds in [Rect::new(0, 0, 3, 5), Rect::new(-7, 11, -4, 16)] {
|
||||
let mut src = Image::new(bounds, 4);
|
||||
// Include signed/HDR values and varying alpha; offset must only move samples.
|
||||
for (i, sample) in src.data.iter_mut().enumerate() {
|
||||
*sample = i as f32 * 0.125 - 2.0;
|
||||
}
|
||||
let ctx = Ctx { bounds, mode: photocraft_color::ColorMode::Rgb, alpha: true };
|
||||
for policy in [UndefinedAreas::Wrap, UndefinedAreas::Repeat, UndefinedAreas::Transparent] {
|
||||
for dx in [i32::MIN, i32::MAX, -8, 0, 7] {
|
||||
for dy in [i32::MIN, i32::MAX, -9, 0, 6] {
|
||||
let reduce = |d: i32, size: i32| match policy {
|
||||
UndefinedAreas::Wrap => d.rem_euclid(size),
|
||||
_ => d.clamp(-100, 100),
|
||||
};
|
||||
let (rx, ry) = (reduce(dx, 3), reduce(dy, 5));
|
||||
let mut expected = Vec::new();
|
||||
for y in bounds.y0..bounds.y1 {
|
||||
for x in bounds.x0..bounds.x1 {
|
||||
for c in 0..4 {
|
||||
expected.push(src.get_edge(x - rx, y - ry, c, edge_of(policy), bounds));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(offset(&src, bounds, &ctx, dx, dy, policy), expected, "{bounds:?} {policy:?} {dx} {dy}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_wrap_handles_bounds_at_signed_coordinate_limits() {
|
||||
for start in [i32::MIN, i32::MAX - 3] {
|
||||
let bounds = Rect::new(start, start, start + 3, start + 3);
|
||||
let mut src = Image::new(bounds, 1);
|
||||
src.data = (0..9).map(|i| i as f32).collect();
|
||||
let ctx = Ctx { bounds, mode: photocraft_color::ColorMode::Grayscale, alpha: false };
|
||||
assert_eq!(offset(&src, bounds, &ctx, i32::MIN, i32::MIN, UndefinedAreas::Wrap), vec![8.0, 6.0, 7.0, 2.0, 0.0, 1.0, 5.0, 3.0, 4.0]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_wrap_accepts_minimum_signed_distance() {
|
||||
let bounds = Rect::new(0, 0, 3, 1);
|
||||
let mut src = Image::new(bounds, 1);
|
||||
src.data = vec![0.1, 0.5, 0.9];
|
||||
let ctx = Ctx { bounds, mode: photocraft_color::ColorMode::Grayscale, alpha: false };
|
||||
let got = offset(&src, bounds, &ctx, i32::MIN, 0, UndefinedAreas::Wrap);
|
||||
assert_eq!(got, vec![0.9, 0.1, 0.5]);
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,7 @@ fn composite_area(
|
||||
coverage: impl Fn(i32, i32) -> f32 + Sync,
|
||||
color: impl Fn(i32, i32) -> [f32; 4] + Sync,
|
||||
lock_transparency: bool,
|
||||
inks: Option<[f32; 4]>,
|
||||
) {
|
||||
// Composite tile by tile (in parallel on native); tiles with zero coverage are left untouched,
|
||||
// so their copy-on-write storage (and GPU residency) survives.
|
||||
@@ -93,6 +94,14 @@ fn composite_area(
|
||||
o[3] = d[3];
|
||||
}
|
||||
from_rgba_into(&fmt, o, &mut enc);
|
||||
// A colour picked as CMYK inks replaces fully covered pixels of a CMYK target as it is.
|
||||
if let Some(i) = inks
|
||||
&& fmt.mode == photocraft_color::ColorMode::Cmyk
|
||||
&& blend_mode == BlendMode::Normal
|
||||
&& k >= 1.0
|
||||
{
|
||||
enc[..4].copy_from_slice(&i);
|
||||
}
|
||||
px.copy_from_slice(&enc[..n]);
|
||||
}
|
||||
}
|
||||
@@ -144,6 +153,7 @@ pub fn paint_gradient(
|
||||
c
|
||||
},
|
||||
lock_transparency,
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -179,6 +189,25 @@ pub fn bucket_fill_src(
|
||||
selection: Option<&Surface>,
|
||||
lock_transparency: bool,
|
||||
src: impl Fn(i32, i32) -> [f32; 4] + Sync,
|
||||
) -> bool {
|
||||
bucket_fill_inks(s, area, seed, tolerance, contiguous, anti_alias, opacity, selection, lock_transparency, src, None)
|
||||
}
|
||||
|
||||
/// [`bucket_fill_src`] where `inks` (CMYK, 0..1) are what a fully covered pixel of a CMYK target
|
||||
/// gets instead of `src`'s sRGB stand-in (a colour that was picked as CMYK).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn bucket_fill_inks(
|
||||
s: &mut Surface,
|
||||
area: Rect,
|
||||
seed: (i32, i32),
|
||||
tolerance: f32,
|
||||
contiguous: bool,
|
||||
anti_alias: bool,
|
||||
opacity: f32,
|
||||
selection: Option<&Surface>,
|
||||
lock_transparency: bool,
|
||||
src: impl Fn(i32, i32) -> [f32; 4] + Sync,
|
||||
inks: Option<[f32; 4]>,
|
||||
) -> bool {
|
||||
if !area.contains(seed.0, seed.1) {
|
||||
return false;
|
||||
@@ -186,7 +215,7 @@ pub fn bucket_fill_src(
|
||||
let img = crate::selection::rgba8_image(s, area);
|
||||
let Some(region) = crate::selection::wand_region(&img, area, seed, tolerance, contiguous, anti_alias) else { return false };
|
||||
drop(img);
|
||||
bucket_fill_region_src(s, ®ion, opacity, selection, lock_transparency, src);
|
||||
bucket_fill_region_inks(s, ®ion, opacity, selection, lock_transparency, src, inks);
|
||||
true
|
||||
}
|
||||
|
||||
@@ -199,10 +228,31 @@ pub fn bucket_fill_region_src(
|
||||
selection: Option<&Surface>,
|
||||
lock_transparency: bool,
|
||||
src: impl Fn(i32, i32) -> [f32; 4] + Sync,
|
||||
) {
|
||||
bucket_fill_region_inks(s, region, opacity, selection, lock_transparency, src, None);
|
||||
}
|
||||
|
||||
/// [`bucket_fill_region_src`] with the CMYK `inks` of [`bucket_fill_inks`].
|
||||
pub fn bucket_fill_region_inks(
|
||||
s: &mut Surface,
|
||||
region: &crate::selection::Region,
|
||||
opacity: f32,
|
||||
selection: Option<&Surface>,
|
||||
lock_transparency: bool,
|
||||
src: impl Fn(i32, i32) -> [f32; 4] + Sync,
|
||||
inks: Option<[f32; 4]>,
|
||||
) {
|
||||
// Composite only over the filled region's box.
|
||||
let b = region.bbox;
|
||||
composite_area(s, b, BlendMode::Normal, |x, y| region.at(x, y) * opacity * selection.map_or(1.0, |m| m.sample_channel(x, y, 0)), src, lock_transparency);
|
||||
composite_area(
|
||||
s,
|
||||
b,
|
||||
BlendMode::Normal,
|
||||
|x, y| region.at(x, y) * opacity * selection.map_or(1.0, |m| m.sample_channel(x, y, 0)),
|
||||
src,
|
||||
lock_transparency,
|
||||
inks,
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -115,8 +115,11 @@ pub(crate) fn color_halftone(src: &Image, out: Rect, ctx: &Ctx, max_radius: f32,
|
||||
res
|
||||
}
|
||||
|
||||
/// Crystallize: Voronoi cells around jittered points, each filled with the
|
||||
/// (premultiplied) average colour of its pixels.
|
||||
/// Crystallize: Voronoi cells around jittered points, each filled with the colour at its
|
||||
/// point, as Photoshop's are: cells stay solid, so a black-and-white image (a layer mask
|
||||
/// being distressed) keeps hard edges and its weight instead of greying where cells straddle
|
||||
/// an edge. A cell whose point falls outside the image takes the (premultiplied) average
|
||||
/// colour of its pixels.
|
||||
pub(crate) fn crystallize(src: &Image, out: Rect, ctx: &Ctx, cell_size: f32, seed: u32) -> Vec<f32> {
|
||||
let n = src.ch;
|
||||
let cell = cell_size.max(2.0);
|
||||
@@ -177,6 +180,14 @@ pub(crate) fn crystallize(src: &Image, out: Rect, ctx: &Ctx, cell_size: f32, see
|
||||
continue;
|
||||
}
|
||||
let (cx, cy) = if win.contains(x, y) { owners[i] } else { owner(x, y) };
|
||||
let (sx, sy) = cell_point(cx, cy, cell, seed, origin);
|
||||
let (sx, sy) = (sx.floor() as i32, sy.floor() as i32);
|
||||
if src.rect.contains(sx, sy) && b.contains(sx, sy) {
|
||||
for (c, v) in px.iter_mut().enumerate() {
|
||||
*v = src.get(sx, sy, c);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if cx < c0.0 || cy < c0.1 || cx > c1.0 || cy > c1.1 {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -116,3 +116,147 @@ pub fn object_select(sampler: &dyn Sampler, canvas: Rect, rect: Rect, work_px: u
|
||||
let low = super::clean_mask(&low, lw, lh, 0.1, 0.002);
|
||||
super::finish_region(sampler, window, step, &low, lw, lh, Some((&fg, &bg)))
|
||||
}
|
||||
|
||||
/// Working-resolution budget (pixels) for [`object_at`]: small enough to run while the pointer
|
||||
/// hovers.
|
||||
pub const OBJECT_AT_WORK_PX: usize = 60_000;
|
||||
|
||||
/// Object Selection's Object Finder (hover / click): the object under document point `(x, y)`.
|
||||
///
|
||||
/// A heuristic, not a semantic segmenter (that is #2788): on a working-resolution copy of the
|
||||
/// canvas the area around the point is grown over pixels of similar colour (CIELAB, bounded both
|
||||
/// by the distance to the seed's running mean and by the local edge step), so it stops at the
|
||||
/// object's outline. A region that grows over most of the canvas is the background: nothing is
|
||||
/// found. Otherwise GrabCut refines it inside its padded bounding box (the grown area probable
|
||||
/// foreground, the cursor definite foreground, outside the box definite background), the part
|
||||
/// connected to the cursor is kept and the boundary is re-cut at full resolution, as the
|
||||
/// rectangle mode does.
|
||||
pub fn object_at(sampler: &dyn Sampler, canvas: Rect, x: f32, y: f32, work_px: usize) -> Option<Region> {
|
||||
if canvas.width() < 4 || canvas.height() < 4 || !x.is_finite() || !y.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let (px, py) = (x.floor() as i32, y.floor() as i32);
|
||||
if !canvas.contains(px, py) {
|
||||
return None;
|
||||
}
|
||||
let step = super::scale_for(canvas, work_px.max(1024));
|
||||
let img = sampler.rgb_scaled(canvas, step);
|
||||
let (w, h) = (img.w, img.h);
|
||||
if w < 2 || h < 2 {
|
||||
return None;
|
||||
}
|
||||
// Lightly smoothed, so noise and texture don't stop the growth; edges survive.
|
||||
let lab: Vec<[f32; 3]> = box3(&box3(&img.px, w, h), w, h).iter().map(|c| super::slic::rgb_to_lab(*c)).collect();
|
||||
let sx = (((px - canvas.x0) as usize) / step).min(w - 1);
|
||||
let sy = (((py - canvas.y0) as usize) / step).min(h - 1);
|
||||
let grown = grow_similar(&lab, w, h, sx, sy);
|
||||
let count = grown.iter().filter(|g| **g).count();
|
||||
// Background (or a flat image): no single object under the cursor.
|
||||
if count * 10 > w * h * 6 || count < 4 {
|
||||
return None;
|
||||
}
|
||||
let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0usize, 0usize);
|
||||
for (i, g) in grown.iter().enumerate() {
|
||||
if *g {
|
||||
let (gx, gy) = (i % w, i / w);
|
||||
(x0, y0, x1, y1) = (x0.min(gx), y0.min(gy), x1.max(gx + 1), y1.max(gy + 1));
|
||||
}
|
||||
}
|
||||
let pad = ((x1 - x0).max(y1 - y0) / 6).max(2);
|
||||
let (bx0, by0, bx1, by1) = (x0.saturating_sub(pad), y0.saturating_sub(pad), (x1 + pad).min(w), (y1 + pad).min(h));
|
||||
let mut trimap = vec![BG; w * h];
|
||||
let mut any_bg = false;
|
||||
for yy in 0..h {
|
||||
for xx in 0..w {
|
||||
let i = yy * w + xx;
|
||||
let inside = xx >= bx0 && xx < bx1 && yy >= by0 && yy < by1;
|
||||
trimap[i] = if grown[i] {
|
||||
PR_FG
|
||||
} else if inside {
|
||||
PR_BG
|
||||
} else {
|
||||
any_bg = true;
|
||||
BG
|
||||
};
|
||||
}
|
||||
}
|
||||
if !any_bg {
|
||||
// The box is the whole canvas: the frame is background.
|
||||
for yy in 0..h {
|
||||
for xx in 0..w {
|
||||
if (xx == 0 || yy == 0 || xx + 1 == w || yy + 1 == h) && !(xx == sx && yy == sy) {
|
||||
trimap[yy * w + xx] = BG;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
trimap[sy * w + sx] = FG;
|
||||
let (fg, bg) = grabcut(&img, &mut trimap, 5)?;
|
||||
let low: Vec<bool> = trimap.iter().map(|t| is_fg(*t)).collect();
|
||||
let mut seeds = vec![false; w * h];
|
||||
seeds[sy * w + sx] = true;
|
||||
let low = super::keep_seeded(&low, &seeds, w, h);
|
||||
let low = super::clean_mask(&low, w, h, 0.1, 0.01);
|
||||
if !low.iter().any(|v| *v) {
|
||||
return None;
|
||||
}
|
||||
super::finish_region(sampler, canvas, step, &low, w, h, Some((&fg, &bg)))
|
||||
}
|
||||
|
||||
/// Region growing (4-connected) from `(sx, sy)` over pixels whose CIELAB colour is within a
|
||||
/// distance of the seed area's running mean and whose step from the neighbour it was reached
|
||||
/// from is small, so it stops at edges.
|
||||
fn grow_similar(lab: &[[f32; 3]], w: usize, h: usize, sx: usize, sy: usize) -> Vec<bool> {
|
||||
const MEAN_DIST: f32 = 32.0;
|
||||
const EDGE_STEP: f32 = 10.0;
|
||||
let d = |a: [f32; 3], b: [f32; 3]| ((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt();
|
||||
let mut out = vec![false; w * h];
|
||||
let Some(&seed) = lab.get(sy * w + sx) else { return out };
|
||||
let (mut sum, mut n) = ([seed[0] as f64, seed[1] as f64, seed[2] as f64], 1.0f64);
|
||||
let mut stack = vec![(sx, sy)];
|
||||
out[sy * w + sx] = true;
|
||||
while let Some((x, y)) = stack.pop() {
|
||||
let c = lab[y * w + x];
|
||||
let mean = [(sum[0] / n) as f32, (sum[1] / n) as f32, (sum[2] / n) as f32];
|
||||
let nbrs = [(x.wrapping_sub(1), y), (x + 1, y), (x, y.wrapping_sub(1)), (x, y + 1)];
|
||||
for (nx, ny) in nbrs {
|
||||
if nx >= w || ny >= h {
|
||||
continue;
|
||||
}
|
||||
let i = ny * w + nx;
|
||||
if out[i] {
|
||||
continue;
|
||||
}
|
||||
let q = lab[i];
|
||||
if d(q, c) <= EDGE_STEP && d(q, mean) <= MEAN_DIST {
|
||||
out[i] = true;
|
||||
sum = [sum[0] + q[0] as f64, sum[1] + q[1] as f64, sum[2] + q[2] as f64];
|
||||
n += 1.0;
|
||||
stack.push((nx, ny));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 3×3 box blur (clamped at the edges).
|
||||
fn box3(px: &[[f32; 3]], w: usize, h: usize) -> Vec<[f32; 3]> {
|
||||
let mut out = vec![[0.0f32; 3]; px.len()];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let (mut acc, mut n) = ([0.0f32; 3], 0.0f32);
|
||||
for yy in y.saturating_sub(1)..(y + 2).min(h) {
|
||||
for xx in x.saturating_sub(1)..(x + 2).min(w) {
|
||||
if let Some(p) = px.get(yy * w + xx) {
|
||||
acc = [acc[0] + p[0], acc[1] + p[1], acc[2] + p[2]];
|
||||
n += 1.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(o) = out.get_mut(y * w + x) {
|
||||
*o = acc.map(|v| v / n.max(1.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -288,6 +288,9 @@ fn grid_cut_impl<const OPTIMIZED: bool>(
|
||||
if count == 0 {
|
||||
return Some(out);
|
||||
}
|
||||
if cancel() {
|
||||
return None;
|
||||
}
|
||||
let mut g = Graph::with_capacity(count as usize, count as usize * 4);
|
||||
for i in 0..n {
|
||||
if idx[i] != u32::MAX {
|
||||
@@ -330,6 +333,9 @@ fn grid_cut_impl<const OPTIMIZED: bool>(
|
||||
}
|
||||
}
|
||||
}
|
||||
if cancel() {
|
||||
return None;
|
||||
}
|
||||
if OPTIMIZED {
|
||||
g.maxflow_with(cancel)?;
|
||||
} else {
|
||||
|
||||
@@ -88,6 +88,19 @@ pub fn quick_select_with(sampler: &dyn Sampler, canvas: Rect, points: &[(f32, f3
|
||||
}
|
||||
|
||||
pub(super) fn quick_select_using<const OPTIMIZED: bool>(sampler: &dyn Sampler, canvas: Rect, points: &[(f32, f32)], size: f32, t: &Tuning) -> Option<Region> {
|
||||
quick_select_batched::<OPTIMIZED>(sampler, canvas, points, size, t, SPECULATE).map(|(region, _)| region)
|
||||
}
|
||||
|
||||
/// [`quick_select_using`] cutting `batch` window sizes at a time (1: one after another). Also
|
||||
/// returns how many window sizes, smallest first, it took to find the kept one.
|
||||
pub(super) fn quick_select_batched<const OPTIMIZED: bool>(
|
||||
sampler: &dyn Sampler,
|
||||
canvas: Rect,
|
||||
points: &[(f32, f32)],
|
||||
size: f32,
|
||||
t: &Tuning,
|
||||
batch: usize,
|
||||
) -> Option<(Region, usize)> {
|
||||
if points.is_empty() || canvas.is_empty() {
|
||||
return None;
|
||||
}
|
||||
@@ -107,8 +120,15 @@ pub(super) fn quick_select_using<const OPTIMIZED: bool>(sampler: &dyn Sampler, c
|
||||
let step = working_step(canvas.width() as usize, canvas.height() as usize) as i32;
|
||||
let rw = r / step as f32;
|
||||
// Work in a window around the stroke; grow it while the selection runs into its border.
|
||||
let mut margin = (48.0f32).max(2.0 * rw) as i32 * step;
|
||||
loop {
|
||||
// The window's sides are free like the canvas' edges, so a selection that stays clear of
|
||||
// them is kept; one that reaches a side inside the canvas is recomputed in a larger window.
|
||||
// Index of the smallest window kept so far in this batch: larger ones stop cutting.
|
||||
let kept = std::sync::atomic::AtomicUsize::new(usize::MAX);
|
||||
let attempt = |index: usize, margin: i32| -> Attempt {
|
||||
let cancel = || kept.load(std::sync::atomic::Ordering::Relaxed) < index;
|
||||
if cancel() {
|
||||
return Attempt::Cancelled;
|
||||
}
|
||||
let snap0 = |v: i32, o: i32| o + (v - o).div_euclid(step) * step;
|
||||
let grown = bbox.inflate(margin).intersect(&canvas);
|
||||
let window = Rect::new(snap0(grown.x0, canvas.x0), snap0(grown.y0, canvas.y0), grown.x1, grown.y1);
|
||||
@@ -118,13 +138,13 @@ pub(super) fn quick_select_using<const OPTIMIZED: bool>(sampler: &dyn Sampler, c
|
||||
let pts: Vec<(f32, f32)> = points.iter().map(|&(x, y)| ((x - window.x0 as f32) / s, (y - window.y0 as f32) / s)).collect();
|
||||
let seeds = stroke_mask(&pts, rw.max(0.5), lw, lh);
|
||||
if !seeds.iter().any(|v| *v) {
|
||||
return None;
|
||||
return Attempt::NoSeeds;
|
||||
}
|
||||
if cancel() {
|
||||
return Attempt::Cancelled;
|
||||
}
|
||||
// The window's sides are free like the canvas' edges, so a selection that stays clear of
|
||||
// them is the best over the whole canvas too (outside the window it could only add cost).
|
||||
// One that reaches a side inside the canvas is recomputed in a larger window.
|
||||
let inner = [window.x0 > canvas.x0, window.y0 > canvas.y0, window.x1 < canvas.x1, window.y1 < canvas.y1];
|
||||
let low = select_working_using::<OPTIMIZED>(&img, &seeds, rw, t);
|
||||
let Some(low) = select_working_using::<OPTIMIZED>(&img, &seeds, rw, t, &cancel) else { return Attempt::Cancelled };
|
||||
let touches = |side: usize| match side {
|
||||
0 => (0..lh).any(|y| low[y * lw]),
|
||||
1 => (0..lw).any(|x| low[x]),
|
||||
@@ -132,21 +152,87 @@ pub(super) fn quick_select_using<const OPTIMIZED: bool>(sampler: &dyn Sampler, c
|
||||
_ => (0..lw).any(|x| low[(lh - 1) * lw + x]),
|
||||
};
|
||||
if (0..4).any(|k| inner[k] && touches(k)) {
|
||||
margin = margin.saturating_mul(2);
|
||||
continue;
|
||||
return Attempt::Touches;
|
||||
}
|
||||
let mask = upsample(&low, lw, lh, step as usize, window.width() as usize, window.height() as usize);
|
||||
return super::trim_region(Region { bbox: window, mask });
|
||||
kept.fetch_min(index, std::sync::atomic::Ordering::Relaxed);
|
||||
Attempt::Done { window, low, lw, lh }
|
||||
};
|
||||
let mut margin = (48.0f32).max(2.0 * rw) as i32 * step;
|
||||
let mut sizes = 0;
|
||||
loop {
|
||||
// The attempts don't depend on each other, so a few window sizes are cut at once and the
|
||||
// first in growing order that stays clear of its sides is kept: the same result as trying
|
||||
// them one after another, without waiting for the smaller ones to fail first. Once one is
|
||||
// kept, the larger ones still cutting stop, so a stroke settled by a small window costs
|
||||
// about what that window alone costs.
|
||||
let batch = margins_to_try(margin, bbox, canvas, batch);
|
||||
kept.store(usize::MAX, std::sync::atomic::Ordering::Relaxed);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let results: Vec<Attempt> = {
|
||||
use rayon::prelude::*;
|
||||
batch.par_iter().enumerate().map(|(i, &m)| attempt(i, m)).collect()
|
||||
};
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let results: Vec<Attempt> = batch.iter().enumerate().map(|(i, &m)| attempt(i, m)).collect();
|
||||
for result in results {
|
||||
sizes += 1;
|
||||
match result {
|
||||
Attempt::NoSeeds => return None,
|
||||
// Only a window larger than one already kept is cancelled, and that one comes
|
||||
// first in this order, so a cancelled attempt is never reached.
|
||||
Attempt::Touches | Attempt::Cancelled => continue,
|
||||
Attempt::Done { window, low, lw, lh } => {
|
||||
let mask = upsample(&low, lw, lh, step as usize, window.width() as usize, window.height() as usize);
|
||||
return super::trim_region(Region { bbox: window, mask }).map(|region| (region, sizes));
|
||||
}
|
||||
}
|
||||
}
|
||||
let &last = batch.last()?;
|
||||
margin = last.saturating_mul(2);
|
||||
}
|
||||
}
|
||||
|
||||
/// One window size's outcome in [`quick_select_using`].
|
||||
enum Attempt {
|
||||
/// The stroke misses the window.
|
||||
NoSeeds,
|
||||
/// The selection reaches a side of the window inside the canvas.
|
||||
Touches,
|
||||
/// A smaller window of the same batch was kept first.
|
||||
Cancelled,
|
||||
Done {
|
||||
window: Rect,
|
||||
low: Vec<bool>,
|
||||
lw: usize,
|
||||
lh: usize,
|
||||
},
|
||||
}
|
||||
|
||||
/// Window sizes cut together: most strokes settle within this many doublings.
|
||||
const SPECULATE: usize = 4;
|
||||
|
||||
/// The next `count` margins from `margin`, doubling, stopping at the first one whose window
|
||||
/// already covers the whole canvas (a larger one would be the same window).
|
||||
pub(super) fn margins_to_try(margin: i32, bbox: Rect, canvas: Rect, count: usize) -> Vec<i32> {
|
||||
let mut out = Vec::with_capacity(count.max(1));
|
||||
let mut m = margin;
|
||||
for _ in 0..count.max(1) {
|
||||
out.push(m);
|
||||
if bbox.inflate(m).intersect(&canvas) == canvas {
|
||||
break;
|
||||
}
|
||||
m = m.saturating_mul(2);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The model at working resolution: `seeds` (the brush footprint, `radius` working pixels) are
|
||||
/// selected; returns the selection.
|
||||
pub fn select_working(img: &RgbImage, seeds: &[bool], radius: f32, t: &Tuning) -> Vec<bool> {
|
||||
select_working_using::<true>(img, seeds, radius, t)
|
||||
select_working_using::<true>(img, seeds, radius, t, &|| false).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn select_working_using<const OPTIMIZED: bool>(img: &RgbImage, seeds: &[bool], radius: f32, t: &Tuning) -> Vec<bool> {
|
||||
fn select_working_using<const OPTIMIZED: bool>(img: &RgbImage, seeds: &[bool], radius: f32, t: &Tuning, cancel: &dyn Fn() -> bool) -> Option<Vec<bool>> {
|
||||
let (w, h) = (img.w, img.h);
|
||||
let d = crate::selection::edt(seeds, w, h);
|
||||
let grow = (t.grow + t.grow_frac * radius).max(0.0);
|
||||
@@ -154,7 +240,10 @@ fn select_working_using<const OPTIMIZED: bool>(img: &RgbImage, seeds: &[bool], r
|
||||
let fixed: Vec<u8> = d.iter().map(|d| if *d <= grow { HARD_FG } else { FREE }).collect();
|
||||
let (floor, sigma, k) = (t.floor, t.sigma.max(1e-4), t.k);
|
||||
let weight = move |d2: f32| floor + 1.0 / (1.0 + (d2.sqrt() / sigma).powf(k));
|
||||
cut_levels::<OPTIMIZED>(img, &fixed, &cost_fg, &weight, 1.0, t.direct_px.max(1024))
|
||||
if cancel() {
|
||||
return None;
|
||||
}
|
||||
cut_levels::<OPTIMIZED>(img, &fixed, &cost_fg, &weight, 1.0, t.direct_px.max(1024), cancel)
|
||||
}
|
||||
|
||||
/// Band half-width (pixels) re-cut at each finer level.
|
||||
@@ -164,11 +253,19 @@ const BAND: f32 = 3.0;
|
||||
/// problem (colours averaged, pixel costs summed, cut weights doubled since a coarse side spans two
|
||||
/// fine ones) gives a boundary, and the full-size cut is redone only within `BAND` of it. Seeds
|
||||
/// stay foreground.
|
||||
fn cut_levels<const OPTIMIZED: bool>(img: &RgbImage, fixed: &[u8], cost_fg: &[f32], weight: &dyn Fn(f32) -> f32, scale: f32, direct_px: usize) -> Vec<bool> {
|
||||
fn cut_levels<const OPTIMIZED: bool>(
|
||||
img: &RgbImage,
|
||||
fixed: &[u8],
|
||||
cost_fg: &[f32],
|
||||
weight: &dyn Fn(f32) -> f32,
|
||||
scale: f32,
|
||||
direct_px: usize,
|
||||
cancel: &dyn Fn() -> bool,
|
||||
) -> Option<Vec<bool>> {
|
||||
let (w, h) = (img.w, img.h);
|
||||
let zeros = vec![0.0f32; w * h];
|
||||
if w * h <= direct_px || w < 8 || h < 8 {
|
||||
return super::grid_cut_weighted::<OPTIMIZED>(img, cost_fg, &zeros, fixed, |d2| scale * weight(d2));
|
||||
return super::grid_cut_impl::<OPTIMIZED>(img, cost_fg, &zeros, fixed, |d2| scale * weight(d2), cancel);
|
||||
}
|
||||
let (cw, ch) = (w.div_ceil(2), h.div_ceil(2));
|
||||
let coarse_img = img.downsample(2);
|
||||
@@ -183,7 +280,10 @@ fn cut_levels<const OPTIMIZED: bool>(img: &RgbImage, fixed: &[u8], cost_fg: &[f3
|
||||
}
|
||||
}
|
||||
}
|
||||
let coarse = cut_levels::<OPTIMIZED>(&coarse_img, &cfix, &ccost, weight, scale * 2.0, direct_px);
|
||||
let coarse = cut_levels::<OPTIMIZED>(&coarse_img, &cfix, &ccost, weight, scale * 2.0, direct_px, cancel)?;
|
||||
if cancel() {
|
||||
return None;
|
||||
}
|
||||
let proj: Vec<bool> = (0..w * h).map(|i| coarse[(i / w / 2) * cw + (i % w) / 2]).collect();
|
||||
// Band: within BAND of the projected boundary.
|
||||
let edge: Vec<bool> = (0..w * h)
|
||||
@@ -194,7 +294,7 @@ fn cut_levels<const OPTIMIZED: bool>(img: &RgbImage, fixed: &[u8], cost_fg: &[f3
|
||||
})
|
||||
.collect();
|
||||
if !edge.iter().any(|e| *e) {
|
||||
return (0..w * h).map(|i| proj[i] || fixed[i] == HARD_FG).collect();
|
||||
return Some((0..w * h).map(|i| proj[i] || fixed[i] == HARD_FG).collect());
|
||||
}
|
||||
let near = crate::selection::edt(&edge, w, h);
|
||||
let banded: Vec<u8> = (0..w * h)
|
||||
@@ -210,7 +310,7 @@ fn cut_levels<const OPTIMIZED: bool>(img: &RgbImage, fixed: &[u8], cost_fg: &[f3
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
super::grid_cut_weighted::<OPTIMIZED>(img, cost_fg, &zeros, &banded, |d2| scale * weight(d2))
|
||||
super::grid_cut_impl::<OPTIMIZED>(img, cost_fg, &zeros, &banded, |d2| scale * weight(d2), cancel)
|
||||
}
|
||||
|
||||
/// Pixels within `radius` (working pixels) of the polyline `pts`, plus each point's own pixel.
|
||||
|
||||
@@ -132,9 +132,31 @@ pub fn sky_mask_low(img: &RgbImage, threshold: f32) -> Option<Vec<bool>> {
|
||||
}
|
||||
}
|
||||
let mask = clean_mask(&mask, w, h, 0.002, 0.01);
|
||||
if is_backdrop(img, &mask, top) {
|
||||
return None;
|
||||
}
|
||||
mask.iter().any(|v| *v).then_some(mask)
|
||||
}
|
||||
|
||||
/// A neutral region that also covers most of the bottom rows is a studio or product backdrop,
|
||||
/// not an overcast sky (which never reaches the bottom of a photo); blue sky is never one.
|
||||
fn is_backdrop(img: &RgbImage, mask: &[bool], rows: usize) -> bool {
|
||||
let (w, h) = (img.w, img.h);
|
||||
let bottom = (h - rows.min(h)) * w..h * w;
|
||||
let Some(bottom_rows) = mask.get(bottom) else { return false };
|
||||
let covered = bottom_rows.iter().filter(|m| **m).count();
|
||||
if covered * 10 < bottom_rows.len() * 6 {
|
||||
return false;
|
||||
}
|
||||
let (mut sat, mut n) = (0.0f32, 0usize);
|
||||
for (p, _) in img.px.iter().zip(mask).filter(|(_, m)| **m) {
|
||||
let (mx, mn) = (p[0].max(p[1]).max(p[2]), p[0].min(p[1]).min(p[2]));
|
||||
sat += if mx > 1e-4 { (mx - mn) / mx } else { 0.0 };
|
||||
n += 1;
|
||||
}
|
||||
n > 0 && sat / (n as f32) < 0.1
|
||||
}
|
||||
|
||||
/// Selects the sky over `canvas`. `None` if no sky was found.
|
||||
pub fn select_sky(sampler: &dyn Sampler, canvas: Rect, p: SkyParams) -> Option<Region> {
|
||||
if canvas.width() < 4 || canvas.height() < 4 {
|
||||
@@ -269,6 +291,18 @@ mod tests {
|
||||
assert!(r.at(30, 10) > 0.9 && r.at(30, 50) < 0.1);
|
||||
}
|
||||
|
||||
/// #4634: a near-white studio backdrop around a product is not an overcast sky, but blue sky
|
||||
/// reaching the bottom (a photo taken upward) still is.
|
||||
#[test]
|
||||
fn a_white_backdrop_is_not_sky_but_a_blue_sky_down_to_the_bottom_is() {
|
||||
let disc = |x: usize, y: usize| ((x as f32 - 75.0).powi(2) + (y as f32 - 50.0).powi(2)).sqrt() < 30.0;
|
||||
let img = RgbImage::from_fn(150, 100, |x, y| if disc(x, y) { [0.12, 0.12, 0.12] } else { [0.95, 0.95, 0.94] });
|
||||
assert!(select_sky(&ImageSampler { img: &img, origin: (0, 0) }, Rect::new(0, 0, 150, 100), SkyParams::default()).is_none());
|
||||
let img = RgbImage::from_fn(150, 100, |x, y| if disc(x, y) { [0.12, 0.3, 0.1] } else { [0.35, 0.6, 0.95] });
|
||||
let r = select_sky(&ImageSampler { img: &img, origin: (0, 0) }, Rect::new(0, 0, 150, 100), SkyParams::default()).expect("blue sky");
|
||||
assert!(r.at(5, 95) > 0.9 && r.at(75, 50) < 0.1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colour_prior_ranks_sky_over_foliage() {
|
||||
assert!(colour_prior([0.4, 0.6, 0.95]) > 0.8);
|
||||
|
||||
@@ -344,6 +344,45 @@ fn quick_select_with_points_far_off_canvas_selects_nothing() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quick_select_cuts_window_sizes_in_parallel_with_the_same_result() {
|
||||
// Soft gradients and faint blobs let a selection spread, so most strokes need more than one
|
||||
// window size; cutting several at once must keep exactly what one-after-another keeps.
|
||||
let (w, h) = (1200usize, 900usize);
|
||||
let img = RgbImage::from_fn(w, h, |x, y| {
|
||||
let (fx, fy) = (x as f32 / w as f32, y as f32 / h as f32);
|
||||
let blob = (((fx * 9.0).sin() * (fy * 7.0).cos()) * 0.5 + 0.5) * 0.06;
|
||||
[0.3 + 0.4 * fx + blob, 0.5 + 0.2 * fy, 0.7 - 0.3 * fx + blob]
|
||||
});
|
||||
let s = ImageSampler { img: &img, origin: (0, 0) };
|
||||
let canvas = Rect::new(0, 0, w as i32, h as i32);
|
||||
let t = quick::Tuning::default();
|
||||
let strokes: [(&[(f32, f32)], f32); 4] = [
|
||||
(&[(300.0, 300.0)], 30.0),
|
||||
(&[(500.0, 400.0), (580.0, 400.0), (660.0, 420.0)], 60.0),
|
||||
(&[(200.0, 700.0), (260.0, 650.0), (320.0, 700.0), (380.0, 650.0)], 30.0),
|
||||
(&[(900.0, 200.0)], 120.0),
|
||||
];
|
||||
let mut most = 0;
|
||||
for (pts, size) in strokes {
|
||||
let one = quick::quick_select_batched::<true>(&s, canvas, pts, size, &t, 1);
|
||||
let many = quick::quick_select_batched::<true>(&s, canvas, pts, size, &t, 4);
|
||||
assert_eq!(one.as_ref().map(|(r, n)| (r.bbox, &r.mask, n)), many.as_ref().map(|(r, n)| (r.bbox, &r.mask, n)), "{pts:?} size {size}");
|
||||
most = most.max(one.map_or(0, |(_, n)| n));
|
||||
}
|
||||
assert!(most > 1, "no stroke needed a larger window, so the test exercises nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quick_select_window_sizes_stop_at_the_canvas() {
|
||||
let canvas = Rect::new(0, 0, 1000, 800);
|
||||
let bbox = Rect::new(400, 300, 460, 360);
|
||||
assert_eq!(quick::margins_to_try(96, bbox, canvas, 4), vec![96, 192, 384, 768]);
|
||||
// 768 already covers the canvas: no larger (identical) window is cut.
|
||||
assert_eq!(quick::margins_to_try(384, bbox, canvas, 4), vec![384, 768]);
|
||||
assert_eq!(quick::margins_to_try(96, bbox, canvas, 0), vec![96]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quick_select_grows_without_leaking() {
|
||||
let img = two_regions(140, 100);
|
||||
@@ -620,3 +659,24 @@ fn dump_quick_scenes() {
|
||||
save("thin_line", &line);
|
||||
save("disc_scene", &disc_scene(600, 500, 300.0, 250.0, 180.0, 4).0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_at_finds_the_disc_under_the_cursor() {
|
||||
let (img, truth) = disc_scene(320, 240, 160.0, 120.0, 70.0, 7);
|
||||
let s = ImageSampler { img: &img, origin: (0, 0) };
|
||||
let canvas = Rect::new(0, 0, 320, 240);
|
||||
let reg = grabcut::object_at(&s, canvas, 165.0, 118.0, 20_000).unwrap();
|
||||
let v = iou(®, &truth, 320, 240);
|
||||
assert!(v > 0.9, "IoU {v}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_at_on_the_background_or_off_canvas_finds_nothing() {
|
||||
let (img, _) = disc_scene(320, 240, 160.0, 120.0, 70.0, 7);
|
||||
let s = ImageSampler { img: &img, origin: (0, 0) };
|
||||
let canvas = Rect::new(0, 0, 320, 240);
|
||||
assert!(grabcut::object_at(&s, canvas, 10.0, 10.0, 20_000).is_none());
|
||||
assert!(grabcut::object_at(&s, canvas, -5.0, 10.0, 20_000).is_none());
|
||||
assert!(grabcut::object_at(&s, canvas, f32::NAN, 10.0, 20_000).is_none());
|
||||
assert!(grabcut::object_at(&s, canvas, 400.0, 10.0, 0).is_none());
|
||||
}
|
||||
@@ -166,7 +166,8 @@ fn close(a: [f32; 4], b: [f32; 4], tol: f32) -> bool {
|
||||
/// around the seed. `px` covers `area`.
|
||||
pub fn magic_wand(px: &[[f32; 4]], area: Rect, seed: (i32, i32), tolerance: f32, contiguous: bool, anti_alias: bool) -> Vec<f32> {
|
||||
let (w, h) = (area.width() as usize, area.height() as usize);
|
||||
let mut m = vec![0.0f32; w * h];
|
||||
let Some(n) = w.checked_mul(h).filter(|&n| n <= px.len()) else { return Vec::new() };
|
||||
let mut m = vec![0.0f32; n];
|
||||
if !area.contains(seed.0, seed.1) {
|
||||
return m;
|
||||
}
|
||||
@@ -245,7 +246,9 @@ impl Region {
|
||||
if !self.bbox.contains(x, y) {
|
||||
return 0.0;
|
||||
}
|
||||
self.mask[(y - self.bbox.y0) as usize * self.bbox.width() as usize + (x - self.bbox.x0) as usize] as f32 / 255.0
|
||||
let row = (y.abs_diff(self.bbox.y0) as usize).checked_mul(self.bbox.width() as usize);
|
||||
let value = row.and_then(|i| i.checked_add(x.abs_diff(self.bbox.x0) as usize)).and_then(|i| self.mask.get(i)).copied().unwrap_or(0);
|
||||
f32::from(value) / 255.0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,6 +260,9 @@ pub fn wand_region(img: &[[u8; 4]], area: Rect, seed: (i32, i32), tolerance: f32
|
||||
return None;
|
||||
}
|
||||
let (w, h) = (area.width() as usize, area.height() as usize);
|
||||
if w.checked_mul(h).is_none_or(|n| n > img.len()) {
|
||||
return None;
|
||||
}
|
||||
let target = img[(seed.1 - area.y0) as usize * w + (seed.0 - area.x0) as usize];
|
||||
let tol = (tolerance + 1e-3).floor().max(0.0) as i32;
|
||||
// Copying a selection can leave RGB under zero alpha. Those invisible colours must not
|
||||
@@ -1400,6 +1406,37 @@ mod tests {
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn magic_wand_rejects_incomplete_and_overflowing_pixel_inputs() {
|
||||
let area = Rect::new(0, 0, 4, 4);
|
||||
let short = vec![[255u8, 0, 0, 255]; 4];
|
||||
let floats: Vec<[f32; 4]> = short.iter().map(|p| p.map(|v| f32::from(v) / 255.0)).collect();
|
||||
assert_eq!(magic_wand(&floats, area, (1, 1), 0.0, true, false), Vec::<f32>::new());
|
||||
assert_eq!(wand_region(&short, area, (1, 1), 0.0, true, false), None);
|
||||
|
||||
let huge = Rect::new(i32::MIN, i32::MIN, i32::MAX, i32::MAX);
|
||||
assert!(magic_wand(&floats, huge, (0, 0), 0.0, false, false).is_empty());
|
||||
assert_eq!(wand_region(&short, huge, (0, 0), 0.0, false, false), None);
|
||||
|
||||
let valid = Rect::new(0, 0, 2, 2);
|
||||
assert_eq!(magic_wand(&floats, valid, (0, 0), 0.0, false, false), vec![1.0; 4]);
|
||||
assert!(wand_region(&short, valid, (0, 0), 0.0, false, false).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn region_coverage_outside_a_short_mask_is_zero() {
|
||||
let region = Region { bbox: Rect::new(-2, -3, 1, -1), mask: vec![255, 128] };
|
||||
assert_eq!(region.at(-2, -3), 1.0);
|
||||
assert!((region.at(-1, -3) - 128.0 / 255.0).abs() < 1e-6);
|
||||
assert_eq!(region.at(0, -3), 0.0);
|
||||
assert_eq!(region.at(-2, -2), 0.0);
|
||||
assert_eq!(region.at(-3, -3), 0.0);
|
||||
|
||||
let extreme = Region { bbox: Rect::new(i32::MIN, i32::MIN, i32::MAX, i32::MAX), mask: vec![] };
|
||||
assert_eq!(extreme.at(i32::MAX - 1, i32::MAX - 1), 0.0);
|
||||
assert_eq!(extreme.at(i32::MIN, i32::MIN), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wand_region_matches_reference_magic_wand() {
|
||||
let (w, h) = (37, 29);
|
||||
|
||||
@@ -569,11 +569,14 @@ pub(crate) fn feather(m: &[f32], w: usize, h: usize, radius: f32) -> Vec<f32> {
|
||||
let s = f64::from(sigma);
|
||||
let (kh, kv) = (Kernel::new(s, r, gw), Kernel::new(s, r, gh));
|
||||
// Rows: only the bounding box's rows are non-zero.
|
||||
// The run table is scratch: one buffer per worker thread, reused across rows instead of
|
||||
// one allocation per row (thousands of 8-byte-per-sample Vecs on a 24 MP mask).
|
||||
thread_local! { static RUNS: std::cell::RefCell<Vec<usize>> = const { std::cell::RefCell::new(Vec::new()) } }
|
||||
let mut tmp = vec![0.0f32; b.h() * gw];
|
||||
par_rows(&mut tmp, gw, 1, |y, row| {
|
||||
let o = (b.y0 + y) * w + win.x0;
|
||||
if let Some(s) = m.get(o..o + gw) {
|
||||
kh.line(s, row, &mut Vec::new());
|
||||
RUNS.with(|runs| kh.line(s, row, &mut runs.borrow_mut()));
|
||||
}
|
||||
});
|
||||
// Columns, a strip of columns at a time.
|
||||
|
||||
@@ -5,6 +5,7 @@ use photocraft_geom::Rect;
|
||||
use photocraft_raster::{from_rgba, to_rgba};
|
||||
|
||||
use crate::Ctx;
|
||||
use crate::fxutil::subtractive;
|
||||
use crate::image::Image;
|
||||
|
||||
fn fmt(ctx: &Ctx) -> PixelFormat {
|
||||
@@ -23,6 +24,16 @@ fn set_gray(ctx: &Ctx, px: &mut [f32], g: f32) {
|
||||
px.copy_from_slice(&v[..px.len()]);
|
||||
}
|
||||
|
||||
/// Solarize: inverts the highlights. CMYK samples are ink, so there the
|
||||
/// channels below 50% ink (the light ones) invert.
|
||||
pub(crate) fn solarize(src: &Image, out: Rect, ctx: &Ctx) -> Vec<f32> {
|
||||
if subtractive(ctx) {
|
||||
per_pixel(src, out, ctx, |v| if v < 0.5 { 1.0 - v } else { v })
|
||||
} else {
|
||||
per_pixel(src, out, ctx, |v| if v > 0.5 { 1.0 - v } else { v })
|
||||
}
|
||||
}
|
||||
|
||||
/// Colour channels through `f` (alpha untouched).
|
||||
pub(crate) fn per_pixel(src: &Image, out: Rect, ctx: &Ctx, f: impl Fn(f32) -> f32) -> Vec<f32> {
|
||||
let n = src.ch;
|
||||
@@ -167,10 +178,12 @@ pub(crate) fn emboss(src: &Image, out: Rect, ctx: &Ctx, angle: f32, height: f32,
|
||||
res
|
||||
}
|
||||
|
||||
/// Find Edges: Sobel magnitude per colour channel, dark edges on white.
|
||||
/// Find Edges: Sobel magnitude per colour channel, dark edges on white
|
||||
/// (CMYK samples are ink, so there the edge strength is the ink itself).
|
||||
pub(crate) fn find_edges(src: &Image, out: Rect, ctx: &Ctx) -> Vec<f32> {
|
||||
let n = src.ch;
|
||||
let cc = if ctx.alpha { n - 1 } else { n };
|
||||
let sub = subtractive(ctx);
|
||||
let mut res = src.crop(out);
|
||||
let w = out.width() as usize;
|
||||
for (i, px) in res.chunks_exact_mut(n).enumerate() {
|
||||
@@ -179,7 +192,8 @@ pub(crate) fn find_edges(src: &Image, out: Rect, ctx: &Ctx) -> Vec<f32> {
|
||||
let g = |dx: i32, dy: i32| src.get(x + dx, y + dy, c);
|
||||
let gx = (g(1, -1) + 2.0 * g(1, 0) + g(1, 1)) - (g(-1, -1) + 2.0 * g(-1, 0) + g(-1, 1));
|
||||
let gy = (g(-1, 1) + 2.0 * g(0, 1) + g(1, 1)) - (g(-1, -1) + 2.0 * g(0, -1) + g(1, -1));
|
||||
*v = (1.0 - (gx * gx + gy * gy).sqrt() / 4.0).clamp(0.0, 1.0);
|
||||
let e = ((gx * gx + gy * gy).sqrt() / 4.0).clamp(0.0, 1.0);
|
||||
*v = if sub { e } else { 1.0 - e };
|
||||
}
|
||||
}
|
||||
res
|
||||
|
||||
@@ -367,6 +367,30 @@ fn stylize_basics() {
|
||||
assert!((d[0] - d[1]).abs() < 1e-6 && (d[1] - d[2]).abs() < 1e-6);
|
||||
}
|
||||
|
||||
/// CMYK samples are ink (1 = full ink): Find Edges leaves flat areas white (no ink, not 100% ink)
|
||||
/// and Solarize inverts the channels below 50% ink, as Photoshop does (#4222). Alpha is untouched.
|
||||
#[test]
|
||||
fn find_edges_and_solarize_treat_cmyk_as_ink() {
|
||||
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
let f = PixelFormat::new(ColorMode::Cmyk, depth, true);
|
||||
let mut s = Surface::new(f);
|
||||
s.fill_rect(R, &[0.1, 0.1, 0.1, 0.1, 1.0]);
|
||||
let e = run(&s, &FilterParams::FindEdges).pixel(20, 15);
|
||||
assert!(e[..4].iter().all(|v| v.abs() < 1e-6) && (e[4] - 1.0).abs() < 1e-6, "{depth:?} flat CMYK -> white: {e:?}");
|
||||
let mut s = Surface::new(f);
|
||||
s.fill_rect(R, &[0.1, 0.2, 0.8, 0.9, 0.6]);
|
||||
let so = run(&s, &FilterParams::Solarize).pixel(3, 3);
|
||||
let want = [0.9, 0.8, 0.8, 0.9, 0.6];
|
||||
assert!(so.iter().zip(want).all(|(a, b)| (a - b).abs() < 0.01), "{depth:?} solarize CMYK: {so:?}");
|
||||
}
|
||||
// RGB keeps the light-polarity behaviour: flat -> white, channels above 50% invert.
|
||||
let s = flat(SampleType::U8, R, [0.1, 0.2, 0.8, 1.0]);
|
||||
let e = run(&s, &FilterParams::FindEdges).pixel(20, 15);
|
||||
assert!(e[..3].iter().all(|v| (v - 1.0).abs() < 1e-6), "{e:?}");
|
||||
let so = run(&s, &FilterParams::Solarize).pixel(3, 3);
|
||||
assert!(so.iter().zip([0.1, 0.2, 0.2, 1.0]).all(|(a, b)| (a - b).abs() < 0.01), "{so:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distortions_are_identity_at_zero() {
|
||||
let s = pattern(SampleType::U8, R);
|
||||
@@ -463,3 +487,72 @@ fn layer_filters_repeat_the_canvas_edge() {
|
||||
assert!(faded.pixel(0, 0)[3] < 0.9, "{depth:?}: unclamped path should read transparency");
|
||||
}
|
||||
}
|
||||
|
||||
/// `mix_selection` before #4533, one selection lookup per pixel: the oracle for the per-tile read.
|
||||
fn mix_selection_reference(data: &mut [f32], t: Rect, sel: &Surface, orig: &crate::image::Image, alpha: bool) {
|
||||
let w = t.width() as usize;
|
||||
for (i, px) in data.chunks_exact_mut(orig.ch.max(1)).enumerate() {
|
||||
let (x, y) = (t.x0 + (i % w) as i32, t.y0 + (i / w) as i32);
|
||||
let k = sel.pixel(x, y)[0].clamp(0.0, 1.0);
|
||||
if k >= 1.0 {
|
||||
continue;
|
||||
}
|
||||
if k <= 0.0 {
|
||||
for (c, v) in px.iter_mut().enumerate() {
|
||||
*v = orig.get(x, y, c);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if alpha {
|
||||
let a = px.len() - 1;
|
||||
let original_alpha = orig.get(x, y, a);
|
||||
let filtered_alpha = px[a];
|
||||
if original_alpha != filtered_alpha {
|
||||
let mixed_alpha = original_alpha + (filtered_alpha - original_alpha) * k;
|
||||
let original_weight = original_alpha * (1.0 - k);
|
||||
let filtered_weight = filtered_alpha * k;
|
||||
for (c, v) in px[..a].iter_mut().enumerate() {
|
||||
*v = if mixed_alpha > 0.0 { (orig.get(x, y, c) * original_weight + *v * filtered_weight) / mixed_alpha } else { 0.0 };
|
||||
}
|
||||
px[a] = mixed_alpha;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
for (c, v) in px.iter_mut().enumerate() {
|
||||
let o = orig.get(x, y, c);
|
||||
*v = o + (*v - o) * k;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reading the selection once per tile gives exactly the per-pixel result (#4533): a soft
|
||||
/// selection with fully selected, unselected and partial pixels, with and without alpha, on a
|
||||
/// tile that crosses the selection's tile boundaries.
|
||||
#[test]
|
||||
fn mix_selection_reads_the_selection_once_per_tile() {
|
||||
let noise = |seed: u32, n: usize| -> Vec<f32> {
|
||||
(0..n as u32)
|
||||
.map(|i| {
|
||||
let mut h = seed.wrapping_mul(0x9e37_79b9) ^ i.wrapping_mul(0x85eb_ca6b);
|
||||
h ^= h >> 13;
|
||||
h = h.wrapping_mul(0xc2b2_ae35);
|
||||
(h >> 16) as f32 / 65535.0
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let t = Rect::new(200, 180, 330, 300);
|
||||
let n = (t.width() * t.height()) as usize;
|
||||
let mut sel = Surface::new(PixelFormat::GRAY8);
|
||||
sel.fill_rect(Rect::new(150, 150, 260, 250), &[1.0]);
|
||||
let soft = Rect::new(260, 150, 330, 250);
|
||||
sel.write_region(soft, &noise(5, (soft.width() * soft.height()) as usize));
|
||||
for (alpha, fmt) in [(true, PixelFormat::RGBA8), (false, PixelFormat { alpha: false, ..PixelFormat::RGBA8 })] {
|
||||
let ch = fmt.channels();
|
||||
let orig = crate::image::Image { rect: t, ch, data: noise(1, n * ch) };
|
||||
let filtered = noise(2, n * ch);
|
||||
let (mut a, mut b) = (filtered.clone(), filtered);
|
||||
mix_selection(&mut a, t, &sel, &orig, alpha);
|
||||
mix_selection_reference(&mut b, t, &sel, &orig, alpha);
|
||||
assert!(a.iter().zip(&b).all(|(x, y)| x.to_bits() == y.to_bits()), "alpha {alpha}: results differ");
|
||||
}
|
||||
}
|
||||
+119
-26
@@ -3,13 +3,13 @@
|
||||
//! A transform is described by where the four corners of a source rectangle land (a quad). Scale,
|
||||
//! rotate, skew and flips are affine special cases; Distort and Perspective use the full
|
||||
//! homography. Warping is an inverse mapping with premultiplied-alpha sampling (nearest, bilinear
|
||||
//! or Catmull-Rom bicubic), processed per destination tile (in parallel on native) so memory stays
|
||||
//! or Photoshop's Keys bicubic), processed per destination tile (in parallel on native) so memory stays
|
||||
//! bounded by the tile working set. Large reductions are pre-filtered with a proper resize first,
|
||||
//! so shrinking a layer doesn't alias.
|
||||
|
||||
use photocraft_color::PixelFormat;
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_raster::Surface;
|
||||
use photocraft_raster::{Interrupt, Surface};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::resample::{Resample, resize_surface};
|
||||
@@ -133,24 +133,40 @@ impl Interp {
|
||||
}
|
||||
}
|
||||
|
||||
fn catmull_rom(t: f64) -> [f64; 4] {
|
||||
let t2 = t * t;
|
||||
let t3 = t2 * t;
|
||||
[-0.5 * t3 + t2 - 0.5 * t, 1.5 * t3 - 2.5 * t2 + 1.0, -1.5 * t3 + 2.0 * t2 + 0.5 * t, 0.5 * t3 - 0.5 * t2]
|
||||
/// Photoshop's Bicubic: the Keys cubic at a = −0.75 (Catmull-Rom is −0.5), the weights of the
|
||||
/// four source pixels around a sample `t` (0..1) past the second. Measured on Photoshop 27.11's
|
||||
/// Free Transform: a 200 % step edge matches within 0.5 levels; Catmull-Rom was 6 off.
|
||||
pub(crate) fn bicubic(t: f64) -> [f64; 4] {
|
||||
const A: f64 = -0.75;
|
||||
let near = |x: f64| (A + 2.0) * x * x * x - (A + 3.0) * x * x + 1.0;
|
||||
let far = |x: f64| A * (x * x * x - 5.0 * x * x + 8.0 * x - 4.0);
|
||||
[far(1.0 + t), near(t), near(1.0 - t), far(2.0 - t)]
|
||||
}
|
||||
|
||||
/// Warp `src` (its content inside `src_rect`) by `h` (source → destination document space).
|
||||
/// The result has the same format as `src`, with alpha added if `src` had none; pixels outside
|
||||
/// the warped quad are transparent.
|
||||
pub fn warp_surface(src: &Surface, src_rect: Rect, h: &Homography, interp: Interp) -> Result<Surface, photocraft_raster::AllocationError> {
|
||||
warp(src, src_rect, h, interp, None)
|
||||
warp(src, src_rect, h, interp, None, &Interrupt::NONE)
|
||||
}
|
||||
|
||||
/// [`warp_surface`] reporting progress per destination tile; `Ok(None)` once `ctl` is cancelled.
|
||||
pub fn warp_surface_with(
|
||||
src: &Surface,
|
||||
src_rect: Rect,
|
||||
h: &Homography,
|
||||
interp: Interp,
|
||||
ctl: &Interrupt,
|
||||
) -> Result<Option<Surface>, photocraft_raster::AllocationError> {
|
||||
let out = warp(src, src_rect, h, interp, None, ctl)?;
|
||||
Ok((!ctl.cancelled()).then_some(out))
|
||||
}
|
||||
|
||||
/// [`warp_surface`] keeping only the destination pixels inside `clip`. The work is bounded by
|
||||
/// `clip` even when part of the source lies at or beyond the map's horizon (a Perspective Crop of a
|
||||
/// layer that reaches far past the crop quad), which would make the full warped extent unbounded.
|
||||
pub fn warp_surface_clipped(src: &Surface, src_rect: Rect, h: &Homography, interp: Interp, clip: Rect) -> Result<Surface, photocraft_raster::AllocationError> {
|
||||
warp(src, src_rect, h, interp, Some(clip))
|
||||
warp(src, src_rect, h, interp, Some(clip), &Interrupt::NONE)
|
||||
}
|
||||
|
||||
/// Is `q` (TL, TR, BR, BL) a usable Perspective Crop quad: finite, strictly convex with every
|
||||
@@ -192,7 +208,39 @@ pub fn quad_to_rect(q: &[[f64; 2]; 4], w: f64, h: f64) -> Option<Homography> {
|
||||
Homography::rect_to_quad([0.0, 0.0, w, h], *q)?.inverse()
|
||||
}
|
||||
|
||||
fn warp(src: &Surface, src_rect: Rect, h: &Homography, interp: Interp, clip: Option<Rect>) -> Result<Surface, photocraft_raster::AllocationError> {
|
||||
/// The destination rectangle [`warp_surface`] fills for `src_rect` under `h`: the warped corners
|
||||
/// plus a pixel for filter support, each side clamped to ±2²⁰. `None` when a corner maps to
|
||||
/// infinity (the result is then empty). Lets a caller price a warp before allocating it.
|
||||
pub fn warp_output_bounds(src_rect: Rect, h: &Homography) -> Option<Rect> {
|
||||
if src_rect.is_empty() || h.inverse().is_none() {
|
||||
return None;
|
||||
}
|
||||
let corners = [(src_rect.x0, src_rect.y0), (src_rect.x1, src_rect.y0), (src_rect.x1, src_rect.y1), (src_rect.x0, src_rect.y1)]
|
||||
.map(|(x, y)| h.apply(x as f64, y as f64));
|
||||
corner_bounds(&corners)
|
||||
}
|
||||
|
||||
fn corner_bounds(corners: &[(f64, f64); 4]) -> Option<Rect> {
|
||||
if !corners.iter().all(|c| c.0.is_finite() && c.1.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let lim = (1 << 20) as f64;
|
||||
let bx0 = corners.iter().map(|c| c.0).fold(f64::MAX, f64::min).floor().max(-lim) as i32 - 1;
|
||||
let by0 = corners.iter().map(|c| c.1).fold(f64::MAX, f64::min).floor().max(-lim) as i32 - 1;
|
||||
let bx1 = corners.iter().map(|c| c.0).fold(f64::MIN, f64::max).ceil().min(lim) as i32 + 1;
|
||||
let by1 = corners.iter().map(|c| c.1).fold(f64::MIN, f64::max).ceil().min(lim) as i32 + 1;
|
||||
Some(Rect::new(bx0, by0, bx1, by1))
|
||||
}
|
||||
|
||||
/// A cancelled `ctl` skips the remaining tiles; the caller discards the result.
|
||||
fn warp(
|
||||
src: &Surface,
|
||||
src_rect: Rect,
|
||||
h: &Homography,
|
||||
interp: Interp,
|
||||
clip: Option<Rect>,
|
||||
ctl: &Interrupt,
|
||||
) -> Result<Surface, photocraft_raster::AllocationError> {
|
||||
let mut fmt = src.format();
|
||||
let converted;
|
||||
let mut src = if fmt.alpha {
|
||||
@@ -216,8 +264,12 @@ fn warp(src: &Surface, src_rect: Rect, h: &Homography, interp: Interp, clip: Opt
|
||||
};
|
||||
let scale = h.local_scale(cx, cy);
|
||||
let reduced;
|
||||
if interp != Interp::Nearest && scale < 0.5 && scale > 0.0 {
|
||||
let f = 2f64.powf(scale.log2().ceil()).min(1.0); // remaining scale lands in [0.5, 1)
|
||||
// Photoshop reduces with its Bicubic widened by 1 / scale (as Image Size does), so the source is
|
||||
// brought down by the whole local scale first and the warp samples it at about 1:1; a plain
|
||||
// 4×4 kernel at 50 % aliased (Photoshop 27.11 Free Transform, 50 % of an impulse image).
|
||||
// Bilinear samples the source directly, as Photoshop's does (exact at 50 % without a prefilter).
|
||||
if interp == Interp::Bicubic && scale < 0.999 && scale > 0.0 {
|
||||
let f = scale.max(1.0 / 64.0);
|
||||
reduced = resize_surface(src, f, f, Resample::Bicubic);
|
||||
src = &reduced;
|
||||
h = h.mul(&Homography([1.0 / f, 0.0, 0.0, 0.0, 1.0 / f, 0.0, 0.0, 0.0, 1.0]));
|
||||
@@ -234,21 +286,13 @@ fn warp(src: &Surface, src_rect: Rect, h: &Homography, interp: Interp, clip: Opt
|
||||
let fwd_w = [(sx0, sy0), (sx1, sy0), (sx1, sy1), (sx0, sy1)].map(|(x, y)| h.0[6] * x + h.0[7] * y + h.0[8]);
|
||||
let side = [1.0, -1.0].into_iter().find(|&s| fwd_w.iter().all(|&c| s * c > 0.0));
|
||||
let w_lo = 1.0 / fwd_w.iter().fold(0.0f64, |m, c| m.max(c.abs()));
|
||||
let finite = corners.iter().all(|c| c.0.is_finite() && c.1.is_finite());
|
||||
let lim = 1 << 20;
|
||||
let bounds = || {
|
||||
let bx0 = corners.iter().map(|c| c.0).fold(f64::MAX, f64::min).floor().max(-(lim as f64)) as i32 - 1;
|
||||
let by0 = corners.iter().map(|c| c.1).fold(f64::MAX, f64::min).floor().max(-(lim as f64)) as i32 - 1;
|
||||
let bx1 = corners.iter().map(|c| c.0).fold(f64::MIN, f64::max).ceil().min(lim as f64) as i32 + 1;
|
||||
let by1 = corners.iter().map(|c| c.1).fold(f64::MIN, f64::max).ceil().min(lim as f64) as i32 + 1;
|
||||
Rect::new(bx0, by0, bx1, by1)
|
||||
};
|
||||
let dst = match clip {
|
||||
let bounds = corner_bounds(&corners);
|
||||
let dst = match (clip, bounds) {
|
||||
// The warped corners only bound the result while the whole source is in front of the horizon.
|
||||
Some(c) if finite && side.is_some() => bounds().intersect(&c),
|
||||
Some(c) => c,
|
||||
None if finite => bounds(),
|
||||
None => return Ok(out),
|
||||
(Some(c), Some(b)) if side.is_some() => b.intersect(&c),
|
||||
(Some(c), _) => c,
|
||||
(None, Some(b)) => b,
|
||||
(None, None) => return Ok(out),
|
||||
};
|
||||
if dst.is_empty() {
|
||||
return Ok(out);
|
||||
@@ -261,7 +305,16 @@ fn warp(src: &Surface, src_rect: Rect, h: &Homography, interp: Interp, clip: Opt
|
||||
tiles.try_reserve_exact(tile_count).map_err(|_| photocraft_raster::AllocationError::NotEnoughMemory { bytes: tile_bytes })?;
|
||||
tiles.extend(dst.tiles().map(|tc| tc.rect().intersect(&dst)).filter(|r| !r.is_empty()));
|
||||
let src_ref: &Surface = src;
|
||||
// Tiles finished so far, for progress reported per tile (from any worker thread).
|
||||
let finished = std::sync::atomic::AtomicUsize::new(0);
|
||||
let total = tiles.len().max(1);
|
||||
let work = |t: &Rect| -> Result<Option<(Rect, Vec<f32>)>, photocraft_raster::AllocationError> {
|
||||
if ctl.cancelled() {
|
||||
return Ok(None);
|
||||
}
|
||||
let k = finished.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
|
||||
// The last few percent are the writes.
|
||||
ctl.progress(0.98 * k as f32 / total as f32);
|
||||
// Source footprint of this tile (inverse-mapped corners of its sampled part), padded for the filter.
|
||||
let tc = [(t.x0, t.y0), (t.x1, t.y0), (t.x1, t.y1), (t.x0, t.y1)].map(|(x, y)| (x as f64, y as f64));
|
||||
let wc = tc.map(|(x, y)| inv.0[6] * x + inv.0[7] * y + inv.0[8]);
|
||||
@@ -350,7 +403,7 @@ fn warp(src: &Surface, src_rect: Rect, h: &Homography, interp: Interp, clip: Opt
|
||||
}
|
||||
Interp::Bicubic => {
|
||||
let (ix, iy) = (u.floor() as i32, v.floor() as i32);
|
||||
let (wx, wy) = (catmull_rom(u - ix as f64), catmull_rom(v - iy as f64));
|
||||
let (wx, wy) = (bicubic(u - ix as f64), bicubic(v - iy as f64));
|
||||
for (j, wyj) in wy.iter().enumerate() {
|
||||
for (i, wxi) in wx.iter().enumerate() {
|
||||
let k = wxi * wyj;
|
||||
@@ -397,6 +450,9 @@ fn warp(src: &Surface, src_rect: Rect, h: &Homography, interp: Interp, clip: Opt
|
||||
}
|
||||
}
|
||||
let done = done.into_inner().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if ctl.cancelled() {
|
||||
return Ok(out);
|
||||
}
|
||||
for (r, v) in done {
|
||||
out.try_write_region(r, &v)?;
|
||||
}
|
||||
@@ -408,6 +464,25 @@ fn warp(src: &Surface, src_rect: Rect, h: &Homography, interp: Interp, clip: Opt
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_interruptible_warp_reports_progress_and_stops_when_cancelled() {
|
||||
let mut s = Surface::new(PixelFormat::RGBA8);
|
||||
s.fill_rect(Rect::new(0, 0, 600, 400), &[0.2, 0.4, 0.6, 1.0]);
|
||||
let r = s.content_bounds();
|
||||
let h = Homography::rect_to_quad([0.0, 0.0, 600.0, 400.0], [[10.0, 0.0], [590.0, 20.0], [600.0, 400.0], [0.0, 380.0]]).unwrap();
|
||||
let reported = std::sync::Mutex::new(0.0f32);
|
||||
let progress = |p: f32| {
|
||||
let mut m = reported.lock().unwrap();
|
||||
*m = m.max(p);
|
||||
};
|
||||
let never = || false;
|
||||
let out = warp_surface_with(&s, r, &h, Interp::Bicubic, &Interrupt::new(&never, &progress)).unwrap().expect("not cancelled");
|
||||
assert_eq!(out.read_region(r), warp_surface(&s, r, &h, Interp::Bicubic).unwrap().read_region(r));
|
||||
assert!(*reported.lock().unwrap() > 0.9);
|
||||
let always = || true;
|
||||
assert!(warp_surface_with(&s, r, &h, Interp::Bicubic, &Interrupt::cancel_only(&always)).unwrap().is_none());
|
||||
}
|
||||
|
||||
/// Perspective Crop's map lands each quad corner exactly on its rectangle corner; degenerate,
|
||||
/// self-crossing, counter-clockwise and non-finite quads have none.
|
||||
#[test]
|
||||
@@ -436,6 +511,24 @@ mod tests {
|
||||
Surface::new(PixelFormat::RGBA8)
|
||||
}
|
||||
|
||||
/// Photoshop 27.11's Free Transform, Bicubic, 200 % about the centre of a 64 px image whose left
|
||||
/// half is grey 64 and right half grey 192: the row across the edge, columns 26..36.
|
||||
#[test]
|
||||
fn bicubic_upscale_matches_photoshop() {
|
||||
let mut src = rgba();
|
||||
let g = |v: f32| v / 255.0;
|
||||
src.fill_rect(Rect::new(0, 0, 32, 64), &[g(64.0), g(64.0), g(64.0), 1.0]);
|
||||
src.fill_rect(Rect::new(32, 0, 64, 64), &[g(192.0), g(192.0), g(192.0), 1.0]);
|
||||
let q = [[-32.0, -32.0], [96.0, -32.0], [96.0, 96.0], [-32.0, 96.0]];
|
||||
let h = Homography::rect_to_quad([0.0, 0.0, 64.0, 64.0], q).unwrap();
|
||||
let out = warp_surface(&src, Rect::new(0, 0, 64, 64), &h, Interp::Bicubic).unwrap();
|
||||
let photoshop = [64.0, 64.0, 64.0, 60.0, 51.0, 93.0, 163.0, 206.0, 197.0, 192.0];
|
||||
for (i, want) in photoshop.iter().enumerate() {
|
||||
let got = out.pixel(26 + i as i32, 50)[0] * 255.0;
|
||||
assert!((got - want).abs() <= 1.5, "column {}: {got} vs {want}", 26 + i);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn homography_maps_rect_corners_and_inverts() {
|
||||
let r = [10.0, 20.0, 110.0, 70.0];
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! The source rectangle is subdivided into a fine grid whose vertices are mapped forward; each
|
||||
//! cell's two triangles are rasterized in destination space with the source position
|
||||
//! interpolated barycentrically, then the source is sampled there (premultiplied, nearest /
|
||||
//! bilinear / Catmull-Rom bicubic, like Free Transform). Cells are a few source pixels across, so
|
||||
//! bilinear / Photoshop's Keys bicubic, like Free Transform). Cells are a few source pixels across, so
|
||||
//! the piecewise-affine inverse is within a small fraction of a pixel of the true one; affine
|
||||
//! maps (the identity included) are reproduced exactly. Where the warp shrinks the source, each
|
||||
//! pixel instead averages a grid of bilinear subsamples over its source footprint (about one per
|
||||
@@ -15,13 +15,7 @@ use photocraft_color::PixelFormat;
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_raster::Surface;
|
||||
|
||||
use crate::transform::Interp;
|
||||
|
||||
fn catmull_rom(t: f64) -> [f64; 4] {
|
||||
let t2 = t * t;
|
||||
let t3 = t2 * t;
|
||||
[-0.5 * t3 + t2 - 0.5 * t, 1.5 * t3 - 2.5 * t2 + 1.0, -1.5 * t3 + 2.0 * t2 + 0.5 * t, 0.5 * t3 - 0.5 * t2]
|
||||
}
|
||||
use crate::transform::{Interp, bicubic};
|
||||
|
||||
/// Grid cell size (source px) used to approximate the map.
|
||||
const CELL: f64 = 4.0;
|
||||
@@ -290,7 +284,7 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2
|
||||
let snap = |t: f64| if (t - t.round()).abs() < 1e-7 { t.round() } else { t };
|
||||
let (u, v) = (snap(u), snap(v));
|
||||
let (ix, iy) = (u.floor() as i32, v.floor() as i32);
|
||||
let (wx, wy) = (catmull_rom(u - f64::from(ix)), catmull_rom(v - f64::from(iy)));
|
||||
let (wx, wy) = (bicubic(u - f64::from(ix)), bicubic(v - f64::from(iy)));
|
||||
for (j, wyj) in wy.iter().enumerate() {
|
||||
for (i, wxi) in wx.iter().enumerate() {
|
||||
let k = wxi * wyj;
|
||||
|
||||
@@ -230,9 +230,11 @@ impl Headless {
|
||||
(None, Some(p)) => {
|
||||
let own = file_cmds::extension(p);
|
||||
let written = format.map(|f| f.trim_start_matches('.').to_ascii_lowercase()).or_else(|| own.clone());
|
||||
if !file_cmds::saves_in_place(p) || written != own {
|
||||
if !file_cmds::holds_at(&doc, p) || written != own {
|
||||
let lost = file_cmds::losses_at(&doc, p);
|
||||
let why = if lost.is_empty() { String::new() } else { format!(" (it can't keep: {})", lost.join("; ")) };
|
||||
return Err(AutomationError::BadRequest(format!(
|
||||
"pass `path`: without one, only a PSD, PSB or .pcraft file is written back, in its own format, so `{p}` was left unchanged"
|
||||
"pass `path`: without one, only a PSD, PSB, .pcraft or OpenRaster file that holds the whole document is written back, in its own format, so `{p}` was left unchanged{why}"
|
||||
)));
|
||||
}
|
||||
PathBuf::from(p)
|
||||
@@ -255,13 +257,14 @@ impl Headless {
|
||||
}
|
||||
};
|
||||
warnings.extend(photocraft_engine::type_extra_cmds::missing_font_warnings(&doc));
|
||||
// A layered document save (PSD, PSB, OpenRaster or .pcraft) is a full write, not a flattened copy:
|
||||
// record the current revision as saved so the session stops reporting `dirty`, and make
|
||||
// the file the document's path. A flat export (PNG, JPEG, …) is a copy and leaves both
|
||||
// alone (#1547).
|
||||
// A layered document save (PSD, PSB, OpenRaster or .pcraft) that holds the whole document is a
|
||||
// full write, not a flattened copy: record the current revision as saved so the session
|
||||
// stops reporting `dirty`, and make the file the document's path. A flat export (PNG,
|
||||
// JPEG, …), or an OpenRaster file that drops part of it, is a copy and leaves both alone
|
||||
// (#1547, #3806).
|
||||
let ext =
|
||||
format.map(|f| f.trim_start_matches('.').to_ascii_lowercase()).or_else(|| target.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()));
|
||||
if ext.as_deref().is_some_and(file_cmds::layered_extension) {
|
||||
if ext.as_deref().is_some_and(|e| file_cmds::holds(&doc, e)) {
|
||||
// Saving by index must not retarget the next automation command.
|
||||
let previously_active = self.session.active_index();
|
||||
self.session.set_active(i);
|
||||
|
||||
@@ -60,8 +60,13 @@ fn bad(msg: impl Into<String>) -> AutomationError {
|
||||
AutomationError::BadRequest(msg.into())
|
||||
}
|
||||
|
||||
fn index_of(p: &Value) -> Option<usize> {
|
||||
p.get("index").and_then(Value::as_u64).map(|i| i as usize)
|
||||
/// The optional `index` param: missing or null means the active document; anything else must be a
|
||||
/// non-negative integer (never silently fall back to the active document, which `doc.close` would close).
|
||||
fn index_of(p: &Value) -> Result<Option<usize>, AutomationError> {
|
||||
match p.get("index") {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) => v.as_u64().and_then(|i| usize::try_from(i).ok()).map(Some).ok_or_else(|| bad("`index` must be a non-negative integer")),
|
||||
}
|
||||
}
|
||||
|
||||
fn str_of<'a>(p: &'a Value, k: &str) -> Option<&'a str> {
|
||||
@@ -109,20 +114,21 @@ impl Headless {
|
||||
let mut opts = p.as_object().cloned().unwrap_or_default();
|
||||
opts.remove("path");
|
||||
opts.remove("index");
|
||||
self.place(index_of(&p), path, &Value::Object(opts))
|
||||
self.place(index_of(&p)?, path, &Value::Object(opts))
|
||||
}
|
||||
"doc.new" => self.command_run("file.new", p),
|
||||
"doc.save" => {
|
||||
let tga_bits = photocraft_engine::file_cmds::tga_bits_param(&p, "doc.save").map_err(|e| bad(e.to_string()))?;
|
||||
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()))?;
|
||||
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;
|
||||
opts.encode.webp_lossless = false;
|
||||
}
|
||||
let path = str_of(&p, "path").map(PathBuf::from);
|
||||
self.save(index_of(&p), path.as_deref(), str_of(&p, "format"), &opts)
|
||||
self.save(index_of(&p)?, path.as_deref(), str_of(&p, "format"), &opts)
|
||||
}
|
||||
"doc.exportLayers" | "doc.exportDataSets" => {
|
||||
let dir = str_of(&p, "dir").ok_or_else(|| bad(format!("{method} needs `dir`")))?;
|
||||
@@ -130,16 +136,16 @@ impl Headless {
|
||||
opts.remove("dir");
|
||||
opts.remove("index");
|
||||
let opts = Value::Object(opts);
|
||||
if method == "doc.exportLayers" { self.export_layers(index_of(&p), dir, &opts) } else { self.export_data_sets(index_of(&p), dir, &opts) }
|
||||
if method == "doc.exportLayers" { self.export_layers(index_of(&p)?, dir, &opts) } else { self.export_data_sets(index_of(&p)?, dir, &opts) }
|
||||
}
|
||||
"doc.importDataSets" => {
|
||||
let path = str_of(&p, "path").ok_or_else(|| bad("doc.importDataSets needs `path`"))?;
|
||||
let mut opts = p.as_object().cloned().unwrap_or_default();
|
||||
opts.remove("path");
|
||||
opts.remove("index");
|
||||
self.import_data_sets(index_of(&p), path, &Value::Object(opts))
|
||||
self.import_data_sets(index_of(&p)?, path, &Value::Object(opts))
|
||||
}
|
||||
"doc.inspect" => self.inspect(index_of(&p)),
|
||||
"doc.inspect" => self.inspect(index_of(&p)?),
|
||||
"doc.render" => {
|
||||
let max = match p.get("maxSide") {
|
||||
None => 1024,
|
||||
@@ -147,7 +153,7 @@ impl Headless {
|
||||
value.as_u64().and_then(|value| u32::try_from(value).ok()).ok_or_else(|| bad("maxSide must be an unsigned 32-bit integer"))?
|
||||
}
|
||||
};
|
||||
let png = self.render_png(index_of(&p), max)?;
|
||||
let png = self.render_png(index_of(&p)?, max)?;
|
||||
match str_of(&p, "path") {
|
||||
Some(path) => {
|
||||
self.write_render(&PathBuf::from(path), &png)?;
|
||||
@@ -160,10 +166,10 @@ impl Headless {
|
||||
}
|
||||
}
|
||||
"doc.select" => {
|
||||
let i = index_of(&p).ok_or_else(|| bad("doc.select needs `index`"))?;
|
||||
let i = index_of(&p)?.ok_or_else(|| bad("doc.select needs `index`"))?;
|
||||
self.select(i)
|
||||
}
|
||||
"doc.close" => self.close(index_of(&p)),
|
||||
"doc.close" => self.close(index_of(&p)?),
|
||||
"batch" => self.batch(&p),
|
||||
"methods" => Ok(json!(METHODS)),
|
||||
other => Err(bad(format!("unknown method `{other}` (try `methods`)"))),
|
||||
@@ -829,4 +835,27 @@ mod tests {
|
||||
assert_eq!(v["error"], "authentication required");
|
||||
assert!(v.get("result").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_document_index_is_rejected_not_the_active_document() {
|
||||
let h = session();
|
||||
for _ in 0..2 {
|
||||
assert_eq!(respond(&h, r#"{"method":"doc.new","params":{"width":8,"height":8}}"#)["ok"], true);
|
||||
}
|
||||
let count = |h: &Mutex<Headless>| h.lock().unwrap().session_list()["documents"].as_array().unwrap().len();
|
||||
for index in [json!("0"), json!(-1), json!(0.5), json!(true)] {
|
||||
for method in ["doc.close", "doc.inspect", "doc.select"] {
|
||||
let reply = respond(&h, &json!({"method": method, "params": {"index": index}}).to_string());
|
||||
assert_eq!(reply["ok"], false, "{method} {index}: {reply}");
|
||||
assert!(reply["error"].as_str().unwrap().contains("`index` must be a non-negative integer"), "{reply}");
|
||||
}
|
||||
assert_eq!(count(&h), 2, "index {index} closed a document");
|
||||
}
|
||||
// Null means "the active document", like a missing `index`.
|
||||
assert_eq!(respond(&h, r#"{"method":"doc.inspect","params":{"index":null}}"#)["ok"], true);
|
||||
// A valid index still closes that document.
|
||||
let reply = respond(&h, r#"{"method":"doc.close","params":{"index":0}}"#);
|
||||
assert_eq!(reply["ok"], true, "{reply}");
|
||||
assert_eq!(count(&h), 1);
|
||||
}
|
||||
}
|
||||
@@ -167,6 +167,9 @@ pub struct SaveParams {
|
||||
/// alpha, 24 none. Unset: 32 with an alpha channel or transparency, else 24.
|
||||
#[serde(default, rename = "tgaBits")]
|
||||
pub tga_bits: Option<u8>,
|
||||
/// 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>,
|
||||
#[serde(default)]
|
||||
pub index: Option<usize>,
|
||||
}
|
||||
@@ -306,7 +309,7 @@ pub struct UiSetParams {
|
||||
/// Fields for the control method `ui.set`: tool, panels, dock, dockTabs, dockWidth, colorPanel
|
||||
/// ({background: bool} picks which swatch the Color panel edits), maskTarget,
|
||||
/// vectorMaskTarget, selectionMode, zoom, center, rotation (view angle in degrees), fit, theme (pro, proMedium, studio,
|
||||
/// studioLight, classic, solarizedDark, adwaita, adwaitaDark, breezeLight, breezeDark), brushSection, brushTab,
|
||||
/// studioLight, classic, solarizedDark, adwaita, adwaitaDark, breezeLight, breezeDark, softLight, softDark), brushSection, brushTab,
|
||||
/// brushesView, brushPicker ([x, y] opens the Brush Preset picker there, null closes it),
|
||||
/// brushPickerName, brushPickerStroke, brushPickerTip (the picker card's parts, at least one
|
||||
/// on), brushPickerScale (the picker footer's card-scale slider, 0.15 to 2, 1 the standard),
|
||||
@@ -320,7 +323,8 @@ pub struct UiSetParams {
|
||||
/// auto_adjust, classic_mode and auto_center_preview), shapeStroke (an object patching width, align, cap, join, miterLimit,
|
||||
/// dashes, dashOffset and opacity; null disables the stroke). Shape stroke fields use
|
||||
/// `shape.edit` names and units; colour comes from `tools.setColors`. camTransformOnDrop (bool,
|
||||
/// the Content-Aware Move tool's Transform On Drop). Other fields are an error.
|
||||
/// the Content-Aware Move tool's Transform On Drop). tileGrid (bool, a developer overlay of the
|
||||
/// active layer's storage tiles). Other fields are an error.
|
||||
pub fields: Value,
|
||||
}
|
||||
|
||||
@@ -987,6 +991,11 @@ impl PhotocraftMcp {
|
||||
Some((_, Some(bits))) => Some(bits),
|
||||
Some((b, None)) => return Ok(fail(format!("`tgaBits` must be 24 or 32, got {b}"))),
|
||||
};
|
||||
let dpx_bits = match p.dpx_bits.map(|b| (b, photocraft_io::DpxBits::from_bits(u64::from(b)))) {
|
||||
None => None,
|
||||
Some((_, Some(bits))) => Some(bits),
|
||||
Some((b, None)) => return Ok(fail(format!("`dpxBits` must be 8, 10, 12 or 16, got {b}"))),
|
||||
};
|
||||
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
|
||||
@@ -996,6 +1005,7 @@ impl PhotocraftMcp {
|
||||
p.format.is_some().then_some("format"),
|
||||
p.quality.is_some().then_some("quality"),
|
||||
p.tiff_layers.then_some("tiffLayers"),
|
||||
dpx_bits.is_some().then_some("dpxBits"),
|
||||
p.index.is_some().then_some("index"),
|
||||
]
|
||||
.into_iter()
|
||||
@@ -1016,6 +1026,7 @@ impl PhotocraftMcp {
|
||||
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;
|
||||
if let Some(q) = p.quality {
|
||||
opts.encode.jpeg_quality = q.clamp(1, 100);
|
||||
opts.encode.webp_quality = q.clamp(1, 100);
|
||||
|
||||
@@ -858,6 +858,8 @@ mod tests {
|
||||
| "path.clippingPath.set"
|
||||
| "path.rename"
|
||||
| "path.stroke"
|
||||
| "type.attachToPath"
|
||||
| "type.detachFromPath"
|
||||
| "select.toWorkPath"
|
||||
| "layer.vectorMask.add"
|
||||
| "layer.vectorMask.edit"
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
//! Krita documents through the agent surface (`doc.open`, `engine.execute`, `doc.save`, as MCP
|
||||
//! and the CLI use it): open a .kra with its layers, edit it, and save it as OpenRaster.
|
||||
|
||||
use photocraft_automation::Headless;
|
||||
use serde_json::json;
|
||||
|
||||
/// A tiled pixel file with one raw 64x64 tile of `bgra` at (0, 0).
|
||||
fn tiles(bgra: [u8; 4]) -> Vec<u8> {
|
||||
let n = 64 * 64;
|
||||
let mut out = b"VERSION 2\nTILEWIDTH 64\nTILEHEIGHT 64\nPIXELSIZE 4\nDATA 1\n".to_vec();
|
||||
out.extend(format!("0,0,LZF,{}\n", 1 + n * 4).into_bytes());
|
||||
out.push(0);
|
||||
for b in bgra {
|
||||
out.extend(std::iter::repeat_n(b, n));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn kra() -> Vec<u8> {
|
||||
let maindoc = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE DOC PUBLIC '-//KDE//DTD krita 2.0//EN' 'http://www.calligra.org/DTD/krita-2.0.dtd'>
|
||||
<DOC xmlns="http://www.calligra.org/DTD/krita" syntaxVersion="2.0"><IMAGE name="art" width="32" height="24" colorspacename="RGBA" x-res="72" y-res="72"><layers>
|
||||
<layer nodetype="grouplayer" name="Inks" filename="layer1" compositeop="normal" opacity="255" visible="1"><layers>
|
||||
<layer nodetype="paintlayer" name="Line" colorspacename="RGBA" filename="layer2" compositeop="multiply" opacity="204" visible="1"/>
|
||||
</layers></layer>
|
||||
<layer nodetype="shapelayer" name="Speech bubble" filename="layer3"/>
|
||||
<layer nodetype="paintlayer" name="Paper" colorspacename="RGBA" filename="layer4" compositeop="normal" opacity="255" visible="1"/>
|
||||
</layers></IMAGE></DOC>"#;
|
||||
let mut z = photocraft_format::zip::ZipWriter::new();
|
||||
z.add("mimetype", b"application/x-krita").unwrap();
|
||||
z.add("maindoc.xml", maindoc.as_bytes()).unwrap();
|
||||
z.add("art/layers/layer2", &tiles([0, 0, 255, 255])).unwrap();
|
||||
z.add("art/layers/layer4", &tiles([255, 255, 255, 255])).unwrap();
|
||||
z.finish().unwrap()
|
||||
}
|
||||
|
||||
fn exec(h: &mut Headless, command: &str, params: serde_json::Value) -> serde_json::Value {
|
||||
h.handle("engine.execute", json!({"command": command, "params": params})).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn krita_documents_open_with_layers_and_save_as_openraster() {
|
||||
let dir = std::env::temp_dir().join(format!("pc-krita-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("art.kra");
|
||||
std::fs::write(&path, kra()).unwrap();
|
||||
let mut h = Headless::trusted_local();
|
||||
let opened = h.handle("doc.open", json!({"path": path.to_str().unwrap()})).unwrap();
|
||||
let warnings = opened["warnings"].to_string();
|
||||
assert!(warnings.contains("vector layers") && warnings.contains("Speech bubble"), "{opened}");
|
||||
let doc = &h.session.active().unwrap().doc;
|
||||
let names: Vec<&str> = doc.layers.iter().map(|l| l.name.as_str()).collect();
|
||||
assert_eq!(names, ["Paper", "Inks"]);
|
||||
let line = doc.layers[1].children().unwrap()[0].clone();
|
||||
assert_eq!((line.name.as_str(), line.blend), ("Line", photocraft_doc::BlendMode::Multiply));
|
||||
let flat = photocraft_compose::flatten(doc);
|
||||
assert!((flat.px[0][1] - 0.2).abs() < 0.01, "80% red multiplied over white: {:?}", flat.px[0]);
|
||||
|
||||
// Read-only source: the file itself is never overwritten as Krita.
|
||||
assert!(h.handle("doc.save", json!({"path": path.to_str().unwrap()})).is_err());
|
||||
exec(&mut h, "layer.setProps", json!({"layer": line.id.0, "opacity": 0.5}));
|
||||
let ora = dir.join("art.ora");
|
||||
h.handle("doc.save", json!({"path": ora.to_str().unwrap()})).unwrap();
|
||||
let back = photocraft_io::import("art.ora", &std::fs::read(&ora).unwrap()).unwrap().document;
|
||||
assert_eq!(back.layers.len(), 2);
|
||||
assert_eq!(back.layers[1].children().unwrap()[0].opacity, 0.5);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -66,3 +66,40 @@ fn a_corrupt_openraster_file_reports_an_error() {
|
||||
assert!(err.to_string().contains("OpenRaster"), "{err}");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
/// #3806: an OpenRaster file holds only what OpenRaster can. Once the document has an adjustment
|
||||
/// layer, a pathless save refuses to overwrite the .ora (saying what it would drop), and a save to
|
||||
/// an .ora path writes a copy: the document keeps its file and stays unsaved.
|
||||
#[test]
|
||||
fn an_openraster_file_never_overwrites_what_it_cannot_hold() {
|
||||
let dir = tmp("lossy");
|
||||
let path = dir.join("art.ora");
|
||||
let path_str = path.to_str().unwrap();
|
||||
let mut h = Headless::trusted_local();
|
||||
h.handle("doc.new", json!({"width": 40, "height": 30, "background": "white"})).unwrap();
|
||||
exec(&mut h, "layer.new.layer", json!({}));
|
||||
exec(&mut h, "paint.stroke", json!({"points": [[4, 4], [30, 20]], "size": 6, "hardness": 1.0, "color": "#ff0000"}));
|
||||
h.handle("doc.save", json!({"path": path_str})).unwrap();
|
||||
let original = std::fs::read(&path).unwrap();
|
||||
// A Curves adjustment layer: OpenRaster has no adjustment layers.
|
||||
exec(&mut h, "layer.newAdjustmentLayer.curves", json!({}));
|
||||
assert!(h.session.active().unwrap().is_dirty());
|
||||
let err = h.handle("doc.save", json!({})).unwrap_err().to_string();
|
||||
assert!(err.contains("adjustment layers"), "{err}");
|
||||
assert_eq!(std::fs::read(&path).unwrap(), original, "the original is untouched");
|
||||
assert!(h.session.active().unwrap().is_dirty(), "and the edits are still unsaved");
|
||||
// Saving to an .ora path explicitly writes a copy (with the warnings), not the document's file.
|
||||
let copy = dir.join("copy.ora");
|
||||
let saved = h.handle("doc.save", json!({"path": copy.to_str().unwrap()})).unwrap();
|
||||
assert!(saved["warnings"].as_array().is_some_and(|w| w.iter().any(|w| w.as_str().is_some_and(|w| w.contains("adjustment layers")))), "{saved}");
|
||||
let st = h.session.active().unwrap();
|
||||
assert!(st.is_dirty(), "a copy leaves the edits unsaved");
|
||||
assert_eq!(st.path.as_deref(), Some(path_str), "the document keeps its file");
|
||||
// As PSD it holds everything, so that becomes the document's file.
|
||||
let psd = dir.join("art.psd");
|
||||
h.handle("doc.save", json!({"path": psd.to_str().unwrap()})).unwrap();
|
||||
let st = h.session.active().unwrap();
|
||||
assert!(!st.is_dirty());
|
||||
assert_eq!(st.path.as_deref(), psd.to_str());
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
@@ -32,6 +32,10 @@ pub enum Builtin {
|
||||
GrayGamma22,
|
||||
/// Gray with the sRGB transfer function (sGray): gray `v` looks like RGB `(v, v, v)` in sRGB.
|
||||
SGray,
|
||||
/// Gray matching Photoshop's default working gray (Dot Gain 20 %) with our own curve: a pure
|
||||
/// gamma of 1.732, fitted on Photoshop 27.11's RGB → Grayscale (sRGB 64 / 128 / 192 and pure red
|
||||
/// become 46 / 105 / 177 / 107, all within 1 level). Not Adobe's profile.
|
||||
DotGain20Compat,
|
||||
/// CIE L*a*b* D50 identity (device Lab = PCS Lab, ICC v4 encoding).
|
||||
LabD50,
|
||||
/// Synthetic coated-offset CMYK (300 % total ink, medium GCR); see [`crate::synth`].
|
||||
@@ -39,7 +43,7 @@ pub enum Builtin {
|
||||
}
|
||||
|
||||
impl Builtin {
|
||||
pub const ALL: [Builtin; 10] = [
|
||||
pub const ALL: [Builtin; 11] = [
|
||||
Builtin::Srgb,
|
||||
Builtin::DisplayP3,
|
||||
Builtin::AdobeRgbCompat,
|
||||
@@ -48,6 +52,7 @@ impl Builtin {
|
||||
Builtin::Rec2020,
|
||||
Builtin::GrayGamma22,
|
||||
Builtin::SGray,
|
||||
Builtin::DotGain20Compat,
|
||||
Builtin::LabD50,
|
||||
Builtin::CoatedCmyk,
|
||||
];
|
||||
@@ -63,6 +68,7 @@ impl Builtin {
|
||||
Builtin::Rec2020 => "rec2020",
|
||||
Builtin::GrayGamma22 => "gray-gamma-2.2",
|
||||
Builtin::SGray => "sgray",
|
||||
Builtin::DotGain20Compat => "dot-gain-20-compat",
|
||||
Builtin::LabD50 => "lab-d50",
|
||||
Builtin::CoatedCmyk => "coated-cmyk",
|
||||
}
|
||||
@@ -79,6 +85,7 @@ impl Builtin {
|
||||
Builtin::Rec2020 => "Rec. ITU-R BT.2020 (Photocraft)",
|
||||
Builtin::GrayGamma22 => "Gray Gamma 2.2 (Photocraft)",
|
||||
Builtin::SGray => "sGray (sRGB tone curve, Photocraft)",
|
||||
Builtin::DotGain20Compat => "Dot Gain 20%-compatible (gamma 1.732, Photocraft)",
|
||||
Builtin::LabD50 => "Lab D50 identity (Photocraft)",
|
||||
Builtin::CoatedCmyk => crate::synth::DESCRIPTION,
|
||||
}
|
||||
@@ -96,6 +103,7 @@ impl Builtin {
|
||||
"rec2020" | "bt2020" => Some(Builtin::Rec2020),
|
||||
"gray" | "graygamma22" | "gamma22" => Some(Builtin::GrayGamma22),
|
||||
"sgray" | "graysrgb" => Some(Builtin::SGray),
|
||||
"dotgain20" | "dotgain20compat" | "dotgain" => Some(Builtin::DotGain20Compat),
|
||||
"lab" | "labd50" | "cielab" => Some(Builtin::LabD50),
|
||||
"cmyk" | "coatedcmyk" | "defaultcmyk" | "photocraftcoatedcmyk" => Some(Builtin::CoatedCmyk),
|
||||
_ => None,
|
||||
@@ -105,18 +113,19 @@ impl Builtin {
|
||||
|
||||
/// The profile (built once per process).
|
||||
pub fn profile(self) -> &'static Profile {
|
||||
static CELLS: [OnceLock<Profile>; 10] = [const { OnceLock::new() }; 10];
|
||||
static CELLS: [OnceLock<Profile>; 11] = [const { OnceLock::new() }; 11];
|
||||
// `ALL` lists the variants in declaration order (checked by `all_in_declaration_order`),
|
||||
// so the discriminant is the cell index.
|
||||
CELLS[self as usize].get_or_init(|| build(self))
|
||||
}
|
||||
}
|
||||
|
||||
/// The default profile for a colour space: sRGB, sGray, the coated CMYK profile, Lab D50.
|
||||
/// The default profile for a colour space: sRGB, Dot Gain 20 %-compatible gray (Photoshop's default
|
||||
/// working gray), the coated CMYK profile, Lab D50.
|
||||
pub fn default_for(cs: ColorSpace) -> Option<&'static Profile> {
|
||||
Some(match cs {
|
||||
ColorSpace::Rgb => Builtin::Srgb.profile(),
|
||||
ColorSpace::Gray => Builtin::SGray.profile(),
|
||||
ColorSpace::Gray => Builtin::DotGain20Compat.profile(),
|
||||
ColorSpace::Cmyk => Builtin::CoatedCmyk.profile(),
|
||||
ColorSpace::Lab => Builtin::LabD50.profile(),
|
||||
_ => return None,
|
||||
@@ -268,6 +277,7 @@ fn build(b: Builtin) -> Profile {
|
||||
}
|
||||
Builtin::GrayGamma22 => gray(b.description(), Curve::Gamma(2.2)),
|
||||
Builtin::SGray => gray(b.description(), srgb_trc()),
|
||||
Builtin::DotGain20Compat => gray(b.description(), Curve::Gamma(1.732)),
|
||||
Builtin::LabD50 => lab(),
|
||||
// The shipped bytes are `synth::coated_cmyk()` (checked by the `regen` test); rebuild
|
||||
// them rather than crash if they ever fail to parse.
|
||||
|
||||
@@ -40,11 +40,13 @@ 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` |
|
||||
| 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 |
|
||||
| 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` |
|
||||
| Radiance HDR | yes | yes | F32 | RGB | no | no | no | no | no | no | yes (RGBE) | `image` |
|
||||
| FITS | yes | yes | U8, U16, F32 | Gray, RGB | no | no | no | no | no | yes (header cards) | no | built in |
|
||||
| DPX | yes | yes | U8, U16 (10/12-bit code values read and written exactly through U16) | Gray, RGB, RGBA | yes | no | no | no | no | DPX header fields only (creator, project, copyright, creation time, transfer, colorimetric, frame position, frame rate, time code) | no | built in |
|
||||
| AVIF | **no** | only with feature `avif` | U8 | RGB, RGBA | yes | no | no | no | no | no | yes | `image` + `ravif` |
|
||||
| HEIF/HEIC | with feature `heif` | **no** | U8, U16 (10/12-bit decodes to U16) | RGB, RGBA | yes (auxiliary alpha) | yes (`colr` prof) | yes | yes | no | no | n/a | `heic-rs` |
|
||||
|
||||
@@ -66,6 +68,12 @@ Palette BMP pixels decode as RGB in `Image`; `bmp_palette` exposes the embedded
|
||||
|
||||
## Documented asymmetries and limitations
|
||||
|
||||
* **DDS.** The top mip level of a 2D texture: uncompressed pixels described by channel masks
|
||||
(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.
|
||||
|
||||
* **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`.
|
||||
Rows follow `ROWORDER` (bottom-up by default, as Siril and DS9 do). Physical values that fit
|
||||
@@ -74,6 +82,19 @@ Palette BMP pixels decode as RGB in `Image`; `bmp_palette` exposes the embedded
|
||||
kept as text (`HIERARCH` for long names). Tile-compressed (fpack) files are refused with
|
||||
`CodecError::Unsupported`; Bayer mosaics open undebayered.
|
||||
|
||||
* **DPX.** SMPTE 268M frames in either byte order (`SDPX`, `XPDS`): the first image element,
|
||||
descriptor 6 (luma), 50 (RGB) or 51 (RGBA), unsigned and unencoded, at 8, 10, 12 or 16 bits.
|
||||
10-bit data may be packed (0) or filled three to a word (1, method A; 2, method B), 12-bit data
|
||||
packed (0) or one to a 16-bit word (1, method A). 10- and 12-bit code values open as U16
|
||||
`round(c × 65535 / (2^b − 1))`, so a frame written back at its depth keeps them exactly. No
|
||||
colour transform is applied: `DecodeWarning::DpxEncoding` names the transfer characteristic and
|
||||
colorimetric specification, so a log frame is reported as log. More elements than the first
|
||||
give `DecodeWarning::MoreElements`; the image orientation is applied on import. Writing is
|
||||
big-endian, one unencoded element, at `EncodeOptions::dpx_bits` (8/10/12/16; by default 8 for
|
||||
U8 and 16 otherwise), packing 1 for 10 and 12 bits; gray with alpha is written as luma.
|
||||
Other descriptors, signed or run-length-encoded data and other bit depths return
|
||||
`CodecError::Unsupported` naming the field. Cineon (`.cin`) is not supported.
|
||||
|
||||
* **Camera raw files** (DNG, CR2, NEF, ARW… which are TIFF-structured) are recognised and refused
|
||||
with `CodecError::Unsupported`: they are sensor data, not flat images. `photocraft-raw` decodes
|
||||
and develops them, and `photocraft-io` routes them there.
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
//! 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.
|
||||
|
||||
use crate::SampleType;
|
||||
use crate::error::CodecError;
|
||||
use crate::format::Format;
|
||||
use crate::image::{ChannelLayout, Image};
|
||||
use crate::options::Limits;
|
||||
|
||||
/// `DDS ` plus the 124-byte DDS_HEADER.
|
||||
const HEADER: usize = 128;
|
||||
/// DDS_HEADER_DXT10, present when the FourCC is `DX10`.
|
||||
const DX10_HEADER: usize = 20;
|
||||
|
||||
const DDSD_DEPTH: u32 = 0x80_0000;
|
||||
const DDPF_ALPHAPIXELS: u32 = 0x1;
|
||||
const DDPF_FOURCC: u32 = 0x4;
|
||||
const DDPF_RGB: u32 = 0x40;
|
||||
const DDPF_LUMINANCE: u32 = 0x2_0000;
|
||||
const DDSCAPS2_CUBEMAP: u32 = 0x200;
|
||||
const DDSCAPS2_VOLUME: u32 = 0x20_0000;
|
||||
const DIMENSION_TEXTURE2D: u32 = 3;
|
||||
const MISC_TEXTURECUBE: u32 = 0x4;
|
||||
|
||||
fn bad(reason: impl std::fmt::Display) -> CodecError {
|
||||
CodecError::malformed(Format::Dds, reason)
|
||||
}
|
||||
fn unsupported(reason: impl Into<String>) -> CodecError {
|
||||
CodecError::unsupported(Format::Dds, reason)
|
||||
}
|
||||
fn le32(b: &[u8], at: usize) -> Result<u32, CodecError> {
|
||||
let bytes = b.get(at..at + 4).and_then(|s| <[u8; 4]>::try_from(s).ok()).ok_or_else(|| bad("truncated header"))?;
|
||||
Ok(u32::from_le_bytes(bytes))
|
||||
}
|
||||
|
||||
/// A FourCC as text when it is printable, else as the number legacy writers store there.
|
||||
fn fourcc_name(fourcc: u32) -> String {
|
||||
let bytes = fourcc.to_le_bytes();
|
||||
if bytes.iter().all(|c| c.is_ascii_graphic() || *c == b' ') { bytes.iter().map(|&c| char::from(c)).collect() } else { fourcc.to_string() }
|
||||
}
|
||||
|
||||
/// One channel of an uncompressed pixel: where it sits and its largest value.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Field {
|
||||
shift: u32,
|
||||
max: u32,
|
||||
}
|
||||
|
||||
impl Field {
|
||||
/// `None` for an empty mask; an error for one with gaps or wider than the pixel.
|
||||
fn new(mask: u32, bits: u32) -> Result<Option<Field>, CodecError> {
|
||||
if mask == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
let shift = mask.trailing_zeros();
|
||||
let max = mask >> shift;
|
||||
if max & max.wrapping_add(1) != 0 || (bits < 32 && mask >> bits != 0) {
|
||||
return Err(bad(format!("channel mask {mask:#x} is not a contiguous field of a {bits}-bit pixel")));
|
||||
}
|
||||
Ok(Some(Field { shift, max }))
|
||||
}
|
||||
|
||||
fn get(self, pixel: u32) -> u8 {
|
||||
let v = u64::from((pixel >> self.shift) & self.max);
|
||||
let max = u64::from(self.max);
|
||||
((v * 255 + max / 2) / max) as u8
|
||||
}
|
||||
}
|
||||
|
||||
enum Encoding {
|
||||
/// Uncompressed: bytes per pixel, then red (or luminance), green, blue and alpha.
|
||||
Masks {
|
||||
bytes: usize,
|
||||
gray: bool,
|
||||
fields: [Option<Field>; 4],
|
||||
},
|
||||
Bc1,
|
||||
Bc2,
|
||||
Bc3,
|
||||
}
|
||||
|
||||
impl Encoding {
|
||||
fn masks(bits: u32, masks: [u32; 4], gray: bool) -> Result<Encoding, CodecError> {
|
||||
if !matches!(bits, 8 | 16 | 24 | 32) {
|
||||
return Err(bad(format!("{bits} bits per pixel is not 8, 16, 24 or 32")));
|
||||
}
|
||||
let mut fields = [None; 4];
|
||||
for (field, mask) in fields.iter_mut().zip(masks) {
|
||||
*field = Field::new(mask, bits)?;
|
||||
}
|
||||
let colour = if gray { fields[0].is_some() } else { fields[..3].iter().any(Option::is_some) };
|
||||
if !colour {
|
||||
return Err(bad("the pixel format has no colour channel masks"));
|
||||
}
|
||||
Ok(Encoding::Masks { bytes: bits as usize / 8, gray, fields })
|
||||
}
|
||||
|
||||
fn layout(&self) -> ChannelLayout {
|
||||
match self {
|
||||
Encoding::Masks { gray: true, fields, .. } if fields[3].is_some() => ChannelLayout::GrayA,
|
||||
Encoding::Masks { gray: true, .. } => ChannelLayout::Gray,
|
||||
Encoding::Masks { fields, .. } if fields[3].is_none() => ChannelLayout::Rgb,
|
||||
_ => ChannelLayout::Rgba,
|
||||
}
|
||||
}
|
||||
|
||||
/// Bytes of the top mip level.
|
||||
fn level_size(&self, width: u32, height: u32) -> Option<usize> {
|
||||
let (w, h) = (usize::try_from(width).ok()?, usize::try_from(height).ok()?);
|
||||
let block = match self {
|
||||
Encoding::Masks { bytes, .. } => return w.checked_mul(*bytes)?.checked_mul(h),
|
||||
Encoding::Bc1 => 8,
|
||||
Encoding::Bc2 | Encoding::Bc3 => 16,
|
||||
};
|
||||
w.div_ceil(4).checked_mul(h.div_ceil(4))?.checked_mul(block)
|
||||
}
|
||||
}
|
||||
|
||||
/// The legacy pixel format: a FourCC, or channel masks.
|
||||
fn legacy_encoding(b: &[u8]) -> Result<Encoding, CodecError> {
|
||||
let (flags, fourcc, bits) = (le32(b, 80)?, le32(b, 84)?, le32(b, 88)?);
|
||||
let alpha = if flags & DDPF_ALPHAPIXELS != 0 { le32(b, 104)? } else { 0 };
|
||||
if flags & DDPF_FOURCC != 0 {
|
||||
return match &fourcc.to_le_bytes() {
|
||||
b"DXT1" => Ok(Encoding::Bc1),
|
||||
b"DXT3" => Ok(Encoding::Bc2),
|
||||
b"DXT5" => Ok(Encoding::Bc3),
|
||||
b"DXT2" | b"DXT4" => Err(unsupported("premultiplied-alpha DXT2/DXT4 textures are not supported")),
|
||||
_ => Err(unsupported(format!("compression `{}` is not supported (DXT1, DXT3 and DXT5 are)", fourcc_name(fourcc)))),
|
||||
};
|
||||
}
|
||||
if flags & DDPF_RGB != 0 {
|
||||
return Encoding::masks(bits, [le32(b, 92)?, le32(b, 96)?, le32(b, 100)?, alpha], false);
|
||||
}
|
||||
if flags & DDPF_LUMINANCE != 0 {
|
||||
return Encoding::masks(bits, [le32(b, 92)?, 0, 0, alpha], true);
|
||||
}
|
||||
Err(unsupported(format!("pixel format flags {flags:#x} are not RGB, luminance or a supported compression")))
|
||||
}
|
||||
|
||||
/// The DX10 header's DXGI format, refusing anything but a single 2D texture.
|
||||
fn dx10_encoding(b: &[u8]) -> Result<Encoding, CodecError> {
|
||||
let (dxgi, dimension, misc, array) = (le32(b, HEADER)?, le32(b, HEADER + 4)?, le32(b, HEADER + 8)?, le32(b, HEADER + 12)?);
|
||||
if misc & MISC_TEXTURECUBE != 0 {
|
||||
return Err(unsupported("cubemaps are not supported"));
|
||||
}
|
||||
if dimension != DIMENSION_TEXTURE2D {
|
||||
return Err(unsupported(format!("only 2D textures are supported (resource dimension {dimension})")));
|
||||
}
|
||||
if array > 1 {
|
||||
return Err(unsupported(format!("texture arrays are not supported ({array} textures)")));
|
||||
}
|
||||
const RGBA: [u32; 4] = [0xff, 0xff00, 0xff_0000, 0xff00_0000];
|
||||
const BGRA: [u32; 4] = [0xff_0000, 0xff00, 0xff, 0xff00_0000];
|
||||
const BGRX: [u32; 4] = [0xff_0000, 0xff00, 0xff, 0];
|
||||
match dxgi {
|
||||
27..=29 => Encoding::masks(32, RGBA, false),
|
||||
87 | 90 | 91 => Encoding::masks(32, BGRA, false),
|
||||
88 | 92 | 93 => Encoding::masks(32, BGRX, false),
|
||||
70..=72 => Ok(Encoding::Bc1),
|
||||
73..=75 => Ok(Encoding::Bc2),
|
||||
76..=78 => Ok(Encoding::Bc3),
|
||||
_ => Err(unsupported(format!("DXGI format {dxgi} is not supported (BC1, BC2, BC3 and 8-bit RGBA or BGRA are)"))),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn decode(b: &[u8], limits: &Limits) -> Result<Image, CodecError> {
|
||||
if !b.starts_with(b"DDS ") {
|
||||
return Err(bad("missing the `DDS ` signature"));
|
||||
}
|
||||
if le32(b, 4)? != 124 {
|
||||
return Err(bad("header size is not 124"));
|
||||
}
|
||||
let (flags, height, width, depth, caps2) = (le32(b, 8)?, le32(b, 12)?, le32(b, 16)?, le32(b, 24)?, le32(b, 112)?);
|
||||
if caps2 & DDSCAPS2_CUBEMAP != 0 {
|
||||
return Err(unsupported("cubemaps are not supported"));
|
||||
}
|
||||
if caps2 & DDSCAPS2_VOLUME != 0 || (flags & DDSD_DEPTH != 0 && depth > 1) {
|
||||
return Err(unsupported("volume textures are not supported"));
|
||||
}
|
||||
let dx10 = le32(b, 80)? & DDPF_FOURCC != 0 && &le32(b, 84)?.to_le_bytes() == b"DX10";
|
||||
let (encoding, start) = if dx10 { (dx10_encoding(b)?, HEADER + DX10_HEADER) } else { (legacy_encoding(b)?, HEADER) };
|
||||
let layout = encoding.layout();
|
||||
limits.check(width, height, layout, SampleType::U8)?;
|
||||
let size = encoding.level_size(width, height).ok_or_else(|| bad("pixel data size overflow"))?;
|
||||
let data = b.get(start..).and_then(|d| d.get(..size)).ok_or_else(|| bad(format!("truncated pixel data: {size} bytes needed")))?;
|
||||
let (w, h) = (width as usize, height as usize);
|
||||
let channels = layout.channels();
|
||||
let total = w.checked_mul(h).and_then(|n| n.checked_mul(channels)).ok_or_else(|| bad("pixel size overflow"))?;
|
||||
let mut pixels = vec![0u8; total];
|
||||
match encoding {
|
||||
Encoding::Masks { bytes, gray, fields } => {
|
||||
for (src, dst) in data.chunks_exact(bytes).zip(pixels.chunks_exact_mut(channels)) {
|
||||
let pixel = src.iter().rev().fold(0u32, |v, &byte| v << 8 | u32::from(byte));
|
||||
let channel = |i: usize| fields[i].map_or(0, |f| f.get(pixel));
|
||||
let values = if gray { [channel(0), channel(3), 0, 0] } else { [channel(0), channel(1), channel(2), channel(3)] };
|
||||
dst.copy_from_slice(&values[..channels]);
|
||||
}
|
||||
}
|
||||
Encoding::Bc1 | Encoding::Bc2 | Encoding::Bc3 => {
|
||||
let block_size = if matches!(encoding, Encoding::Bc1) { 8 } else { 16 };
|
||||
let blocks_x = w.div_ceil(4);
|
||||
for (i, block) in data.chunks_exact(block_size).enumerate() {
|
||||
let texels = match encoding {
|
||||
Encoding::Bc1 => bc1(block, true),
|
||||
Encoding::Bc2 => with_alpha(bc1(block.get(8..).unwrap_or_default(), false), bc2_alpha(block)),
|
||||
_ => with_alpha(bc1(block.get(8..).unwrap_or_default(), false), bc3_alpha(block)),
|
||||
};
|
||||
let (bx, by) = ((i % blocks_x) * 4, (i / blocks_x) * 4);
|
||||
for (t, texel) in texels.iter().enumerate() {
|
||||
let (x, y) = (bx + t % 4, by + t / 4);
|
||||
if x < w && y < h {
|
||||
let at = (y * w + x) * 4;
|
||||
if let Some(dst) = pixels.get_mut(at..at + 4) {
|
||||
dst.copy_from_slice(texel);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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 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])
|
||||
}
|
||||
|
||||
/// BC2: four explicit bits of alpha per texel.
|
||||
fn bc2_alpha(block: &[u8]) -> [u8; 16] {
|
||||
let bits = u64::from_le_bytes(std::array::from_fn(|i| block.get(i).copied().unwrap_or(0)));
|
||||
std::array::from_fn(|t| ((bits >> (4 * t)) & 0xf) as u8 * 17)
|
||||
}
|
||||
|
||||
/// BC3: two alpha endpoints and a 3-bit index per texel into eight values, or into six plus
|
||||
/// 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 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)
|
||||
}
|
||||
|
||||
fn with_alpha(mut texels: [[u8; 4]; 16], alpha: [u8; 16]) -> [[u8; 4]; 16] {
|
||||
for (texel, a) in texels.iter_mut().zip(alpha) {
|
||||
texel[3] = a;
|
||||
}
|
||||
texels
|
||||
}
|
||||
@@ -0,0 +1,656 @@
|
||||
//! DPX (SMPTE 268M Digital Picture Exchange), implemented here from the published layout: the
|
||||
//! 1664-byte generic header (file and image information, image source), the 384-byte industry
|
||||
//! header (film and television), then the image data of each element.
|
||||
//!
|
||||
//! Reading:
|
||||
//!
|
||||
//! * Both byte orders: `SDPX` (big-endian) and `XPDS` (little-endian).
|
||||
//! * The first image element, descriptor 6 (luma), 50 (RGB) or 51 (RGBA), unsigned, without
|
||||
//! run-length encoding, at 8, 10, 12 or 16 bits. 10-bit samples are packed into 32-bit words
|
||||
//! (packing 0), or three to a word with the padding bits at the bottom (1, method A) or the
|
||||
//! top (2, method B); 12-bit samples are packed (0) or one to a 16-bit word in its top bits
|
||||
//! (1, method A). Lines start on a 32-bit word boundary.
|
||||
//! * 8- and 16-bit code values become U8 and U16 samples unchanged; 10- and 12-bit ones become U16
|
||||
//! as `round(c × 65535 / (2^b − 1))`, so they come back exactly when written at their depth.
|
||||
//! * No colour transform: the transfer characteristic and colorimetric specification are reported
|
||||
//! ([`DecodeWarning::DpxEncoding`]), so a log frame is labelled log, not shown as display-referred.
|
||||
//! * The image orientation is applied on import (the caller turns the image upright).
|
||||
//! * Header fields a pipeline relies on travel as text metadata under the [`KEYS`] names, and a
|
||||
//! DPX export writes them back.
|
||||
//!
|
||||
//! Writing: one element, big-endian, unencoded, descriptor 6, 50 or 51; 8 and 16 bits with
|
||||
//! packing 0, 10 and 12 bits with packing 1 (method A). The bit depth follows the image
|
||||
//! (8 → 8, 16 → 16) unless [`EncodeOptions::dpx_bits`] picks one.
|
||||
|
||||
use crate::Format;
|
||||
use crate::error::CodecError;
|
||||
use crate::fidelity::Plan;
|
||||
use crate::image::{ChannelLayout, DecodeWarning, Image, SampleType};
|
||||
use crate::options::{DpxBits, EncodeOptions, Limits};
|
||||
|
||||
const F: Format = Format::Dpx;
|
||||
/// Generic plus industry header: where our image data starts.
|
||||
const HEADER_LEN: usize = 2048;
|
||||
const GENERIC_LEN: u32 = 1664;
|
||||
const INDUSTRY_LEN: u32 = 384;
|
||||
/// "Undefined" for 32-bit fields (also the bit pattern of an undefined float).
|
||||
const UNDEF32: u32 = u32::MAX;
|
||||
const UNDEF8: u8 = u8::MAX;
|
||||
/// Offset of the first image element and the size of one.
|
||||
const ELEMENT0: usize = 780;
|
||||
const ELEMENT_LEN: usize = 72;
|
||||
const MAX_ELEMENTS: u16 = 8;
|
||||
|
||||
// Header field offsets (generic file header, image information, film and TV headers).
|
||||
const CREATION_TIME: (usize, usize) = (136, 24);
|
||||
const CREATOR: (usize, usize) = (160, 100);
|
||||
const PROJECT: (usize, usize) = (260, 200);
|
||||
const COPYRIGHT: (usize, usize) = (460, 200);
|
||||
const ORIENTATION: usize = 768;
|
||||
const FRAME_POSITION: usize = 1712;
|
||||
const FRAME_RATE: usize = 1724;
|
||||
const TIME_CODE: usize = 1920;
|
||||
|
||||
/// Text metadata keys of the header fields kept through a DPX → document → DPX round trip.
|
||||
pub(crate) const KEY_CREATOR: &str = "DPX:Creator";
|
||||
pub(crate) const KEY_PROJECT: &str = "DPX:Project";
|
||||
pub(crate) const KEY_COPYRIGHT: &str = "DPX:Copyright";
|
||||
pub(crate) const KEY_CREATION_TIME: &str = "DPX:CreationTime";
|
||||
pub(crate) const KEY_TRANSFER: &str = "DPX:Transfer";
|
||||
pub(crate) const KEY_COLORIMETRIC: &str = "DPX:Colorimetric";
|
||||
pub(crate) const KEY_FRAME_POSITION: &str = "DPX:FramePosition";
|
||||
pub(crate) const KEY_FRAME_RATE: &str = "DPX:FrameRate";
|
||||
pub(crate) const KEY_TIME_CODE: &str = "DPX:TimeCode";
|
||||
pub(crate) const KEYS: [&str; 9] =
|
||||
[KEY_CREATOR, KEY_PROJECT, KEY_COPYRIGHT, KEY_CREATION_TIME, KEY_TRANSFER, KEY_COLORIMETRIC, KEY_FRAME_POSITION, KEY_FRAME_RATE, KEY_TIME_CODE];
|
||||
|
||||
fn err(msg: impl std::fmt::Display) -> CodecError {
|
||||
CodecError::malformed(F, msg)
|
||||
}
|
||||
|
||||
fn unsupported(msg: impl Into<String>) -> CodecError {
|
||||
CodecError::unsupported(F, msg)
|
||||
}
|
||||
|
||||
/// The file's byte order.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Order {
|
||||
Big,
|
||||
Little,
|
||||
}
|
||||
|
||||
impl Order {
|
||||
fn bytes<const N: usize>(b: &[u8], at: usize) -> Option<[u8; N]> {
|
||||
b.get(at..at.checked_add(N)?)?.try_into().ok()
|
||||
}
|
||||
|
||||
fn u16(self, b: &[u8], at: usize) -> Option<u16> {
|
||||
let s = Self::bytes::<2>(b, at)?;
|
||||
Some(if self == Order::Big { u16::from_be_bytes(s) } else { u16::from_le_bytes(s) })
|
||||
}
|
||||
|
||||
fn u32(self, b: &[u8], at: usize) -> Option<u32> {
|
||||
let s = Self::bytes::<4>(b, at)?;
|
||||
Some(if self == Order::Big { u32::from_be_bytes(s) } else { u32::from_le_bytes(s) })
|
||||
}
|
||||
|
||||
/// A 32-bit field that may be undefined (all ones).
|
||||
fn defined_u32(self, b: &[u8], at: usize) -> Option<u32> {
|
||||
self.u32(b, at).filter(|&v| v != UNDEF32)
|
||||
}
|
||||
|
||||
fn f32(self, b: &[u8], at: usize) -> Option<f32> {
|
||||
self.defined_u32(b, at).map(f32::from_bits).filter(|v| v.is_finite())
|
||||
}
|
||||
}
|
||||
|
||||
/// The byte order from the magic number, `None` when it is not DPX.
|
||||
fn order(b: &[u8]) -> Option<Order> {
|
||||
match b.get(..4)? {
|
||||
b"SDPX" => Some(Order::Big),
|
||||
b"XPDS" => Some(Order::Little),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// An ASCII header string: up to the first NUL, printable characters only, trimmed. `None` when
|
||||
/// empty or undefined (all ones).
|
||||
fn string(b: &[u8], (at, len): (usize, usize)) -> Option<String> {
|
||||
let raw = b.get(at..at.checked_add(len)?)?;
|
||||
let end = raw.iter().position(|&c| c == 0).unwrap_or(raw.len());
|
||||
let s: String = raw.get(..end)?.iter().filter(|c| (32..=126).contains(*c)).map(|&c| c as char).collect();
|
||||
Some(s.trim().to_string()).filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
/// The descriptive fields of image element 0 that decoding needs.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct Element {
|
||||
signed: bool,
|
||||
descriptor: u8,
|
||||
transfer: u8,
|
||||
colorimetric: u8,
|
||||
bits: u8,
|
||||
packing: u16,
|
||||
encoding: u16,
|
||||
data_offset: usize,
|
||||
eol_padding: usize,
|
||||
}
|
||||
|
||||
struct Header {
|
||||
order: Order,
|
||||
width: u32,
|
||||
height: u32,
|
||||
elements: u16,
|
||||
element: Element,
|
||||
}
|
||||
|
||||
fn parse(b: &[u8]) -> Result<Header, CodecError> {
|
||||
let o = order(b).ok_or_else(|| err("missing SDPX/XPDS magic number"))?;
|
||||
let missing = |what: &str| err(format!("the header ends before {what}"));
|
||||
let image_offset = o.u32(b, 4).ok_or_else(|| missing("the image data offset"))?;
|
||||
o.u16(b, ORIENTATION).ok_or_else(|| missing("the orientation"))?;
|
||||
let elements = o.u16(b, ORIENTATION + 2).ok_or_else(|| missing("the element count"))?;
|
||||
if !(1..=MAX_ELEMENTS).contains(&elements) {
|
||||
return Err(err(format!("invalid number of image elements {elements}")));
|
||||
}
|
||||
let width = o.u32(b, 772).ok_or_else(|| missing("the line length"))?;
|
||||
let height = o.u32(b, 776).ok_or_else(|| missing("the line count"))?;
|
||||
let e = ELEMENT0;
|
||||
let field = |at: usize| o.u32(b, e + at).ok_or_else(|| missing("image element 1"));
|
||||
let byte = |at: usize| b.get(e + at).copied().ok_or_else(|| missing("image element 1"));
|
||||
let half = |at: usize| o.u16(b, e + at).ok_or_else(|| missing("image element 1"));
|
||||
let data_offset = match field(28)? {
|
||||
0 | UNDEF32 => image_offset,
|
||||
v => v,
|
||||
};
|
||||
let eol_padding = match field(32)? {
|
||||
UNDEF32 => 0,
|
||||
v => v,
|
||||
};
|
||||
let element = Element {
|
||||
signed: field(0)? == 1,
|
||||
descriptor: byte(20)?,
|
||||
transfer: byte(21)?,
|
||||
colorimetric: byte(22)?,
|
||||
bits: byte(23)?,
|
||||
packing: half(24)?,
|
||||
encoding: half(26)?,
|
||||
data_offset: usize::try_from(data_offset).map_err(|_| err("image data offset out of range"))?,
|
||||
eol_padding: usize::try_from(eol_padding).map_err(|_| err("line padding out of range"))?,
|
||||
};
|
||||
Ok(Header { order: o, width, height, elements, element })
|
||||
}
|
||||
|
||||
/// The EXIF orientation (1–8) that turns the stored lines upright: the DPX orientation codes 0–7
|
||||
/// in SMPTE 268M order (left-to-right top-to-bottom, mirrored, flipped, rotated, transposed…).
|
||||
/// Anything else, undefined included, is taken as the default (1).
|
||||
pub(crate) fn orientation(b: &[u8]) -> u16 {
|
||||
let Some(o) = order(b).and_then(|o| o.u16(b, ORIENTATION)) else { return 1 };
|
||||
match o {
|
||||
1 => 2,
|
||||
2 => 4,
|
||||
3 => 3,
|
||||
4 => 5,
|
||||
5 => 6,
|
||||
6 => 8,
|
||||
7 => 7,
|
||||
_ => 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// Bytes in one stored line of `samples` samples, padded to a 32-bit word.
|
||||
fn line_bytes(samples: usize, bits: u8, packing: u16) -> Option<usize> {
|
||||
let words = match (bits, packing) {
|
||||
// Three samples to a word, or one to a 16-bit half word.
|
||||
(10, 1 | 2) => samples.div_ceil(3),
|
||||
(12, 1 | 2) => samples.div_ceil(2),
|
||||
_ => samples.checked_mul(usize::from(bits))?.div_ceil(32),
|
||||
};
|
||||
words.checked_mul(4)
|
||||
}
|
||||
|
||||
/// Code values of one stored line.
|
||||
fn unpack_line(line: &[u8], o: Order, bits: u8, packing: u16, out: &mut [u16]) -> Result<(), CodecError> {
|
||||
let short = || err("truncated image data");
|
||||
match (bits, packing) {
|
||||
(8, _) => {
|
||||
let src = line.get(..out.len()).ok_or_else(short)?;
|
||||
for (v, &c) in out.iter_mut().zip(src) {
|
||||
*v = u16::from(c);
|
||||
}
|
||||
}
|
||||
(16, _) => {
|
||||
for (i, v) in out.iter_mut().enumerate() {
|
||||
*v = o.u16(line, i * 2).ok_or_else(short)?;
|
||||
}
|
||||
}
|
||||
(10, 1 | 2) => {
|
||||
let base = if packing == 1 { 22 } else { 20 };
|
||||
for (i, v) in out.iter_mut().enumerate() {
|
||||
let word = o.u32(line, i / 3 * 4).ok_or_else(short)?;
|
||||
*v = ((word >> (base - 10 * (i % 3) as u32)) & 0x3FF) as u16;
|
||||
}
|
||||
}
|
||||
(12, 1 | 2) => {
|
||||
let shift = if packing == 1 { 4 } else { 0 };
|
||||
for (i, v) in out.iter_mut().enumerate() {
|
||||
*v = (o.u16(line, i * 2).ok_or_else(short)? >> shift) & 0xFFF;
|
||||
}
|
||||
}
|
||||
// Packing 0: one bit stream through the line's 32-bit words, high bits first.
|
||||
_ => {
|
||||
let mask = (1u64 << bits) - 1;
|
||||
let (mut acc, mut have, mut word) = (0u64, 0u32, 0usize);
|
||||
for v in out.iter_mut() {
|
||||
while have < u32::from(bits) {
|
||||
acc = (acc << 32) | u64::from(o.u32(line, word * 4).ok_or_else(short)?);
|
||||
word += 1;
|
||||
have += 32;
|
||||
}
|
||||
have -= u32::from(bits);
|
||||
*v = ((acc >> have) & mask) as u16;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A 10- or 12-bit code value as a 16-bit sample: `round(c × 65535 / max)`.
|
||||
fn widen(c: u16, max: u64) -> u16 {
|
||||
((u64::from(c) * 2 * 65535 + max) / (2 * max)).min(65535) as u16
|
||||
}
|
||||
|
||||
/// The inverse of [`widen`]: `round(v × max / 65535)`.
|
||||
fn narrow(v: u16, max: u64) -> u16 {
|
||||
((u64::from(v) * 2 * max + 65535) / (2 * 65535)) as u16
|
||||
}
|
||||
|
||||
pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError> {
|
||||
let h = parse(bytes)?;
|
||||
let e = h.element;
|
||||
let layout = match e.descriptor {
|
||||
6 => ChannelLayout::Gray,
|
||||
50 => ChannelLayout::Rgb,
|
||||
51 => ChannelLayout::Rgba,
|
||||
d => return Err(unsupported(format!("descriptor {d} is not supported (only 6 luma, 50 RGB and 51 RGBA)"))),
|
||||
};
|
||||
if e.signed {
|
||||
return Err(unsupported("data sign 1 (signed samples) is not supported"));
|
||||
}
|
||||
if e.encoding != 0 {
|
||||
return Err(unsupported(format!("encoding {} (run-length encoded data) is not supported", e.encoding)));
|
||||
}
|
||||
let packing_ok = match e.bits {
|
||||
8 | 16 => e.packing == 0,
|
||||
10 => e.packing <= 2,
|
||||
12 => e.packing <= 1,
|
||||
b => return Err(unsupported(format!("bit size {b} is not supported (only 8, 10, 12 and 16)"))),
|
||||
};
|
||||
if !packing_ok {
|
||||
return Err(unsupported(format!("packing {} is not supported at {} bits", e.packing, e.bits)));
|
||||
}
|
||||
let sample = if e.bits == 8 { SampleType::U8 } else { SampleType::U16 };
|
||||
limits.check(h.width, h.height, layout, sample)?;
|
||||
|
||||
let nc = layout.channels();
|
||||
let per_line = (h.width as usize).checked_mul(nc).ok_or_else(|| CodecError::LimitExceeded("line too long".into()))?;
|
||||
let height = h.height as usize;
|
||||
let data = bytes.get(e.data_offset..).ok_or_else(|| err("the image data offset is past the end of the file"))?;
|
||||
let too_large = || CodecError::LimitExceeded("image too large".into());
|
||||
let mut line_len = line_bytes(per_line, e.bits, e.packing).ok_or_else(too_large)?;
|
||||
let mut stride = line_len.checked_add(e.eol_padding).ok_or_else(too_large)?;
|
||||
// Lines run `stride` apart; the last one needs no padding after it.
|
||||
let span = |stride: usize, line_len: usize| stride.checked_mul(height - 1)?.checked_add(line_len);
|
||||
// Some writers pack 8- and 16-bit lines without padding them to a word; take that layout
|
||||
// when the file is too short for padded lines but holds the unpadded ones.
|
||||
let tight = per_line * usize::from(e.bits / 8);
|
||||
if matches!(e.bits, 8 | 16) && e.eol_padding == 0 && line_len != tight && span(stride, line_len).is_none_or(|need| data.len() < need) {
|
||||
(line_len, stride) = (tight, tight);
|
||||
}
|
||||
if span(stride, line_len).ok_or_else(too_large)? > data.len() {
|
||||
return Err(err("truncated image data"));
|
||||
}
|
||||
|
||||
let mut codes = vec![0u16; per_line.checked_mul(height).ok_or_else(too_large)?];
|
||||
for (y, row) in codes.chunks_exact_mut(per_line.max(1)).enumerate() {
|
||||
let start = y * stride;
|
||||
let line = data.get(start..).ok_or_else(|| err("truncated image data"))?;
|
||||
unpack_line(line, h.order, e.bits, e.packing, row)?;
|
||||
}
|
||||
|
||||
let mut img = match e.bits {
|
||||
8 => Image::from_u8(h.width, h.height, layout, codes.iter().map(|&c| c as u8).collect())?,
|
||||
16 => Image::from_u16(h.width, h.height, layout, &codes)?,
|
||||
b => {
|
||||
let max = (1u64 << b) - 1;
|
||||
let wide: Vec<u16> = codes.iter().map(|&c| widen(c, max)).collect();
|
||||
Image::from_u16(h.width, h.height, layout, &wide)?
|
||||
}
|
||||
};
|
||||
if h.elements > 1 {
|
||||
img.warnings.push(DecodeWarning::MoreElements { total: u32::from(h.elements) });
|
||||
}
|
||||
if e.transfer != UNDEF8 || e.colorimetric != UNDEF8 {
|
||||
img.warnings.push(DecodeWarning::DpxEncoding { transfer: e.transfer, colorimetric: e.colorimetric });
|
||||
}
|
||||
img.meta.text = header_text(bytes, h.order, e);
|
||||
Ok(img)
|
||||
}
|
||||
|
||||
/// The kept header fields as text metadata, in [`KEYS`] order; undefined ones are left out.
|
||||
fn header_text(b: &[u8], o: Order, e: Element) -> Vec<(String, String)> {
|
||||
let mut text = Vec::new();
|
||||
let mut put = |k: &str, v: Option<String>| {
|
||||
if let Some(v) = v {
|
||||
text.push((k.to_string(), v));
|
||||
}
|
||||
};
|
||||
put(KEY_CREATOR, string(b, CREATOR));
|
||||
put(KEY_PROJECT, string(b, PROJECT));
|
||||
put(KEY_COPYRIGHT, string(b, COPYRIGHT));
|
||||
put(KEY_CREATION_TIME, string(b, CREATION_TIME));
|
||||
put(KEY_TRANSFER, (e.transfer != UNDEF8).then(|| e.transfer.to_string()));
|
||||
put(KEY_COLORIMETRIC, (e.colorimetric != UNDEF8).then(|| e.colorimetric.to_string()));
|
||||
// The industry header is there when the generic header says so and the file holds it.
|
||||
let industry = o.u32(b, 28).is_some_and(|l| l != 0 && l != UNDEF32) && b.len() >= HEADER_LEN;
|
||||
if industry {
|
||||
put(KEY_FRAME_POSITION, o.defined_u32(b, FRAME_POSITION).map(|v| v.to_string()));
|
||||
put(KEY_FRAME_RATE, o.f32(b, FRAME_RATE).map(|v| v.to_string()));
|
||||
put(KEY_TIME_CODE, o.defined_u32(b, TIME_CODE).map(format_time_code));
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
/// A SMPTE time code (BCD `HHMMSSFF`) as `HH:MM:SS:FF`, or `0x…` when it is not valid BCD.
|
||||
fn format_time_code(v: u32) -> String {
|
||||
let d: [u32; 8] = std::array::from_fn(|i| (v >> ((7 - i) * 4)) & 0xF);
|
||||
if d.iter().all(|&d| d <= 9) { format!("{}{}:{}{}:{}{}:{}{}", d[0], d[1], d[2], d[3], d[4], d[5], d[6], d[7]) } else { format!("{v:#010x}") }
|
||||
}
|
||||
|
||||
/// The inverse of [`format_time_code`]; `None` for anything else.
|
||||
fn parse_time_code(s: &str) -> Option<u32> {
|
||||
if let Some(hex) = s.strip_prefix("0x") {
|
||||
return u32::from_str_radix(hex, 16).ok();
|
||||
}
|
||||
let parts: Vec<&str> = s.split(':').collect();
|
||||
if parts.len() != 4 || parts.iter().any(|p| p.len() != 2 || !p.bytes().all(|c| c.is_ascii_digit())) {
|
||||
return None;
|
||||
}
|
||||
parts.iter().flat_map(|p| p.bytes()).try_fold(0u32, |acc, c| Some((acc << 4) | u32::from(c - b'0')))
|
||||
}
|
||||
|
||||
/// Bits written for `sample` when the options pick none: 8 for U8, 16 otherwise.
|
||||
pub(crate) fn bits_for(opt: Option<DpxBits>, sample: SampleType) -> u8 {
|
||||
match opt {
|
||||
Some(b) => b.bits(),
|
||||
None if sample == SampleType::U8 => 8,
|
||||
None => 16,
|
||||
}
|
||||
}
|
||||
|
||||
/// The plan's sample type for the chosen bits: U8 for 8 bits, U16 for 10, 12 and 16.
|
||||
pub(crate) fn plan_sample(opt: Option<DpxBits>, src: SampleType) -> SampleType {
|
||||
match opt {
|
||||
Some(DpxBits::Bits8) => SampleType::U8,
|
||||
Some(_) => SampleType::U16,
|
||||
None if src == SampleType::U8 => SampleType::U8,
|
||||
None => SampleType::U16,
|
||||
}
|
||||
}
|
||||
|
||||
struct Writer {
|
||||
h: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Writer {
|
||||
fn u8(&mut self, at: usize, v: u8) {
|
||||
if let Some(b) = self.h.get_mut(at) {
|
||||
*b = v;
|
||||
}
|
||||
}
|
||||
|
||||
fn u16(&mut self, at: usize, v: u16) {
|
||||
if let Some(s) = self.h.get_mut(at..at + 2) {
|
||||
s.copy_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
fn u32(&mut self, at: usize, v: u32) {
|
||||
if let Some(s) = self.h.get_mut(at..at + 4) {
|
||||
s.copy_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks a run of numeric fields undefined (all ones).
|
||||
fn undefined(&mut self, from: usize, to: usize) {
|
||||
if let Some(s) = self.h.get_mut(from..to) {
|
||||
s.fill(0xFF);
|
||||
}
|
||||
}
|
||||
|
||||
/// An ASCII string field, cut to leave room for a terminating NUL.
|
||||
fn string(&mut self, (at, len): (usize, usize), v: &str) {
|
||||
let ascii: Vec<u8> = v.chars().map(|c| if (' '..='~').contains(&c) { c as u8 } else { b'?' }).take(len.saturating_sub(1)).collect();
|
||||
if let Some(s) = self.h.get_mut(at..at + ascii.len()) {
|
||||
s.copy_from_slice(&ascii);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn text<'a>(img: &'a Image, key: &str) -> Option<&'a str> {
|
||||
img.meta.text.iter().find(|(k, _)| k == key).map(|(_, v)| v.trim()).filter(|v| !v.is_empty())
|
||||
}
|
||||
|
||||
/// Appends one line's code values, padded to a 32-bit word.
|
||||
fn pack_line(out: &mut Vec<u8>, codes: &[u16], bits: u8) {
|
||||
let start = out.len();
|
||||
match bits {
|
||||
8 => out.extend(codes.iter().map(|&c| c as u8)),
|
||||
10 => {
|
||||
for chunk in codes.chunks(3) {
|
||||
let word = chunk.iter().enumerate().fold(0u32, |w, (i, &c)| w | (u32::from(c & 0x3FF) << (22 - 10 * i as u32)));
|
||||
out.extend_from_slice(&word.to_be_bytes());
|
||||
}
|
||||
}
|
||||
12 => out.extend(codes.iter().flat_map(|&c| ((c & 0xFFF) << 4).to_be_bytes())),
|
||||
_ => out.extend(codes.iter().flat_map(|&c| c.to_be_bytes())),
|
||||
}
|
||||
let written = out.len() - start;
|
||||
out.resize(start + written.div_ceil(4) * 4, 0);
|
||||
}
|
||||
|
||||
pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Vec<u8>, CodecError> {
|
||||
let img = src.converted(plan.layout, plan.sample);
|
||||
let (w, h) = img.dimensions();
|
||||
let (descriptor, layout) = match img.layout() {
|
||||
ChannelLayout::Gray => (6u8, ChannelLayout::Gray),
|
||||
ChannelLayout::Rgb => (50, ChannelLayout::Rgb),
|
||||
ChannelLayout::Rgba => (51, ChannelLayout::Rgba),
|
||||
l => return Err(CodecError::encode(F, format!("DPX cannot store {l:?}"))),
|
||||
};
|
||||
let bits = bits_for(opts.dpx_bits, img.sample_type());
|
||||
let codes: Vec<u16> = match (img.sample_type(), bits) {
|
||||
(SampleType::U8, 8) => img.data().iter().map(|&v| u16::from(v)).collect(),
|
||||
(SampleType::U16, 16) => img.to_u16_samples().unwrap_or_default(),
|
||||
(SampleType::U16, 10 | 12) => {
|
||||
let max = (1u64 << bits) - 1;
|
||||
img.to_u16_samples().unwrap_or_default().into_iter().map(|v| narrow(v, max)).collect()
|
||||
}
|
||||
(s, b) => return Err(CodecError::encode(F, format!("cannot write {s:?} samples at {b} bits"))),
|
||||
};
|
||||
let per_line = w as usize * layout.channels();
|
||||
if codes.len() != per_line * h as usize {
|
||||
return Err(CodecError::encode(F, "the image has the wrong number of samples"));
|
||||
}
|
||||
let data_len = line_bytes(per_line, bits, if matches!(bits, 10 | 12) { 1 } else { 0 })
|
||||
.and_then(|l| l.checked_mul(h as usize))
|
||||
.ok_or_else(|| CodecError::encode(F, "image too large"))?;
|
||||
let total = u32::try_from(HEADER_LEN + data_len).map_err(|_| CodecError::encode(F, "image too large for a DPX file (4 GiB)"))?;
|
||||
|
||||
let mut wr = Writer { h: vec![0; HEADER_LEN] };
|
||||
// Generic file header.
|
||||
wr.h[..4].copy_from_slice(b"SDPX");
|
||||
wr.u32(4, HEADER_LEN as u32);
|
||||
wr.string((8, 8), "V2.0");
|
||||
wr.u32(16, total);
|
||||
wr.u32(20, 1);
|
||||
wr.u32(24, GENERIC_LEN);
|
||||
wr.u32(28, INDUSTRY_LEN);
|
||||
wr.u32(32, 0);
|
||||
let keep = |key| if opts.embed_metadata { text(&img, key) } else { None };
|
||||
wr.string(CREATION_TIME, keep(KEY_CREATION_TIME).unwrap_or(""));
|
||||
wr.string(CREATOR, keep(KEY_CREATOR).unwrap_or("PhotoCraft"));
|
||||
wr.string(PROJECT, keep(KEY_PROJECT).unwrap_or(""));
|
||||
wr.string(COPYRIGHT, keep(KEY_COPYRIGHT).unwrap_or(""));
|
||||
wr.u32(660, UNDEF32);
|
||||
// Image information: default orientation, one element.
|
||||
wr.u16(ORIENTATION, 0);
|
||||
wr.u16(ORIENTATION + 2, 1);
|
||||
wr.u32(772, w);
|
||||
wr.u32(776, h);
|
||||
let e = ELEMENT0;
|
||||
wr.u32(e, 0);
|
||||
wr.u32(e + 4, 0);
|
||||
wr.u32(e + 8, UNDEF32);
|
||||
wr.u32(e + 12, ((1u32 << bits) - 1).min(65535));
|
||||
wr.u32(e + 16, UNDEF32);
|
||||
wr.u8(e + 20, descriptor);
|
||||
// No claim about the encoding unless the document came with one.
|
||||
let code = |key| keep(key).and_then(|v| v.parse::<u8>().ok()).unwrap_or(UNDEF8);
|
||||
wr.u8(e + 21, code(KEY_TRANSFER));
|
||||
wr.u8(e + 22, code(KEY_COLORIMETRIC));
|
||||
wr.u8(e + 23, bits);
|
||||
wr.u16(e + 24, if matches!(bits, 10 | 12) { 1 } else { 0 });
|
||||
wr.u16(e + 26, 0);
|
||||
wr.u32(e + 28, HEADER_LEN as u32);
|
||||
wr.u32(e + 32, 0);
|
||||
wr.u32(e + 36, 0);
|
||||
// The seven unused elements, the image source header's numbers and the industry header's
|
||||
// numbers are undefined; their strings stay empty.
|
||||
wr.undefined(e + ELEMENT_LEN, e + ELEMENT_LEN * usize::from(MAX_ELEMENTS));
|
||||
wr.undefined(1408, 1432);
|
||||
wr.undefined(1620, 1644);
|
||||
wr.undefined(FRAME_POSITION, 1732);
|
||||
wr.undefined(TIME_CODE, 1972);
|
||||
if let Some(v) = keep(KEY_FRAME_POSITION).and_then(|v| v.parse::<u32>().ok()) {
|
||||
wr.u32(FRAME_POSITION, v);
|
||||
}
|
||||
if let Some(v) = keep(KEY_FRAME_RATE).and_then(|v| v.parse::<f32>().ok()).filter(|v| v.is_finite()) {
|
||||
wr.u32(FRAME_RATE, v.to_bits());
|
||||
}
|
||||
if let Some(v) = keep(KEY_TIME_CODE).and_then(parse_time_code) {
|
||||
wr.u32(TIME_CODE, v);
|
||||
}
|
||||
|
||||
let mut out = wr.h;
|
||||
out.try_reserve_exact(data_len).map_err(|_| CodecError::encode(F, "out of memory"))?;
|
||||
for row in codes.chunks_exact(per_line.max(1)) {
|
||||
pack_line(&mut out, row, bits);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A minimal header for `w`×`h` with element 0 set; image data at 2048.
|
||||
fn header(o: Order, w: u32, h: u32, descriptor: u8, bits: u8, packing: u16) -> Vec<u8> {
|
||||
let mut b = vec![0u8; HEADER_LEN];
|
||||
let put32 = |b: &mut Vec<u8>, at: usize, v: u32| b[at..at + 4].copy_from_slice(&if o == Order::Big { v.to_be_bytes() } else { v.to_le_bytes() });
|
||||
let put16 = |b: &mut Vec<u8>, at: usize, v: u16| b[at..at + 2].copy_from_slice(&if o == Order::Big { v.to_be_bytes() } else { v.to_le_bytes() });
|
||||
b[..4].copy_from_slice(if o == Order::Big { b"SDPX" } else { b"XPDS" });
|
||||
put32(&mut b, 4, HEADER_LEN as u32);
|
||||
put32(&mut b, 28, INDUSTRY_LEN);
|
||||
put16(&mut b, ORIENTATION, 0);
|
||||
put16(&mut b, ORIENTATION + 2, 1);
|
||||
put32(&mut b, 772, w);
|
||||
put32(&mut b, 776, h);
|
||||
b[ELEMENT0 + 20] = descriptor;
|
||||
b[ELEMENT0 + 21] = UNDEF8;
|
||||
b[ELEMENT0 + 22] = UNDEF8;
|
||||
b[ELEMENT0 + 23] = bits;
|
||||
put16(&mut b, ELEMENT0 + 24, packing);
|
||||
put32(&mut b, ELEMENT0 + 28, HEADER_LEN as u32);
|
||||
b[FRAME_POSITION..1732].fill(0xFF);
|
||||
b[TIME_CODE..1972].fill(0xFF);
|
||||
b
|
||||
}
|
||||
|
||||
fn words(o: Order, ws: &[u32]) -> Vec<u8> {
|
||||
ws.iter().flat_map(|w| if o == Order::Big { w.to_be_bytes() } else { w.to_le_bytes() }).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ten_bit_method_a_and_b_unpack_three_to_a_word() {
|
||||
for o in [Order::Big, Order::Little] {
|
||||
for (packing, base) in [(1u16, 22u32), (2, 20)] {
|
||||
let mut b = header(o, 1, 1, 50, 10, packing);
|
||||
b.extend(words(o, &[(1023 << base) | (512 << (base - 10)) | (1 << (base - 20))]));
|
||||
let img = decode(&b, &Limits::default()).unwrap();
|
||||
assert_eq!(img.to_u16_samples().unwrap(), vec![65535, widen(512, 1023), widen(1, 1023)], "{o:?} packing {packing}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ten_bit_packing_0_is_one_bit_stream_per_line() {
|
||||
// Four luma samples: 40 bits over two words, the line padded to the second word.
|
||||
let samples = [1u64, 2, 1022, 1023];
|
||||
let stream = samples.iter().fold(0u64, |acc, &s| (acc << 10) | s) << 24;
|
||||
for o in [Order::Big, Order::Little] {
|
||||
let mut b = header(o, 4, 2, 6, 10, 0);
|
||||
let line = words(o, &[(stream >> 32) as u32, stream as u32]);
|
||||
b.extend(&line);
|
||||
b.extend(&line);
|
||||
let img = decode(&b, &Limits::default()).unwrap();
|
||||
let want: Vec<u16> = samples.iter().map(|&s| widen(s as u16, 1023)).collect();
|
||||
assert_eq!(img.to_u16_samples().unwrap(), [want.clone(), want].concat(), "{o:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn twelve_bit_method_a_keeps_the_top_bits() {
|
||||
let mut b = header(Order::Big, 2, 1, 6, 12, 1);
|
||||
b.extend_from_slice(&[0xFF, 0xF0, 0x00, 0x10]);
|
||||
let img = decode(&b, &Limits::default()).unwrap();
|
||||
assert_eq!(img.to_u16_samples().unwrap(), vec![65535, widen(1, 4095)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn widen_and_narrow_round_trip_every_code() {
|
||||
for bits in [10u8, 12] {
|
||||
let max = (1u64 << bits) - 1;
|
||||
for c in 0..=max as u16 {
|
||||
assert_eq!(narrow(widen(c, max), max), c, "{bits}-bit {c}");
|
||||
}
|
||||
}
|
||||
assert_eq!(widen(512, 1023), ((512.0f64 * 65535.0 / 1023.0).round()) as u16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orientation_codes_map_to_exif() {
|
||||
let mut b = header(Order::Big, 1, 1, 6, 8, 0);
|
||||
for (dpx, exif) in [(0u16, 1u16), (1, 2), (2, 4), (3, 3), (4, 5), (5, 6), (6, 8), (7, 7), (0xFFFF, 1)] {
|
||||
b[ORIENTATION..ORIENTATION + 2].copy_from_slice(&dpx.to_be_bytes());
|
||||
assert_eq!(orientation(&b), exif, "DPX {dpx}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_codes_round_trip() {
|
||||
assert_eq!(format_time_code(0x0102_0304), "01:02:03:04");
|
||||
assert_eq!(parse_time_code("01:02:03:04"), Some(0x0102_0304));
|
||||
assert_eq!(parse_time_code(&format_time_code(0xABCD_0000)), Some(0xABCD_0000));
|
||||
assert_eq!(parse_time_code("1:2:3:4"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eight_bit_lines_without_word_padding_still_open() {
|
||||
// 1 × 2 RGB: 3 bytes a line, 6 in all; padded lines would need 8.
|
||||
let mut b = header(Order::Big, 1, 2, 50, 8, 0);
|
||||
b.extend_from_slice(&[1, 2, 3, 4, 5, 6]);
|
||||
let img = decode(&b, &Limits::default()).unwrap();
|
||||
assert_eq!(img.data(), &[1, 2, 3, 4, 5, 6]);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
//! Cryptomatte (specification 1.2): per-pixel object/material IDs with coverage in EXR
|
||||
//! channels, as written by Arnold, V-Ray, Redshift, Mantra/Karma and Cycles. Layers are
|
||||
//! announced by `cryptomatte/<key>/name|hash|conversion|manifest` header attributes; the
|
||||
//! samples live in `<name>NN.red` (ID) / `<name>NN.green` (coverage) channel pairs.
|
||||
//! samples live in `<name>NN` RGBA channel sets, each holding two (ID, coverage) ranks:
|
||||
//! `.red`/`.green` (rank 2·NN) and `.blue`/`.alpha` (rank 2·NN+1).
|
||||
//!
|
||||
//! IDs are the float *values*: MurmurHash3 x86-32 (seed 0) of the object name, its
|
||||
//! exponent fixed away from 0/255 so no ID is denormal, infinite or NaN, read as an f32
|
||||
@@ -20,21 +21,26 @@ use crate::options::Limits;
|
||||
const F: Format = Format::OpenExr;
|
||||
|
||||
/// A Cryptomatte stream channel (`crypto_asset00.red`) splits into its stream name
|
||||
/// (`crypto_asset00`), the layer prefix without digits (`crypto_asset`), the stream index,
|
||||
/// and whether it is the coverage (green) channel instead of the ID (red).
|
||||
/// (`crypto_asset00`), the stream index, whether it is a coverage channel (green/alpha)
|
||||
/// instead of an ID (red/blue), and its rank within the set (0 for red/green, 1 for
|
||||
/// blue/alpha).
|
||||
pub(crate) struct StreamChannel {
|
||||
pub stream: String,
|
||||
pub index: u32,
|
||||
pub coverage: bool,
|
||||
pub rank: usize,
|
||||
}
|
||||
|
||||
/// `<name>NN` where NN is one or more digits (the reference readers match `\d+`), with a
|
||||
/// `.red`/`.r` (ID) or `.green`/`.g` (coverage) suffix.
|
||||
/// `.red`/`.r` or `.blue`/`.b` (ID) or `.green`/`.g` or `.alpha`/`.a` (coverage) suffix,
|
||||
/// in any case (Blender writes `.R`/`.G`/`.B`/`.A`).
|
||||
pub(crate) fn split_stream(channel: &str) -> Option<StreamChannel> {
|
||||
let (stream, suffix) = channel.rsplit_once('.')?;
|
||||
let coverage = match suffix {
|
||||
"red" | "r" => false,
|
||||
"green" | "g" => true,
|
||||
let (coverage, rank) = match suffix.to_ascii_lowercase().as_str() {
|
||||
"red" | "r" => (false, 0),
|
||||
"green" | "g" => (true, 0),
|
||||
"blue" | "b" => (false, 1),
|
||||
"alpha" | "a" => (true, 1),
|
||||
_ => return None,
|
||||
};
|
||||
let digits = stream.chars().rev().take_while(|c| c.is_ascii_digit()).count();
|
||||
@@ -43,7 +49,7 @@ pub(crate) fn split_stream(channel: &str) -> Option<StreamChannel> {
|
||||
}
|
||||
let split = stream.len() - digits;
|
||||
let index = stream[split..].parse().ok()?;
|
||||
Some(StreamChannel { stream: stream.to_string(), index, coverage })
|
||||
Some(StreamChannel { stream: stream.to_string(), index, coverage, rank })
|
||||
}
|
||||
|
||||
/// MurmurHash3, x86-32 bit variant, seed 0 (public-domain algorithm, Austin Appleby).
|
||||
@@ -210,14 +216,17 @@ pub(crate) fn decode(bytes: &[u8], layer_name: &str, limits: &Limits) -> Result<
|
||||
let npx = w.checked_mul(h).ok_or_else(|| CodecError::malformed(F, "the layer is too large"))?;
|
||||
let too_large = || CodecError::malformed(F, "the layer is too large");
|
||||
let (w32, h32) = (u32::try_from(w).map_err(|_| too_large())?, u32::try_from(h).map_err(|_| too_large())?);
|
||||
// Each pixel also gets a pair list, so count its header with the channel samples.
|
||||
let per_pixel = (layer.channels.len().max(1) as u64).saturating_mul(8).saturating_add(std::mem::size_of::<Vec<(f32, f32)>>() as u64);
|
||||
// Each pixel also gets a pair list, so count its header with the channel samples (four
|
||||
// f32 channels per set: two ranks of ID and coverage).
|
||||
let per_pixel = (layer.channels.len().max(1) as u64).saturating_mul(16).saturating_add(std::mem::size_of::<Vec<(f32, f32)>>() as u64);
|
||||
limits.check_bytes(w32, h32, per_pixel)?;
|
||||
|
||||
let ex_layer = super::exr::read_layer(bytes, layer.part)?;
|
||||
|
||||
let mut ids: Vec<Vec<f32>> = vec![Vec::new(); layer.channels.len()];
|
||||
let mut coverages: Vec<Vec<f32>> = vec![Vec::new(); layer.channels.len()];
|
||||
// Two (ID, coverage) ranks per set: rank `slot * 2 + stream.rank`.
|
||||
let ranks = layer.channels.len().saturating_mul(2);
|
||||
let mut ids: Vec<Vec<f32>> = vec![Vec::new(); ranks];
|
||||
let mut coverages: Vec<Vec<f32>> = vec![Vec::new(); ranks];
|
||||
for c in &ex_layer.channel_data.list {
|
||||
let Some(stream) = split_stream(&c.name.to_string()) else { continue };
|
||||
let Some(slot) = layer.channels.iter().position(|s| *s == stream.stream) else { continue };
|
||||
@@ -226,7 +235,8 @@ pub(crate) fn decode(bytes: &[u8], layer_name: &str, limits: &Limits) -> Result<
|
||||
return Err(CodecError::malformed(F, "a Cryptomatte channel is smaller than its layer"));
|
||||
}
|
||||
let bucket = if stream.coverage { &mut coverages } else { &mut ids };
|
||||
bucket[slot] = values;
|
||||
let Some(dst) = slot.checked_mul(2).and_then(|r| r.checked_add(stream.rank)).and_then(|r| bucket.get_mut(r)) else { continue };
|
||||
*dst = values;
|
||||
}
|
||||
|
||||
let mut pixels = vec![Vec::new(); npx];
|
||||
|
||||
@@ -14,10 +14,12 @@
|
||||
//! 65535 when they fit in it (DeepSkyStacker, most stackers) and by the maximum otherwise. NaN
|
||||
//! and blank pixels read as 0.
|
||||
//! * The other header cards are kept as text: `KEY` → value (strings unquoted), `COMMENT` and
|
||||
//! `HISTORY` by their text, `HIERARCH` keywords by their long name.
|
||||
//! `HISTORY` by their text, `HIERARCH` keywords by their long name. Long strings continued
|
||||
//! on `CONTINUE` cards are joined.
|
||||
//!
|
||||
//! Writing: gray or RGB, U8 (`BITPIX = 8`), U16 (`BITPIX = 16`, `BZERO = 32768`) or F32
|
||||
//! (`BITPIX = -32`, 0..1), bottom-to-top with `ROWORDER = 'BOTTOM-UP'`, plus the text cards.
|
||||
//! (`BITPIX = -32`, 0..1), bottom-to-top with `ROWORDER = 'BOTTOM-UP'`, plus the text cards
|
||||
//! (strings longer than one card on `CONTINUE` cards).
|
||||
//! Tile-compressed files (fpack `ZIMAGE` tables) are recognised and refused.
|
||||
|
||||
use crate::Format;
|
||||
@@ -149,7 +151,20 @@ fn parse_header(b: &[u8], start: usize) -> Result<Header, CodecError> {
|
||||
if raw.starts_with(b"END") && raw.get(3..).is_some_and(|r| r.iter().all(|&c| c == b' ')) {
|
||||
break;
|
||||
}
|
||||
cards.push(parse_card(raw));
|
||||
let card = parse_card(raw);
|
||||
// Long strings (FITS 4.0 section 4.2.1.2): a string ending in '&' goes on in the next
|
||||
// `CONTINUE` record. An orphaned `CONTINUE` stays a card of its own (commentary).
|
||||
if let Some(Card { value: Some(Value::Str(s)), .. }) = cards.last_mut()
|
||||
&& s.ends_with('&')
|
||||
&& card.key == "CONTINUE"
|
||||
&& card.text.starts_with(" ")
|
||||
&& let Value::Str(more) = parse_value(&card.text)
|
||||
{
|
||||
s.pop();
|
||||
s.push_str(&more);
|
||||
continue;
|
||||
}
|
||||
cards.push(card);
|
||||
}
|
||||
let data_start = pos.div_ceil(BLOCK).checked_mul(BLOCK).ok_or_else(|| err("header too long"))?;
|
||||
Ok(Header { cards, data_start })
|
||||
@@ -390,9 +405,22 @@ impl Writer {
|
||||
fn value(&mut self, key: &str, v: &str) {
|
||||
if literal(v) {
|
||||
self.card(&format!("{key:<8}= {v:>20}"));
|
||||
} else {
|
||||
self.card(&format!("{key:<8}= {}", quoted(v, CARD - 10)));
|
||||
return;
|
||||
}
|
||||
// A string too long for one card goes on in `CONTINUE` cards, every piece but the last
|
||||
// ending in '&' (FITS 4.0 section 4.2.1.2). A doubled quote is never split, and trailing
|
||||
// blanks (not significant) are dropped so none are kept before an '&'.
|
||||
let escaped = ascii(v).trim_end().replace('\'', "''");
|
||||
let mut rest = escaped.as_str();
|
||||
let mut head = format!("{key:<8}= ");
|
||||
while rest.len() > CARD - 12 {
|
||||
let cut = CARD - 13 - rest.get(..CARD - 13).unwrap_or("").bytes().rev().take_while(|&b| b == b'\'').count() % 2;
|
||||
let (piece, tail) = rest.split_at_checked(cut).unwrap_or((rest, ""));
|
||||
self.card(&format!("{head}'{piece}&'"));
|
||||
head = "CONTINUE ".to_string();
|
||||
rest = tail;
|
||||
}
|
||||
self.card(&format!("{head}'{rest:<8}'"));
|
||||
}
|
||||
|
||||
fn commentary(&mut self, key: &str, text: &str) {
|
||||
@@ -410,7 +438,8 @@ impl Writer {
|
||||
if matches!(upper.as_str(), "COMMENT" | "HISTORY") {
|
||||
self.commentary(&upper, v);
|
||||
} else if standard_key(key) {
|
||||
if !STRUCTURAL.contains(&key) && !is_naxis(key) {
|
||||
// `CONTINUE` only ever carries a string's continuation, written by `value`.
|
||||
if !STRUCTURAL.contains(&key) && !is_naxis(key) && key != "CONTINUE" {
|
||||
self.value(key, v);
|
||||
}
|
||||
} else {
|
||||
@@ -526,6 +555,40 @@ mod tests {
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn continue_strings_decode() {
|
||||
let mut b = header(&[
|
||||
"SIMPLE = T",
|
||||
"BITPIX = 8",
|
||||
"NAXIS = 2",
|
||||
"NAXIS1 = 1",
|
||||
"NAXIS2 = 1",
|
||||
"OBJECT = 'aaa&'",
|
||||
"CONTINUE 'bbb&' / a comment",
|
||||
"CONTINUE '&'",
|
||||
"CONTINUE ''",
|
||||
"OBSERVER= 'R&'",
|
||||
"COMMENT hi",
|
||||
"CONTINUE 'orphan'",
|
||||
]);
|
||||
b.push(0);
|
||||
let img = decode(&b, &Limits::default()).unwrap();
|
||||
let text = [("OBJECT", "aaabbb"), ("OBSERVER", "R&"), ("COMMENT", "hi"), ("CONTINUE", "'orphan'")];
|
||||
assert_eq!(img.meta.text, text.map(|(k, v)| (k.to_string(), v.to_string())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn continue_strings_round_trip() {
|
||||
for value in ["a".repeat(100), format!("{}' {}", "a".repeat(65), "b".repeat(100)), "'".repeat(100), "a".repeat(68)] {
|
||||
let mut img = Image::from_u8(1, 1, ChannelLayout::Gray, vec![0]).unwrap();
|
||||
img.meta.text = vec![("OBJECT".into(), value.clone()), ("CONTINUE".into(), "orphan".into())];
|
||||
let b = crate::encode(&img, F, &EncodeOptions::default()).unwrap();
|
||||
assert!(!b.chunks(CARD).any(|c| c.starts_with(b"CONTINUE=")));
|
||||
let decoded = decode(&b, &Limits::default()).unwrap();
|
||||
assert_eq!(decoded.meta.text, vec![("OBJECT".into(), value)]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn camera_u16_with_bzero_reads_exactly_and_bottom_up() {
|
||||
let mut b = header(&[
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
pub(crate) mod dds;
|
||||
pub(crate) mod deep_exr;
|
||||
pub(crate) mod dpx;
|
||||
pub(crate) mod exr;
|
||||
pub(crate) mod exr_cryptomatte;
|
||||
pub(crate) mod fits;
|
||||
@@ -13,3 +15,4 @@ pub(crate) mod tiff_ifd;
|
||||
pub(crate) mod via_image;
|
||||
pub(crate) mod vp8;
|
||||
pub(crate) mod webp;
|
||||
pub(crate) mod xpm;
|
||||
@@ -0,0 +1,221 @@
|
||||
//! X PixMap reader for XPM2 plain-text icons and XPM3 C-source icon arrays.
|
||||
//! Supports transparency (None), RGB hex at 4/8/16 bits per channel and
|
||||
//! common X11 colour names. Does not execute C code.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
mod x11_colors;
|
||||
|
||||
use crate::SampleType;
|
||||
use crate::error::CodecError;
|
||||
use crate::format::Format;
|
||||
use crate::image::{ChannelLayout, Image};
|
||||
use crate::options::Limits;
|
||||
|
||||
const MAX_SOURCE: usize = 256 << 20;
|
||||
const MAX_COLOURS: usize = 65_536;
|
||||
const MAX_CPP: usize = 8;
|
||||
|
||||
fn bad(msg: impl std::fmt::Display) -> CodecError {
|
||||
CodecError::malformed(Format::Xpm, msg)
|
||||
}
|
||||
|
||||
/// Extract C string literals without treating quoted text or // in a string as a comment.
|
||||
/// XPM uses one literal for the header, each palette entry and each row.
|
||||
fn c_strings(bytes: &[u8]) -> Result<Vec<Vec<u8>>, CodecError> {
|
||||
let mut lines = Vec::new();
|
||||
let mut i = 0usize;
|
||||
while i < bytes.len() {
|
||||
if bytes[i..].starts_with(b"/*") {
|
||||
let tail = &bytes[i + 2..];
|
||||
let Some(end) = tail.windows(2).position(|p| p == b"*/") else { return Err(bad("unterminated C comment")) };
|
||||
i += end + 4;
|
||||
} else if bytes[i..].starts_with(b"//") {
|
||||
i += bytes[i..].iter().position(|&c| c == b'\n').unwrap_or(bytes.len() - i);
|
||||
} else if bytes[i] == b'"' {
|
||||
i += 1;
|
||||
let mut s = Vec::new();
|
||||
let mut closed = false;
|
||||
while i < bytes.len() {
|
||||
let c = bytes[i];
|
||||
i += 1;
|
||||
match c {
|
||||
b'"' => {
|
||||
closed = true;
|
||||
break;
|
||||
}
|
||||
b'\\' => {
|
||||
let next = *bytes.get(i).ok_or_else(|| bad("truncated C escape"))?;
|
||||
i += 1;
|
||||
s.push(match next {
|
||||
b'n' => b'\n',
|
||||
b'r' => b'\r',
|
||||
b't' => b'\t',
|
||||
b'0' => 0,
|
||||
_ => next, // quoted backslash or quote
|
||||
});
|
||||
}
|
||||
_ => s.push(c),
|
||||
}
|
||||
}
|
||||
if !closed {
|
||||
return Err(bad("unterminated XPM string"));
|
||||
}
|
||||
lines.push(s);
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
Ok(lines)
|
||||
}
|
||||
|
||||
/// Extract XPM2's plain-text lines without trimming pixel rows: leading and
|
||||
/// trailing spaces can be palette keys, and physical CRLF is not image data.
|
||||
fn xpm2_lines(bytes: &[u8]) -> Result<Vec<Vec<u8>>, CodecError> {
|
||||
let mut input = bytes.split(|&b| b == b'\n');
|
||||
let header = input.next().ok_or_else(|| bad("missing XPM2 marker"))?;
|
||||
if header.strip_suffix(b"\r").unwrap_or(header) != b"! XPM2" {
|
||||
return Err(bad("not an XPM2 image"));
|
||||
}
|
||||
let mut lines = Vec::new();
|
||||
for raw in input {
|
||||
let line = raw.strip_suffix(b"\r").unwrap_or(raw);
|
||||
// The header may be preceded by comments and blank lines. After the
|
||||
// header, a leading '!' is allowed as a palette key or pixel value.
|
||||
if lines.is_empty() && (line.is_empty() || line.starts_with(b"!")) {
|
||||
continue;
|
||||
}
|
||||
lines.push(line.to_vec());
|
||||
}
|
||||
Ok(lines)
|
||||
}
|
||||
|
||||
fn channel(hex: &str) -> Option<u8> {
|
||||
let v = u32::from_str_radix(hex, 16).ok()?;
|
||||
let max = (1u32 << (hex.len() * 4)) - 1;
|
||||
Some(((v * 255 + max / 2) / max) as u8)
|
||||
}
|
||||
|
||||
fn named(name: &str) -> Option<[u8; 3]> {
|
||||
let s = name.to_ascii_lowercase().replace([' ', '-', '_'], "");
|
||||
// XPM imports X11 names, whose RGB values differ from CSS for names
|
||||
// such as gray, maroon and purple. Keep these exact rather than guessing.
|
||||
if let Ok(index) = x11_colors::X11_COLORS.binary_search_by_key(&s.as_str(), |(name, _)| *name) {
|
||||
return Some(x11_colors::X11_COLORS[index].1);
|
||||
}
|
||||
// Retain useful web color aliases not present in the X11 database.
|
||||
Some(match s.as_str() {
|
||||
"silver" => [192, 192, 192],
|
||||
"olive" => [128, 128, 0],
|
||||
"teal" => [0, 128, 128],
|
||||
"fuchsia" => [255, 0, 255],
|
||||
"navyblue" => [0, 0, 128],
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn color(name: &str) -> Option<[u8; 4]> {
|
||||
if name.eq_ignore_ascii_case("none") {
|
||||
return Some([0, 0, 0, 0]);
|
||||
}
|
||||
let rgb = if let Some(hex) = name.strip_prefix('#') {
|
||||
// UTF-8 byte lengths can match a supported hex width while slicing would panic.
|
||||
// `from_str_radix` would also take a leading `+`: accept hex digits only.
|
||||
if !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
let digits = match hex.len() {
|
||||
3 => 1,
|
||||
6 => 2,
|
||||
12 => 4,
|
||||
_ => return None,
|
||||
};
|
||||
let rgb = (0..3).map(|i| channel(&hex[i * digits..(i + 1) * digits])).collect::<Option<Vec<_>>>()?;
|
||||
[rgb[0], rgb[1], rgb[2]]
|
||||
} else {
|
||||
named(name)?
|
||||
};
|
||||
Some([rgb[0], rgb[1], rgb[2], 255])
|
||||
}
|
||||
|
||||
pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError> {
|
||||
if bytes.len() > MAX_SOURCE {
|
||||
return Err(CodecError::LimitExceeded("XPM source exceeds 256 MiB".into()));
|
||||
}
|
||||
let lines = if bytes.starts_with(b"/* XPM */") {
|
||||
c_strings(bytes)?
|
||||
} else if bytes.starts_with(b"! XPM2\n") || bytes.starts_with(b"! XPM2\r\n") {
|
||||
xpm2_lines(bytes)?
|
||||
} else {
|
||||
return Err(bad("not an XPM2/XPM3 image"));
|
||||
};
|
||||
decode_lines(&lines, limits)
|
||||
}
|
||||
|
||||
fn decode_lines(lines: &[Vec<u8>], limits: &Limits) -> Result<Image, CodecError> {
|
||||
let header = lines.first().ok_or_else(|| bad("missing image header"))?;
|
||||
let parts = std::str::from_utf8(header).map_err(|_| bad("non-UTF-8 XPM header"))?;
|
||||
let mut parts = parts.split_ascii_whitespace();
|
||||
let number = |key: &str, s: Option<&str>| -> Result<usize, CodecError> {
|
||||
s.ok_or_else(|| bad(format!("missing {key}")))?.parse::<usize>().map_err(|_| bad(format!("invalid {key}")))
|
||||
};
|
||||
let width = number("width", parts.next())?;
|
||||
let height = number("height", parts.next())?;
|
||||
let colours = number("palette size", parts.next())?;
|
||||
let cpp = number("chars per pixel", parts.next())?;
|
||||
if width == 0 || height == 0 || colours == 0 || colours > MAX_COLOURS || !(1..=MAX_CPP).contains(&cpp) {
|
||||
return Err(bad("invalid XPM dimensions, palette size or chars per pixel"));
|
||||
}
|
||||
let (w, h) = (u32::try_from(width).map_err(|_| bad("width overflow"))?, u32::try_from(height).map_err(|_| bad("height overflow"))?);
|
||||
limits.check(w, h, ChannelLayout::Rgba, SampleType::U8)?;
|
||||
let required = 1usize.checked_add(colours).and_then(|n| n.checked_add(height)).ok_or_else(|| bad("row count overflow"))?;
|
||||
if lines.len() < required {
|
||||
return Err(bad("truncated palette or image rows"));
|
||||
}
|
||||
let mut palette = HashMap::<Vec<u8>, [u8; 4]>::with_capacity(colours);
|
||||
for line in &lines[1..1 + colours] {
|
||||
if line.len() < cpp {
|
||||
return Err(bad("truncated colour key"));
|
||||
}
|
||||
let fields = std::str::from_utf8(&line[cpp..]).map_err(|_| bad("invalid colour name"))?;
|
||||
// XPM priority is colour, gray, four-level gray, then monochrome,
|
||||
// regardless of the order of keys in the palette entry.
|
||||
let terms: Vec<&str> = fields.split_ascii_whitespace().collect();
|
||||
let mut picked: Option<(u8, String)> = None;
|
||||
let mut i = 0;
|
||||
while i < terms.len() {
|
||||
let priority = match terms[i] {
|
||||
"c" => 4,
|
||||
"g" => 3,
|
||||
"g4" => 2,
|
||||
"m" => 1,
|
||||
_ => 0, // Symbolic names ('s') are not colour values.
|
||||
};
|
||||
let start = i + 1;
|
||||
i = start;
|
||||
while i < terms.len() && !matches!(terms[i], "c" | "g" | "g4" | "m" | "s") {
|
||||
i += 1;
|
||||
}
|
||||
if priority > 0 && i > start && picked.as_ref().is_none_or(|(current, _)| priority > *current) {
|
||||
picked = Some((priority, terms[start..i].join(" ")));
|
||||
}
|
||||
}
|
||||
let value = picked.as_ref().and_then(|(_, name)| color(name)).ok_or_else(|| bad("unsupported or missing XPM colour"))?;
|
||||
if palette.insert(line[..cpp].to_vec(), value).is_some() {
|
||||
return Err(bad("duplicate XPM palette key"));
|
||||
}
|
||||
}
|
||||
let row_len = width.checked_mul(cpp).ok_or_else(|| bad("row byte count overflow"))?;
|
||||
let mut pixels = vec![0u8; width.checked_mul(height).and_then(|n| n.checked_mul(4)).ok_or_else(|| bad("pixel size overflow"))?];
|
||||
for (y, line) in lines[1 + colours..required].iter().enumerate() {
|
||||
if line.len() != row_len {
|
||||
return Err(bad("XPM row has the wrong width"));
|
||||
}
|
||||
for x in 0..width {
|
||||
let key = &line[x * cpp..(x + 1) * cpp];
|
||||
let rgba = palette.get(key).ok_or_else(|| bad("unknown palette key"))?;
|
||||
pixels[(y * width + x) * 4..(y * width + x + 1) * 4].copy_from_slice(rgba);
|
||||
}
|
||||
}
|
||||
Image::from_u8(w, h, ChannelLayout::Rgba, pixels)
|
||||
}
|
||||
@@ -0,0 +1,666 @@
|
||||
//! X11 color-name database (X.Org rgb.txt) normalized to lowercase without
|
||||
//! spaces, underscores, or hyphens. RGB values are *X11* values, not CSS values.
|
||||
//! Source: https://cgit.freedesktop.org/xorg/app/rgb/tree/rgb.txt
|
||||
//! License: MIT (X.Org Foundation), see the upstream app/rgb COPYING file.
|
||||
//! Aliases differing only in case or spaces have been collapsed.
|
||||
//! Keep sorted: decoder uses binary search without allocation per candidate.
|
||||
|
||||
pub(super) const X11_COLORS: &[(&str, [u8; 3])] = &[
|
||||
("aliceblue", [240, 248, 255]),
|
||||
("antiquewhite", [250, 235, 215]),
|
||||
("antiquewhite1", [255, 239, 219]),
|
||||
("antiquewhite2", [238, 223, 204]),
|
||||
("antiquewhite3", [205, 192, 176]),
|
||||
("antiquewhite4", [139, 131, 120]),
|
||||
("aquamarine", [127, 255, 212]),
|
||||
("aquamarine1", [127, 255, 212]),
|
||||
("aquamarine2", [118, 238, 198]),
|
||||
("aquamarine3", [102, 205, 170]),
|
||||
("aquamarine4", [69, 139, 116]),
|
||||
("azure", [240, 255, 255]),
|
||||
("azure1", [240, 255, 255]),
|
||||
("azure2", [224, 238, 238]),
|
||||
("azure3", [193, 205, 205]),
|
||||
("azure4", [131, 139, 139]),
|
||||
("beige", [245, 245, 220]),
|
||||
("bisque", [255, 228, 196]),
|
||||
("bisque1", [255, 228, 196]),
|
||||
("bisque2", [238, 213, 183]),
|
||||
("bisque3", [205, 183, 158]),
|
||||
("bisque4", [139, 125, 107]),
|
||||
("black", [0, 0, 0]),
|
||||
("blanchedalmond", [255, 235, 205]),
|
||||
("blue", [0, 0, 255]),
|
||||
("blue1", [0, 0, 255]),
|
||||
("blue2", [0, 0, 238]),
|
||||
("blue3", [0, 0, 205]),
|
||||
("blue4", [0, 0, 139]),
|
||||
("blueviolet", [138, 43, 226]),
|
||||
("brown", [165, 42, 42]),
|
||||
("brown1", [255, 64, 64]),
|
||||
("brown2", [238, 59, 59]),
|
||||
("brown3", [205, 51, 51]),
|
||||
("brown4", [139, 35, 35]),
|
||||
("burlywood", [222, 184, 135]),
|
||||
("burlywood1", [255, 211, 155]),
|
||||
("burlywood2", [238, 197, 145]),
|
||||
("burlywood3", [205, 170, 125]),
|
||||
("burlywood4", [139, 115, 85]),
|
||||
("cadetblue", [95, 158, 160]),
|
||||
("cadetblue1", [152, 245, 255]),
|
||||
("cadetblue2", [142, 229, 238]),
|
||||
("cadetblue3", [122, 197, 205]),
|
||||
("cadetblue4", [83, 134, 139]),
|
||||
("chartreuse", [127, 255, 0]),
|
||||
("chartreuse1", [127, 255, 0]),
|
||||
("chartreuse2", [118, 238, 0]),
|
||||
("chartreuse3", [102, 205, 0]),
|
||||
("chartreuse4", [69, 139, 0]),
|
||||
("chocolate", [210, 105, 30]),
|
||||
("chocolate1", [255, 127, 36]),
|
||||
("chocolate2", [238, 118, 33]),
|
||||
("chocolate3", [205, 102, 29]),
|
||||
("chocolate4", [139, 69, 19]),
|
||||
("coral", [255, 127, 80]),
|
||||
("coral1", [255, 114, 86]),
|
||||
("coral2", [238, 106, 80]),
|
||||
("coral3", [205, 91, 69]),
|
||||
("coral4", [139, 62, 47]),
|
||||
("cornflowerblue", [100, 149, 237]),
|
||||
("cornsilk", [255, 248, 220]),
|
||||
("cornsilk1", [255, 248, 220]),
|
||||
("cornsilk2", [238, 232, 205]),
|
||||
("cornsilk3", [205, 200, 177]),
|
||||
("cornsilk4", [139, 136, 120]),
|
||||
("cyan", [0, 255, 255]),
|
||||
("cyan1", [0, 255, 255]),
|
||||
("cyan2", [0, 238, 238]),
|
||||
("cyan3", [0, 205, 205]),
|
||||
("cyan4", [0, 139, 139]),
|
||||
("darkblue", [0, 0, 139]),
|
||||
("darkcyan", [0, 139, 139]),
|
||||
("darkgoldenrod", [184, 134, 11]),
|
||||
("darkgoldenrod1", [255, 185, 15]),
|
||||
("darkgoldenrod2", [238, 173, 14]),
|
||||
("darkgoldenrod3", [205, 149, 12]),
|
||||
("darkgoldenrod4", [139, 101, 8]),
|
||||
("darkgray", [169, 169, 169]),
|
||||
("darkgreen", [0, 100, 0]),
|
||||
("darkgrey", [169, 169, 169]),
|
||||
("darkkhaki", [189, 183, 107]),
|
||||
("darkmagenta", [139, 0, 139]),
|
||||
("darkolivegreen", [85, 107, 47]),
|
||||
("darkolivegreen1", [202, 255, 112]),
|
||||
("darkolivegreen2", [188, 238, 104]),
|
||||
("darkolivegreen3", [162, 205, 90]),
|
||||
("darkolivegreen4", [110, 139, 61]),
|
||||
("darkorange", [255, 140, 0]),
|
||||
("darkorange1", [255, 127, 0]),
|
||||
("darkorange2", [238, 118, 0]),
|
||||
("darkorange3", [205, 102, 0]),
|
||||
("darkorange4", [139, 69, 0]),
|
||||
("darkorchid", [153, 50, 204]),
|
||||
("darkorchid1", [191, 62, 255]),
|
||||
("darkorchid2", [178, 58, 238]),
|
||||
("darkorchid3", [154, 50, 205]),
|
||||
("darkorchid4", [104, 34, 139]),
|
||||
("darkred", [139, 0, 0]),
|
||||
("darksalmon", [233, 150, 122]),
|
||||
("darkseagreen", [143, 188, 143]),
|
||||
("darkseagreen1", [193, 255, 193]),
|
||||
("darkseagreen2", [180, 238, 180]),
|
||||
("darkseagreen3", [155, 205, 155]),
|
||||
("darkseagreen4", [105, 139, 105]),
|
||||
("darkslateblue", [72, 61, 139]),
|
||||
("darkslategray", [47, 79, 79]),
|
||||
("darkslategray1", [151, 255, 255]),
|
||||
("darkslategray2", [141, 238, 238]),
|
||||
("darkslategray3", [121, 205, 205]),
|
||||
("darkslategray4", [82, 139, 139]),
|
||||
("darkslategrey", [47, 79, 79]),
|
||||
("darkturquoise", [0, 206, 209]),
|
||||
("darkviolet", [148, 0, 211]),
|
||||
("deeppink", [255, 20, 147]),
|
||||
("deeppink1", [255, 20, 147]),
|
||||
("deeppink2", [238, 18, 137]),
|
||||
("deeppink3", [205, 16, 118]),
|
||||
("deeppink4", [139, 10, 80]),
|
||||
("deepskyblue", [0, 191, 255]),
|
||||
("deepskyblue1", [0, 191, 255]),
|
||||
("deepskyblue2", [0, 178, 238]),
|
||||
("deepskyblue3", [0, 154, 205]),
|
||||
("deepskyblue4", [0, 104, 139]),
|
||||
("dimgray", [105, 105, 105]),
|
||||
("dimgrey", [105, 105, 105]),
|
||||
("dodgerblue", [30, 144, 255]),
|
||||
("dodgerblue1", [30, 144, 255]),
|
||||
("dodgerblue2", [28, 134, 238]),
|
||||
("dodgerblue3", [24, 116, 205]),
|
||||
("dodgerblue4", [16, 78, 139]),
|
||||
("firebrick", [178, 34, 34]),
|
||||
("firebrick1", [255, 48, 48]),
|
||||
("firebrick2", [238, 44, 44]),
|
||||
("firebrick3", [205, 38, 38]),
|
||||
("firebrick4", [139, 26, 26]),
|
||||
("floralwhite", [255, 250, 240]),
|
||||
("forestgreen", [34, 139, 34]),
|
||||
("gainsboro", [220, 220, 220]),
|
||||
("ghostwhite", [248, 248, 255]),
|
||||
("gold", [255, 215, 0]),
|
||||
("gold1", [255, 215, 0]),
|
||||
("gold2", [238, 201, 0]),
|
||||
("gold3", [205, 173, 0]),
|
||||
("gold4", [139, 117, 0]),
|
||||
("goldenrod", [218, 165, 32]),
|
||||
("goldenrod1", [255, 193, 37]),
|
||||
("goldenrod2", [238, 180, 34]),
|
||||
("goldenrod3", [205, 155, 29]),
|
||||
("goldenrod4", [139, 105, 20]),
|
||||
("gray", [190, 190, 190]),
|
||||
("gray0", [0, 0, 0]),
|
||||
("gray1", [3, 3, 3]),
|
||||
("gray10", [26, 26, 26]),
|
||||
("gray100", [255, 255, 255]),
|
||||
("gray11", [28, 28, 28]),
|
||||
("gray12", [31, 31, 31]),
|
||||
("gray13", [33, 33, 33]),
|
||||
("gray14", [36, 36, 36]),
|
||||
("gray15", [38, 38, 38]),
|
||||
("gray16", [41, 41, 41]),
|
||||
("gray17", [43, 43, 43]),
|
||||
("gray18", [46, 46, 46]),
|
||||
("gray19", [48, 48, 48]),
|
||||
("gray2", [5, 5, 5]),
|
||||
("gray20", [51, 51, 51]),
|
||||
("gray21", [54, 54, 54]),
|
||||
("gray22", [56, 56, 56]),
|
||||
("gray23", [59, 59, 59]),
|
||||
("gray24", [61, 61, 61]),
|
||||
("gray25", [64, 64, 64]),
|
||||
("gray26", [66, 66, 66]),
|
||||
("gray27", [69, 69, 69]),
|
||||
("gray28", [71, 71, 71]),
|
||||
("gray29", [74, 74, 74]),
|
||||
("gray3", [8, 8, 8]),
|
||||
("gray30", [77, 77, 77]),
|
||||
("gray31", [79, 79, 79]),
|
||||
("gray32", [82, 82, 82]),
|
||||
("gray33", [84, 84, 84]),
|
||||
("gray34", [87, 87, 87]),
|
||||
("gray35", [89, 89, 89]),
|
||||
("gray36", [92, 92, 92]),
|
||||
("gray37", [94, 94, 94]),
|
||||
("gray38", [97, 97, 97]),
|
||||
("gray39", [99, 99, 99]),
|
||||
("gray4", [10, 10, 10]),
|
||||
("gray40", [102, 102, 102]),
|
||||
("gray41", [105, 105, 105]),
|
||||
("gray42", [107, 107, 107]),
|
||||
("gray43", [110, 110, 110]),
|
||||
("gray44", [112, 112, 112]),
|
||||
("gray45", [115, 115, 115]),
|
||||
("gray46", [117, 117, 117]),
|
||||
("gray47", [120, 120, 120]),
|
||||
("gray48", [122, 122, 122]),
|
||||
("gray49", [125, 125, 125]),
|
||||
("gray5", [13, 13, 13]),
|
||||
("gray50", [127, 127, 127]),
|
||||
("gray51", [130, 130, 130]),
|
||||
("gray52", [133, 133, 133]),
|
||||
("gray53", [135, 135, 135]),
|
||||
("gray54", [138, 138, 138]),
|
||||
("gray55", [140, 140, 140]),
|
||||
("gray56", [143, 143, 143]),
|
||||
("gray57", [145, 145, 145]),
|
||||
("gray58", [148, 148, 148]),
|
||||
("gray59", [150, 150, 150]),
|
||||
("gray6", [15, 15, 15]),
|
||||
("gray60", [153, 153, 153]),
|
||||
("gray61", [156, 156, 156]),
|
||||
("gray62", [158, 158, 158]),
|
||||
("gray63", [161, 161, 161]),
|
||||
("gray64", [163, 163, 163]),
|
||||
("gray65", [166, 166, 166]),
|
||||
("gray66", [168, 168, 168]),
|
||||
("gray67", [171, 171, 171]),
|
||||
("gray68", [173, 173, 173]),
|
||||
("gray69", [176, 176, 176]),
|
||||
("gray7", [18, 18, 18]),
|
||||
("gray70", [179, 179, 179]),
|
||||
("gray71", [181, 181, 181]),
|
||||
("gray72", [184, 184, 184]),
|
||||
("gray73", [186, 186, 186]),
|
||||
("gray74", [189, 189, 189]),
|
||||
("gray75", [191, 191, 191]),
|
||||
("gray76", [194, 194, 194]),
|
||||
("gray77", [196, 196, 196]),
|
||||
("gray78", [199, 199, 199]),
|
||||
("gray79", [201, 201, 201]),
|
||||
("gray8", [20, 20, 20]),
|
||||
("gray80", [204, 204, 204]),
|
||||
("gray81", [207, 207, 207]),
|
||||
("gray82", [209, 209, 209]),
|
||||
("gray83", [212, 212, 212]),
|
||||
("gray84", [214, 214, 214]),
|
||||
("gray85", [217, 217, 217]),
|
||||
("gray86", [219, 219, 219]),
|
||||
("gray87", [222, 222, 222]),
|
||||
("gray88", [224, 224, 224]),
|
||||
("gray89", [227, 227, 227]),
|
||||
("gray9", [23, 23, 23]),
|
||||
("gray90", [229, 229, 229]),
|
||||
("gray91", [232, 232, 232]),
|
||||
("gray92", [235, 235, 235]),
|
||||
("gray93", [237, 237, 237]),
|
||||
("gray94", [240, 240, 240]),
|
||||
("gray95", [242, 242, 242]),
|
||||
("gray96", [245, 245, 245]),
|
||||
("gray97", [247, 247, 247]),
|
||||
("gray98", [250, 250, 250]),
|
||||
("gray99", [252, 252, 252]),
|
||||
("green", [0, 255, 0]),
|
||||
("green1", [0, 255, 0]),
|
||||
("green2", [0, 238, 0]),
|
||||
("green3", [0, 205, 0]),
|
||||
("green4", [0, 139, 0]),
|
||||
("greenyellow", [173, 255, 47]),
|
||||
("grey", [190, 190, 190]),
|
||||
("grey0", [0, 0, 0]),
|
||||
("grey1", [3, 3, 3]),
|
||||
("grey10", [26, 26, 26]),
|
||||
("grey100", [255, 255, 255]),
|
||||
("grey11", [28, 28, 28]),
|
||||
("grey12", [31, 31, 31]),
|
||||
("grey13", [33, 33, 33]),
|
||||
("grey14", [36, 36, 36]),
|
||||
("grey15", [38, 38, 38]),
|
||||
("grey16", [41, 41, 41]),
|
||||
("grey17", [43, 43, 43]),
|
||||
("grey18", [46, 46, 46]),
|
||||
("grey19", [48, 48, 48]),
|
||||
("grey2", [5, 5, 5]),
|
||||
("grey20", [51, 51, 51]),
|
||||
("grey21", [54, 54, 54]),
|
||||
("grey22", [56, 56, 56]),
|
||||
("grey23", [59, 59, 59]),
|
||||
("grey24", [61, 61, 61]),
|
||||
("grey25", [64, 64, 64]),
|
||||
("grey26", [66, 66, 66]),
|
||||
("grey27", [69, 69, 69]),
|
||||
("grey28", [71, 71, 71]),
|
||||
("grey29", [74, 74, 74]),
|
||||
("grey3", [8, 8, 8]),
|
||||
("grey30", [77, 77, 77]),
|
||||
("grey31", [79, 79, 79]),
|
||||
("grey32", [82, 82, 82]),
|
||||
("grey33", [84, 84, 84]),
|
||||
("grey34", [87, 87, 87]),
|
||||
("grey35", [89, 89, 89]),
|
||||
("grey36", [92, 92, 92]),
|
||||
("grey37", [94, 94, 94]),
|
||||
("grey38", [97, 97, 97]),
|
||||
("grey39", [99, 99, 99]),
|
||||
("grey4", [10, 10, 10]),
|
||||
("grey40", [102, 102, 102]),
|
||||
("grey41", [105, 105, 105]),
|
||||
("grey42", [107, 107, 107]),
|
||||
("grey43", [110, 110, 110]),
|
||||
("grey44", [112, 112, 112]),
|
||||
("grey45", [115, 115, 115]),
|
||||
("grey46", [117, 117, 117]),
|
||||
("grey47", [120, 120, 120]),
|
||||
("grey48", [122, 122, 122]),
|
||||
("grey49", [125, 125, 125]),
|
||||
("grey5", [13, 13, 13]),
|
||||
("grey50", [127, 127, 127]),
|
||||
("grey51", [130, 130, 130]),
|
||||
("grey52", [133, 133, 133]),
|
||||
("grey53", [135, 135, 135]),
|
||||
("grey54", [138, 138, 138]),
|
||||
("grey55", [140, 140, 140]),
|
||||
("grey56", [143, 143, 143]),
|
||||
("grey57", [145, 145, 145]),
|
||||
("grey58", [148, 148, 148]),
|
||||
("grey59", [150, 150, 150]),
|
||||
("grey6", [15, 15, 15]),
|
||||
("grey60", [153, 153, 153]),
|
||||
("grey61", [156, 156, 156]),
|
||||
("grey62", [158, 158, 158]),
|
||||
("grey63", [161, 161, 161]),
|
||||
("grey64", [163, 163, 163]),
|
||||
("grey65", [166, 166, 166]),
|
||||
("grey66", [168, 168, 168]),
|
||||
("grey67", [171, 171, 171]),
|
||||
("grey68", [173, 173, 173]),
|
||||
("grey69", [176, 176, 176]),
|
||||
("grey7", [18, 18, 18]),
|
||||
("grey70", [179, 179, 179]),
|
||||
("grey71", [181, 181, 181]),
|
||||
("grey72", [184, 184, 184]),
|
||||
("grey73", [186, 186, 186]),
|
||||
("grey74", [189, 189, 189]),
|
||||
("grey75", [191, 191, 191]),
|
||||
("grey76", [194, 194, 194]),
|
||||
("grey77", [196, 196, 196]),
|
||||
("grey78", [199, 199, 199]),
|
||||
("grey79", [201, 201, 201]),
|
||||
("grey8", [20, 20, 20]),
|
||||
("grey80", [204, 204, 204]),
|
||||
("grey81", [207, 207, 207]),
|
||||
("grey82", [209, 209, 209]),
|
||||
("grey83", [212, 212, 212]),
|
||||
("grey84", [214, 214, 214]),
|
||||
("grey85", [217, 217, 217]),
|
||||
("grey86", [219, 219, 219]),
|
||||
("grey87", [222, 222, 222]),
|
||||
("grey88", [224, 224, 224]),
|
||||
("grey89", [227, 227, 227]),
|
||||
("grey9", [23, 23, 23]),
|
||||
("grey90", [229, 229, 229]),
|
||||
("grey91", [232, 232, 232]),
|
||||
("grey92", [235, 235, 235]),
|
||||
("grey93", [237, 237, 237]),
|
||||
("grey94", [240, 240, 240]),
|
||||
("grey95", [242, 242, 242]),
|
||||
("grey96", [245, 245, 245]),
|
||||
("grey97", [247, 247, 247]),
|
||||
("grey98", [250, 250, 250]),
|
||||
("grey99", [252, 252, 252]),
|
||||
("honeydew", [240, 255, 240]),
|
||||
("honeydew1", [240, 255, 240]),
|
||||
("honeydew2", [224, 238, 224]),
|
||||
("honeydew3", [193, 205, 193]),
|
||||
("honeydew4", [131, 139, 131]),
|
||||
("hotpink", [255, 105, 180]),
|
||||
("hotpink1", [255, 110, 180]),
|
||||
("hotpink2", [238, 106, 167]),
|
||||
("hotpink3", [205, 96, 144]),
|
||||
("hotpink4", [139, 58, 98]),
|
||||
("indianred", [205, 92, 92]),
|
||||
("indianred1", [255, 106, 106]),
|
||||
("indianred2", [238, 99, 99]),
|
||||
("indianred3", [205, 85, 85]),
|
||||
("indianred4", [139, 58, 58]),
|
||||
("ivory", [255, 255, 240]),
|
||||
("ivory1", [255, 255, 240]),
|
||||
("ivory2", [238, 238, 224]),
|
||||
("ivory3", [205, 205, 193]),
|
||||
("ivory4", [139, 139, 131]),
|
||||
("khaki", [240, 230, 140]),
|
||||
("khaki1", [255, 246, 143]),
|
||||
("khaki2", [238, 230, 133]),
|
||||
("khaki3", [205, 198, 115]),
|
||||
("khaki4", [139, 134, 78]),
|
||||
("lavender", [230, 230, 250]),
|
||||
("lavenderblush", [255, 240, 245]),
|
||||
("lavenderblush1", [255, 240, 245]),
|
||||
("lavenderblush2", [238, 224, 229]),
|
||||
("lavenderblush3", [205, 193, 197]),
|
||||
("lavenderblush4", [139, 131, 134]),
|
||||
("lawngreen", [124, 252, 0]),
|
||||
("lemonchiffon", [255, 250, 205]),
|
||||
("lemonchiffon1", [255, 250, 205]),
|
||||
("lemonchiffon2", [238, 233, 191]),
|
||||
("lemonchiffon3", [205, 201, 165]),
|
||||
("lemonchiffon4", [139, 137, 112]),
|
||||
("lightblue", [173, 216, 230]),
|
||||
("lightblue1", [191, 239, 255]),
|
||||
("lightblue2", [178, 223, 238]),
|
||||
("lightblue3", [154, 192, 205]),
|
||||
("lightblue4", [104, 131, 139]),
|
||||
("lightcoral", [240, 128, 128]),
|
||||
("lightcyan", [224, 255, 255]),
|
||||
("lightcyan1", [224, 255, 255]),
|
||||
("lightcyan2", [209, 238, 238]),
|
||||
("lightcyan3", [180, 205, 205]),
|
||||
("lightcyan4", [122, 139, 139]),
|
||||
("lightgoldenrod", [238, 221, 130]),
|
||||
("lightgoldenrod1", [255, 236, 139]),
|
||||
("lightgoldenrod2", [238, 220, 130]),
|
||||
("lightgoldenrod3", [205, 190, 112]),
|
||||
("lightgoldenrod4", [139, 129, 76]),
|
||||
("lightgoldenrodyellow", [250, 250, 210]),
|
||||
("lightgray", [211, 211, 211]),
|
||||
("lightgreen", [144, 238, 144]),
|
||||
("lightgrey", [211, 211, 211]),
|
||||
("lightpink", [255, 182, 193]),
|
||||
("lightpink1", [255, 174, 185]),
|
||||
("lightpink2", [238, 162, 173]),
|
||||
("lightpink3", [205, 140, 149]),
|
||||
("lightpink4", [139, 95, 101]),
|
||||
("lightsalmon", [255, 160, 122]),
|
||||
("lightsalmon1", [255, 160, 122]),
|
||||
("lightsalmon2", [238, 149, 114]),
|
||||
("lightsalmon3", [205, 129, 98]),
|
||||
("lightsalmon4", [139, 87, 66]),
|
||||
("lightseagreen", [32, 178, 170]),
|
||||
("lightskyblue", [135, 206, 250]),
|
||||
("lightskyblue1", [176, 226, 255]),
|
||||
("lightskyblue2", [164, 211, 238]),
|
||||
("lightskyblue3", [141, 182, 205]),
|
||||
("lightskyblue4", [96, 123, 139]),
|
||||
("lightslateblue", [132, 112, 255]),
|
||||
("lightslategray", [119, 136, 153]),
|
||||
("lightslategrey", [119, 136, 153]),
|
||||
("lightsteelblue", [176, 196, 222]),
|
||||
("lightsteelblue1", [202, 225, 255]),
|
||||
("lightsteelblue2", [188, 210, 238]),
|
||||
("lightsteelblue3", [162, 181, 205]),
|
||||
("lightsteelblue4", [110, 123, 139]),
|
||||
("lightyellow", [255, 255, 224]),
|
||||
("lightyellow1", [255, 255, 224]),
|
||||
("lightyellow2", [238, 238, 209]),
|
||||
("lightyellow3", [205, 205, 180]),
|
||||
("lightyellow4", [139, 139, 122]),
|
||||
("limegreen", [50, 205, 50]),
|
||||
("linen", [250, 240, 230]),
|
||||
("magenta", [255, 0, 255]),
|
||||
("magenta1", [255, 0, 255]),
|
||||
("magenta2", [238, 0, 238]),
|
||||
("magenta3", [205, 0, 205]),
|
||||
("magenta4", [139, 0, 139]),
|
||||
("maroon", [176, 48, 96]),
|
||||
("maroon1", [255, 52, 179]),
|
||||
("maroon2", [238, 48, 167]),
|
||||
("maroon3", [205, 41, 144]),
|
||||
("maroon4", [139, 28, 98]),
|
||||
("mediumaquamarine", [102, 205, 170]),
|
||||
("mediumblue", [0, 0, 205]),
|
||||
("mediumorchid", [186, 85, 211]),
|
||||
("mediumorchid1", [224, 102, 255]),
|
||||
("mediumorchid2", [209, 95, 238]),
|
||||
("mediumorchid3", [180, 82, 205]),
|
||||
("mediumorchid4", [122, 55, 139]),
|
||||
("mediumpurple", [147, 112, 219]),
|
||||
("mediumpurple1", [171, 130, 255]),
|
||||
("mediumpurple2", [159, 121, 238]),
|
||||
("mediumpurple3", [137, 104, 205]),
|
||||
("mediumpurple4", [93, 71, 139]),
|
||||
("mediumseagreen", [60, 179, 113]),
|
||||
("mediumslateblue", [123, 104, 238]),
|
||||
("mediumspringgreen", [0, 250, 154]),
|
||||
("mediumturquoise", [72, 209, 204]),
|
||||
("mediumvioletred", [199, 21, 133]),
|
||||
("midnightblue", [25, 25, 112]),
|
||||
("mintcream", [245, 255, 250]),
|
||||
("mistyrose", [255, 228, 225]),
|
||||
("mistyrose1", [255, 228, 225]),
|
||||
("mistyrose2", [238, 213, 210]),
|
||||
("mistyrose3", [205, 183, 181]),
|
||||
("mistyrose4", [139, 125, 123]),
|
||||
("moccasin", [255, 228, 181]),
|
||||
("navajowhite", [255, 222, 173]),
|
||||
("navajowhite1", [255, 222, 173]),
|
||||
("navajowhite2", [238, 207, 161]),
|
||||
("navajowhite3", [205, 179, 139]),
|
||||
("navajowhite4", [139, 121, 94]),
|
||||
("navy", [0, 0, 128]),
|
||||
("navyblue", [0, 0, 128]),
|
||||
("oldlace", [253, 245, 230]),
|
||||
("olivedrab", [107, 142, 35]),
|
||||
("olivedrab1", [192, 255, 62]),
|
||||
("olivedrab2", [179, 238, 58]),
|
||||
("olivedrab3", [154, 205, 50]),
|
||||
("olivedrab4", [105, 139, 34]),
|
||||
("orange", [255, 165, 0]),
|
||||
("orange1", [255, 165, 0]),
|
||||
("orange2", [238, 154, 0]),
|
||||
("orange3", [205, 133, 0]),
|
||||
("orange4", [139, 90, 0]),
|
||||
("orangered", [255, 69, 0]),
|
||||
("orangered1", [255, 69, 0]),
|
||||
("orangered2", [238, 64, 0]),
|
||||
("orangered3", [205, 55, 0]),
|
||||
("orangered4", [139, 37, 0]),
|
||||
("orchid", [218, 112, 214]),
|
||||
("orchid1", [255, 131, 250]),
|
||||
("orchid2", [238, 122, 233]),
|
||||
("orchid3", [205, 105, 201]),
|
||||
("orchid4", [139, 71, 137]),
|
||||
("palegoldenrod", [238, 232, 170]),
|
||||
("palegreen", [152, 251, 152]),
|
||||
("palegreen1", [154, 255, 154]),
|
||||
("palegreen2", [144, 238, 144]),
|
||||
("palegreen3", [124, 205, 124]),
|
||||
("palegreen4", [84, 139, 84]),
|
||||
("paleturquoise", [175, 238, 238]),
|
||||
("paleturquoise1", [187, 255, 255]),
|
||||
("paleturquoise2", [174, 238, 238]),
|
||||
("paleturquoise3", [150, 205, 205]),
|
||||
("paleturquoise4", [102, 139, 139]),
|
||||
("palevioletred", [219, 112, 147]),
|
||||
("palevioletred1", [255, 130, 171]),
|
||||
("palevioletred2", [238, 121, 159]),
|
||||
("palevioletred3", [205, 104, 137]),
|
||||
("palevioletred4", [139, 71, 93]),
|
||||
("papayawhip", [255, 239, 213]),
|
||||
("peachpuff", [255, 218, 185]),
|
||||
("peachpuff1", [255, 218, 185]),
|
||||
("peachpuff2", [238, 203, 173]),
|
||||
("peachpuff3", [205, 175, 149]),
|
||||
("peachpuff4", [139, 119, 101]),
|
||||
("peru", [205, 133, 63]),
|
||||
("pink", [255, 192, 203]),
|
||||
("pink1", [255, 181, 197]),
|
||||
("pink2", [238, 169, 184]),
|
||||
("pink3", [205, 145, 158]),
|
||||
("pink4", [139, 99, 108]),
|
||||
("plum", [221, 160, 221]),
|
||||
("plum1", [255, 187, 255]),
|
||||
("plum2", [238, 174, 238]),
|
||||
("plum3", [205, 150, 205]),
|
||||
("plum4", [139, 102, 139]),
|
||||
("powderblue", [176, 224, 230]),
|
||||
("purple", [160, 32, 240]),
|
||||
("purple1", [155, 48, 255]),
|
||||
("purple2", [145, 44, 238]),
|
||||
("purple3", [125, 38, 205]),
|
||||
("purple4", [85, 26, 139]),
|
||||
("red", [255, 0, 0]),
|
||||
("red1", [255, 0, 0]),
|
||||
("red2", [238, 0, 0]),
|
||||
("red3", [205, 0, 0]),
|
||||
("red4", [139, 0, 0]),
|
||||
("rosybrown", [188, 143, 143]),
|
||||
("rosybrown1", [255, 193, 193]),
|
||||
("rosybrown2", [238, 180, 180]),
|
||||
("rosybrown3", [205, 155, 155]),
|
||||
("rosybrown4", [139, 105, 105]),
|
||||
("royalblue", [65, 105, 225]),
|
||||
("royalblue1", [72, 118, 255]),
|
||||
("royalblue2", [67, 110, 238]),
|
||||
("royalblue3", [58, 95, 205]),
|
||||
("royalblue4", [39, 64, 139]),
|
||||
("saddlebrown", [139, 69, 19]),
|
||||
("salmon", [250, 128, 114]),
|
||||
("salmon1", [255, 140, 105]),
|
||||
("salmon2", [238, 130, 98]),
|
||||
("salmon3", [205, 112, 84]),
|
||||
("salmon4", [139, 76, 57]),
|
||||
("sandybrown", [244, 164, 96]),
|
||||
("seagreen", [46, 139, 87]),
|
||||
("seagreen1", [84, 255, 159]),
|
||||
("seagreen2", [78, 238, 148]),
|
||||
("seagreen3", [67, 205, 128]),
|
||||
("seagreen4", [46, 139, 87]),
|
||||
("seashell", [255, 245, 238]),
|
||||
("seashell1", [255, 245, 238]),
|
||||
("seashell2", [238, 229, 222]),
|
||||
("seashell3", [205, 197, 191]),
|
||||
("seashell4", [139, 134, 130]),
|
||||
("sienna", [160, 82, 45]),
|
||||
("sienna1", [255, 130, 71]),
|
||||
("sienna2", [238, 121, 66]),
|
||||
("sienna3", [205, 104, 57]),
|
||||
("sienna4", [139, 71, 38]),
|
||||
("skyblue", [135, 206, 235]),
|
||||
("skyblue1", [135, 206, 255]),
|
||||
("skyblue2", [126, 192, 238]),
|
||||
("skyblue3", [108, 166, 205]),
|
||||
("skyblue4", [74, 112, 139]),
|
||||
("slateblue", [106, 90, 205]),
|
||||
("slateblue1", [131, 111, 255]),
|
||||
("slateblue2", [122, 103, 238]),
|
||||
("slateblue3", [105, 89, 205]),
|
||||
("slateblue4", [71, 60, 139]),
|
||||
("slategray", [112, 128, 144]),
|
||||
("slategray1", [198, 226, 255]),
|
||||
("slategray2", [185, 211, 238]),
|
||||
("slategray3", [159, 182, 205]),
|
||||
("slategray4", [108, 123, 139]),
|
||||
("slategrey", [112, 128, 144]),
|
||||
("snow", [255, 250, 250]),
|
||||
("snow1", [255, 250, 250]),
|
||||
("snow2", [238, 233, 233]),
|
||||
("snow3", [205, 201, 201]),
|
||||
("snow4", [139, 137, 137]),
|
||||
("springgreen", [0, 255, 127]),
|
||||
("springgreen1", [0, 255, 127]),
|
||||
("springgreen2", [0, 238, 118]),
|
||||
("springgreen3", [0, 205, 102]),
|
||||
("springgreen4", [0, 139, 69]),
|
||||
("steelblue", [70, 130, 180]),
|
||||
("steelblue1", [99, 184, 255]),
|
||||
("steelblue2", [92, 172, 238]),
|
||||
("steelblue3", [79, 148, 205]),
|
||||
("steelblue4", [54, 100, 139]),
|
||||
("tan", [210, 180, 140]),
|
||||
("tan1", [255, 165, 79]),
|
||||
("tan2", [238, 154, 73]),
|
||||
("tan3", [205, 133, 63]),
|
||||
("tan4", [139, 90, 43]),
|
||||
("thistle", [216, 191, 216]),
|
||||
("thistle1", [255, 225, 255]),
|
||||
("thistle2", [238, 210, 238]),
|
||||
("thistle3", [205, 181, 205]),
|
||||
("thistle4", [139, 123, 139]),
|
||||
("tomato", [255, 99, 71]),
|
||||
("tomato1", [255, 99, 71]),
|
||||
("tomato2", [238, 92, 66]),
|
||||
("tomato3", [205, 79, 57]),
|
||||
("tomato4", [139, 54, 38]),
|
||||
("turquoise", [64, 224, 208]),
|
||||
("turquoise1", [0, 245, 255]),
|
||||
("turquoise2", [0, 229, 238]),
|
||||
("turquoise3", [0, 197, 205]),
|
||||
("turquoise4", [0, 134, 139]),
|
||||
("violet", [238, 130, 238]),
|
||||
("violetred", [208, 32, 144]),
|
||||
("violetred1", [255, 62, 150]),
|
||||
("violetred2", [238, 58, 140]),
|
||||
("violetred3", [205, 50, 120]),
|
||||
("violetred4", [139, 34, 82]),
|
||||
("wheat", [245, 222, 179]),
|
||||
("wheat1", [255, 231, 186]),
|
||||
("wheat2", [238, 216, 174]),
|
||||
("wheat3", [205, 186, 150]),
|
||||
("wheat4", [139, 126, 102]),
|
||||
("white", [255, 255, 255]),
|
||||
("whitesmoke", [245, 245, 245]),
|
||||
("yellow", [255, 255, 0]),
|
||||
("yellow1", [255, 255, 0]),
|
||||
("yellow2", [238, 238, 0]),
|
||||
("yellow3", [205, 205, 0]),
|
||||
("yellow4", [139, 139, 0]),
|
||||
("yellowgreen", [154, 205, 50]),
|
||||
];
|
||||
@@ -145,13 +145,19 @@ fn pick_layout(src: ChannelLayout, layouts: &[ChannelLayout], alpha: bool) -> Ch
|
||||
layouts[0]
|
||||
}
|
||||
|
||||
pub(crate) fn plan(image: &Image, format: Format, _opts: &EncodeOptions) -> Plan {
|
||||
pub(crate) fn plan(image: &Image, format: Format, opts: &EncodeOptions) -> Plan {
|
||||
let c = caps(format);
|
||||
let (layout, sample) = (image.layout(), image.sample_type());
|
||||
match format {
|
||||
// PNM: integer data can use any layout (PAM); float goes to PFM
|
||||
// which only has gray and RGB.
|
||||
Format::Pnm if sample.is_float() => Plan { layout: pick_layout(layout, &[ChannelLayout::Gray, ChannelLayout::Rgb], false), sample: SampleType::F32 },
|
||||
// DPX has no luma + alpha descriptor: gray with alpha is written as luma, alpha dropped.
|
||||
// The sample type follows the chosen bits (U8 for 8, U16 for 10, 12 and 16).
|
||||
Format::Dpx => Plan {
|
||||
layout: if layout == ChannelLayout::GrayA { ChannelLayout::Gray } else { pick_layout(layout, c.layouts, c.alpha) },
|
||||
sample: crate::codecs::dpx::plan_sample(opts.dpx_bits, sample),
|
||||
},
|
||||
_ => Plan { layout: pick_layout(layout, c.layouts, c.alpha), sample: pick_sample(sample, c.depths) },
|
||||
}
|
||||
}
|
||||
@@ -237,7 +243,9 @@ pub fn fidelity_warnings_with(image: &Image, format: Format, opts: &EncodeOption
|
||||
if m.dpi.is_some() && !c.dpi {
|
||||
w.push(W::DpiDropped);
|
||||
}
|
||||
if !m.text.is_empty() && !c.text {
|
||||
// DPX keeps its own header fields, not free-form text.
|
||||
let text_kept = |k: &str| c.text && (format != Format::Dpx || crate::codecs::dpx::KEYS.contains(&k));
|
||||
if m.text.iter().any(|(k, _)| !text_kept(k)) {
|
||||
w.push(W::TextDropped);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,10 @@ pub enum Format {
|
||||
Pnm,
|
||||
/// ZSoft Paintbrush: 1/2/4-bit planar palettes, 8-bit indexed and 24-bit RGB.
|
||||
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.
|
||||
Dds,
|
||||
Qoi,
|
||||
OpenExr,
|
||||
/// Radiance RGBE.
|
||||
@@ -27,6 +31,8 @@ pub enum Format {
|
||||
Heif,
|
||||
/// FITS (Flexible Image Transport System), the astronomy format: gray or RGB image data.
|
||||
Fits,
|
||||
/// DPX (SMPTE 268M Digital Picture Exchange): luma, RGB or RGBA film and video frames.
|
||||
Dpx,
|
||||
}
|
||||
|
||||
/// What a format can hold **and** what this crate reads/writes for it.
|
||||
@@ -58,10 +64,15 @@ pub struct FormatCaps {
|
||||
/// Formats that are enabled but intentionally not symmetric, with the reason.
|
||||
/// The test-suite asserts that every other enabled format is read+write.
|
||||
pub const ASYMMETRIC_EXCEPTIONS: &[(Format, &str)] = &[
|
||||
(Format::Xpm, "XPM is a legacy Unix icon interchange format. Reading existing XPM icons is supported; writing modern image assets should use PNG instead."),
|
||||
(
|
||||
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 \
|
||||
@@ -84,7 +95,7 @@ const ALL_LAYOUTS: &[ChannelLayout] = &[L::Gray, L::GrayA, L::Rgb, L::Rgba, L::C
|
||||
|
||||
impl Format {
|
||||
/// Every format known to the crate (enabled or not).
|
||||
pub const ALL: [Format; 16] = [
|
||||
pub const ALL: [Format; 19] = [
|
||||
Format::Png,
|
||||
Format::Jpeg,
|
||||
Format::Tiff,
|
||||
@@ -95,12 +106,15 @@ impl Format {
|
||||
Format::Ico,
|
||||
Format::Pnm,
|
||||
Format::Pcx,
|
||||
Format::Xpm,
|
||||
Format::Dds,
|
||||
Format::Qoi,
|
||||
Format::OpenExr,
|
||||
Format::Hdr,
|
||||
Format::Avif,
|
||||
Format::Heif,
|
||||
Format::Fits,
|
||||
Format::Dpx,
|
||||
];
|
||||
|
||||
pub fn caps(self) -> FormatCaps {
|
||||
@@ -125,12 +139,15 @@ impl Format {
|
||||
Format::Ico => "ICO",
|
||||
Format::Pnm => "Netpbm",
|
||||
Format::Pcx => "PCX",
|
||||
Format::Xpm => "XPM",
|
||||
Format::Dds => "DDS",
|
||||
Format::Qoi => "QOI",
|
||||
Format::OpenExr => "OpenEXR",
|
||||
Format::Hdr => "Radiance HDR",
|
||||
Format::Avif => "AVIF",
|
||||
Format::Heif => "HEIF",
|
||||
Format::Fits => "FITS",
|
||||
Format::Dpx => "DPX",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,12 +165,15 @@ impl Format {
|
||||
Format::Ico => &["ico"],
|
||||
Format::Pnm => &["pnm", "pbm", "pgm", "ppm", "pam", "pfm"],
|
||||
Format::Pcx => &["pcx"],
|
||||
Format::Xpm => &["xpm"],
|
||||
Format::Dds => &["dds"],
|
||||
Format::Qoi => &["qoi"],
|
||||
Format::OpenExr => &["exr"],
|
||||
Format::Hdr => &["hdr"],
|
||||
Format::Avif => &["avif"],
|
||||
Format::Heif => &["heic", "heif", "hif"],
|
||||
Format::Fits => &["fits", "fit", "fts"],
|
||||
Format::Dpx => &["dpx"],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,12 +189,15 @@ impl Format {
|
||||
Format::Ico => "image/x-icon",
|
||||
Format::Pnm => "image/x-portable-anymap",
|
||||
Format::Pcx => "image/x-pcx",
|
||||
Format::Xpm => "image/x-xpixmap",
|
||||
Format::Dds => "image/vnd-ms.dds",
|
||||
Format::Qoi => "image/qoi",
|
||||
Format::OpenExr => "image/x-exr",
|
||||
Format::Hdr => "image/vnd.radiance",
|
||||
Format::Avif => "image/avif",
|
||||
Format::Heif => "image/heif",
|
||||
Format::Fits => "image/fits",
|
||||
Format::Dpx => "image/x-dpx",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,6 +241,8 @@ pub fn caps(format: Format) -> FormatCaps {
|
||||
Format::Ico => FormatCaps { layouts: &[L::Rgba], ..base },
|
||||
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 },
|
||||
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 },
|
||||
@@ -226,6 +251,8 @@ pub fn caps(format: Format) -> FormatCaps {
|
||||
FormatCaps { read: cfg!(feature = "heif"), write: false, depths: &[S::U8, S::U16], icc: true, exif: true, xmp: true, lossy: true, ..base }
|
||||
}
|
||||
Format::Fits => FormatCaps { depths: &[S::U8, S::U16, S::F32], layouts: &[L::Gray, L::Rgb], alpha: false, text: true, ..base },
|
||||
// `text`: the DPX header fields only (see `fidelity_warnings_with`).
|
||||
Format::Dpx => FormatCaps { depths: &[S::U8, S::U16], layouts: &[L::Gray, L::Rgb, L::Rgba], text: true, ..base },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,6 +278,9 @@ pub fn detect(bytes: &[u8]) -> Option<Format> {
|
||||
if b.starts_with(b"qoif") {
|
||||
return Some(Format::Qoi);
|
||||
}
|
||||
if b.starts_with(b"DDS ") {
|
||||
return Some(Format::Dds);
|
||||
}
|
||||
if b.starts_with(&[0x76, 0x2F, 0x31, 0x01]) {
|
||||
return Some(Format::OpenExr);
|
||||
}
|
||||
@@ -274,9 +304,15 @@ pub fn detect(bytes: &[u8]) -> Option<Format> {
|
||||
if b.len() >= 3 && b[0] == b'P' && matches!(b[1], b'1'..=b'7' | b'F' | b'f') && b[2].is_ascii_whitespace() {
|
||||
return Some(Format::Pnm);
|
||||
}
|
||||
if b.starts_with(b"SDPX") || b.starts_with(b"XPDS") {
|
||||
return Some(Format::Dpx);
|
||||
}
|
||||
if b.starts_with(b"SIMPLE =") {
|
||||
return Some(Format::Fits);
|
||||
}
|
||||
if b.starts_with(b"/* XPM */") || b.starts_with(b"! XPM2\n") || b.starts_with(b"! XPM2\r\n") {
|
||||
return Some(Format::Xpm);
|
||||
}
|
||||
if crate::codecs::pcx::looks_like_pcx(b) {
|
||||
return Some(Format::Pcx);
|
||||
}
|
||||
|
||||
@@ -156,6 +156,32 @@ pub enum DecodeWarning {
|
||||
/// Deep image data (per-pixel sample lists) was composited into the flat image; the samples
|
||||
/// themselves (Z and other deep channels) are not part of the flat result.
|
||||
DeepFlattened { format: Format, max_samples_per_pixel: u32 },
|
||||
/// Only the first image element of a DPX file was decoded; `total` counts them all.
|
||||
MoreElements { total: u32 },
|
||||
/// A DPX element's transfer characteristic and colorimetric specification (SMPTE 268M codes,
|
||||
/// 255 when undefined). The code values are imported as stored, without a colour transform.
|
||||
DpxEncoding { transfer: u8, colorimetric: u8 },
|
||||
}
|
||||
|
||||
/// The SMPTE 268M name of a transfer characteristic or colorimetric code.
|
||||
fn dpx_code_name(code: u8) -> &'static str {
|
||||
match code {
|
||||
0 => "user-defined",
|
||||
1 => "printing density",
|
||||
2 => "linear",
|
||||
3 => "logarithmic",
|
||||
4 => "unspecified video",
|
||||
5 => "SMPTE 274M",
|
||||
6 => "ITU-R BT.709",
|
||||
7 => "ITU-R BT.601 625-line",
|
||||
8 => "ITU-R BT.601 525-line",
|
||||
9 => "NTSC composite video",
|
||||
10 => "PAL composite video",
|
||||
11 => "Z linear",
|
||||
12 => "Z homogeneous",
|
||||
255 => "undefined",
|
||||
_ => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for DecodeWarning {
|
||||
@@ -175,6 +201,13 @@ impl fmt::Display for DecodeWarning {
|
||||
"{format} deep data was composited into a flat image (up to {max_samples_per_pixel} samples per pixel); per-pixel depth is not kept",
|
||||
format = format.name()
|
||||
),
|
||||
DecodeWarning::MoreElements { total } => write!(f, "only the first of {total} image elements was imported"),
|
||||
DecodeWarning::DpxEncoding { transfer, colorimetric } => write!(
|
||||
f,
|
||||
"DPX transfer characteristic: {} ({transfer}), colorimetric: {} ({colorimetric}); the code values were imported as stored, without a colour transform",
|
||||
dpx_code_name(*transfer),
|
||||
dpx_code_name(*colorimetric)
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,13 +37,13 @@ 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, EncodeOptions, ExrCompression, Limits, PngCompression, TiffCompression};
|
||||
pub use crate::options::{DecodeOptions, DpxBits, EncodeOptions, ExrCompression, Limits, PngCompression, 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};
|
||||
pub use half::f16;
|
||||
|
||||
use crate::codecs::{exr, fits, heif, jpeg, pcx, png, pnm, tiff, via_image, webp};
|
||||
use crate::codecs::{dds, dpx, exr, fits, heif, jpeg, pcx, png, pnm, tiff, via_image, webp, xpm};
|
||||
|
||||
/// Detect the format and decode with default [`Limits`].
|
||||
pub fn decode(bytes: &[u8]) -> Result<Image, CodecError> {
|
||||
@@ -75,7 +75,10 @@ pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Res
|
||||
Format::WebP => webp::decode(bytes, l),
|
||||
Format::Pnm => pnm::decode(bytes, l),
|
||||
Format::Pcx => pcx::decode(bytes, l),
|
||||
Format::Xpm => xpm::decode(bytes, l),
|
||||
Format::Dds => dds::decode(bytes, l),
|
||||
Format::Fits => fits::decode(bytes, l),
|
||||
Format::Dpx => dpx::decode(bytes, l),
|
||||
Format::OpenExr => exr::decode(bytes, l),
|
||||
Format::Heif => heif::decode(bytes, l, opts.keep_orientation),
|
||||
Format::Gif | Format::Bmp | Format::Tga | Format::Ico | Format::Qoi | Format::Hdr | Format::Avif => via_image::decode(format, bytes, l),
|
||||
@@ -93,6 +96,7 @@ pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Res
|
||||
let o = match format {
|
||||
Format::Tiff => tiff::orientation(bytes, None),
|
||||
Format::Heif => 1,
|
||||
Format::Dpx => dpx::orientation(bytes),
|
||||
_ => img.meta.exif.as_deref().map_or(1, exif_orientation),
|
||||
};
|
||||
finish_decode(img, o, opts)
|
||||
@@ -223,7 +227,10 @@ pub fn encode(image: &Image, format: Format, opts: &EncodeOptions) -> Result<Vec
|
||||
Format::WebP => webp::encode(image, plan, opts),
|
||||
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::Fits => fits::encode(image, plan, opts),
|
||||
Format::Dpx => dpx::encode(image, plan, opts),
|
||||
Format::OpenExr => exr::encode(image, plan, opts),
|
||||
Format::Heif => Err(CodecError::unsupported(format, "encoding is not available in this build")),
|
||||
Format::Gif | Format::Bmp | Format::Tga | Format::Ico | Format::Qoi | Format::Hdr | Format::Avif => via_image::encode(format, image, plan, opts),
|
||||
|
||||
@@ -121,6 +121,38 @@ pub enum ExrCompression {
|
||||
Piz,
|
||||
}
|
||||
|
||||
/// Bits per sample of a DPX file.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DpxBits {
|
||||
Bits8,
|
||||
Bits10,
|
||||
Bits12,
|
||||
Bits16,
|
||||
}
|
||||
|
||||
impl DpxBits {
|
||||
/// `8`, `10`, `12` or `16` as [`DpxBits`]; any other number is `None`.
|
||||
pub fn from_bits(bits: u64) -> Option<Self> {
|
||||
match bits {
|
||||
8 => Some(DpxBits::Bits8),
|
||||
10 => Some(DpxBits::Bits10),
|
||||
12 => Some(DpxBits::Bits12),
|
||||
16 => Some(DpxBits::Bits16),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The bits per sample.
|
||||
pub fn bits(self) -> u8 {
|
||||
match self {
|
||||
DpxBits::Bits8 => 8,
|
||||
DpxBits::Bits10 => 10,
|
||||
DpxBits::Bits12 => 12,
|
||||
DpxBits::Bits16 => 16,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Options for [`crate::encode`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EncodeOptions {
|
||||
@@ -141,6 +173,8 @@ pub struct EncodeOptions {
|
||||
/// only when it could pass the 4 GiB a classic TIFF can address.
|
||||
pub tiff_bigtiff: bool,
|
||||
pub exr_compression: ExrCompression,
|
||||
/// DPX bits per sample. `None` follows the image: 8 for 8-bit, 16 otherwise.
|
||||
pub dpx_bits: Option<DpxBits>,
|
||||
/// Embed the ICC profile when the format supports it.
|
||||
pub embed_icc: bool,
|
||||
/// Embed EXIF/XMP/DPI/text when the format supports it.
|
||||
@@ -159,6 +193,7 @@ impl Default for EncodeOptions {
|
||||
tiff_compression: TiffCompression::Deflate,
|
||||
tiff_bigtiff: false,
|
||||
exr_compression: ExrCompression::Zip16,
|
||||
dpx_bits: None,
|
||||
embed_icc: true,
|
||||
embed_metadata: true,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
use photocraft_codecs::{
|
||||
ChannelLayout, CodecError, DecodeOptions, Format, Limits, SampleType, caps, decode, decode_as, decode_as_with, detect, encode, from_extension,
|
||||
};
|
||||
|
||||
const DDPF_ALPHAPIXELS: u32 = 0x1;
|
||||
const DDPF_FOURCC: u32 = 0x4;
|
||||
const DDPF_RGB: u32 = 0x40;
|
||||
const DDPF_LUMINANCE: u32 = 0x2_0000;
|
||||
|
||||
/// A DDS_HEADER for a single-level 2D texture, after the `DDS ` signature.
|
||||
fn header(width: u32, height: u32, pf_flags: u32, fourcc: &[u8; 4], bits: u32, masks: [u32; 4]) -> Vec<u8> {
|
||||
let mut b = vec![0u8; 128];
|
||||
b[0..4].copy_from_slice(b"DDS ");
|
||||
let mut put = |at: usize, v: u32| b[at..at + 4].copy_from_slice(&v.to_le_bytes());
|
||||
put(4, 124);
|
||||
put(8, 0x1007); // CAPS | HEIGHT | WIDTH | PIXELFORMAT
|
||||
put(12, height);
|
||||
put(16, width);
|
||||
put(76, 32);
|
||||
put(80, pf_flags);
|
||||
put(88, bits);
|
||||
for (i, m) in masks.into_iter().enumerate() {
|
||||
put(92 + 4 * i, m);
|
||||
}
|
||||
put(108, 0x1000); // DDSCAPS_TEXTURE
|
||||
b[84..88].copy_from_slice(fourcc);
|
||||
b
|
||||
}
|
||||
|
||||
fn compressed(width: u32, height: u32, fourcc: &[u8; 4]) -> Vec<u8> {
|
||||
header(width, height, DDPF_FOURCC, fourcc, 0, [0; 4])
|
||||
}
|
||||
|
||||
/// The DX10 extension header after a `DX10` FourCC: format, dimension, misc flags, array size.
|
||||
fn dx10(width: u32, height: u32, dxgi: u32, dimension: u32, misc: u32, array: u32) -> Vec<u8> {
|
||||
let mut b = compressed(width, height, b"DX10");
|
||||
for v in [dxgi, dimension, misc, array, 0] {
|
||||
b.extend(v.to_le_bytes());
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
/// BC1 colour endpoints as RGB565, then one 2-bit index per pixel, row by row.
|
||||
fn bc1_block(c0: u16, c1: u16, rows: [u8; 4]) -> Vec<u8> {
|
||||
let mut b = Vec::new();
|
||||
b.extend(c0.to_le_bytes());
|
||||
b.extend(c1.to_le_bytes());
|
||||
b.extend(rows);
|
||||
b
|
||||
}
|
||||
|
||||
const RED: u16 = 0xf800;
|
||||
const GREEN: u16 = 0x07e0;
|
||||
const BLUE: u16 = 0x001f;
|
||||
const BLACK: u16 = 0x0000;
|
||||
const WHITE: u16 = 0xffff;
|
||||
|
||||
/// Indices 0, 1 on the top row and 2, 3 below them, for the top-left 2x2 pixels.
|
||||
const INDICES_0123: [u8; 4] = [0x04, 0x0e, 0, 0];
|
||||
|
||||
#[test]
|
||||
fn dds_is_readable_but_not_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 { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_bc1_four_colour_blocks() {
|
||||
// c0 > c1: four opaque colours, the two in between at thirds.
|
||||
let mut b = compressed(2, 2, b"DXT1");
|
||||
b.extend(bc1_block(RED, BLUE, INDICES_0123));
|
||||
assert_eq!(detect(&b), Some(Format::Dds));
|
||||
let im = decode(&b).unwrap();
|
||||
assert_eq!((im.dimensions(), im.layout(), im.sample_type()), ((2, 2), ChannelLayout::Rgba, SampleType::U8));
|
||||
assert_eq!(im.data(), &[255, 0, 0, 255, 0, 0, 255, 255, 170, 0, 85, 255, 85, 0, 170, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_bc1_one_bit_alpha_blocks() {
|
||||
// c0 <= c1: three colours (the third halfway) and index 3 is transparent black.
|
||||
let mut b = compressed(2, 2, b"DXT1");
|
||||
b.extend(bc1_block(BLUE, RED, INDICES_0123));
|
||||
assert_eq!(decode(&b).unwrap().data(), &[0, 0, 255, 255, 255, 0, 0, 255, 128, 0, 128, 255, 0, 0, 0, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_bc2_explicit_alpha_with_four_colour_blocks() {
|
||||
let mut b = compressed(2, 2, b"DXT3");
|
||||
// Four bits of alpha per pixel: 15, 0 on the top row, 8, 1 below.
|
||||
b.extend([0x0f, 0x00, 0x18, 0x00, 0, 0, 0, 0]);
|
||||
// c0 < c1 would mean transparency in BC1; BC2 colour blocks always have four colours.
|
||||
b.extend(bc1_block(BLACK, WHITE, [0x04, 0x0b, 0, 0]));
|
||||
let im = decode(&b).unwrap();
|
||||
assert_eq!(im.layout(), ChannelLayout::Rgba);
|
||||
assert_eq!(im.data(), &[0, 0, 0, 255, 255, 255, 255, 0, 170, 170, 170, 136, 85, 85, 85, 17]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_bc3_interpolated_alpha() {
|
||||
// a0 > a1: eight alpha values, six interpolated at sevenths. Indices 0, 1 / 2, 7.
|
||||
let mut b = compressed(2, 2, b"DXT5");
|
||||
b.extend([255, 0, 0x08, 0xa0, 0x03, 0, 0, 0]);
|
||||
b.extend(bc1_block(BLACK, WHITE, [0x04, 0x0b, 0, 0]));
|
||||
assert_eq!(decode(&b).unwrap().data(), &[0, 0, 0, 255, 255, 255, 255, 0, 170, 170, 170, 219, 85, 85, 85, 36]);
|
||||
|
||||
// a0 <= a1: six values, four interpolated at fifths, then 0 and 255. Indices 2, 5 / 6, 7.
|
||||
let mut b = compressed(2, 2, b"DXT5");
|
||||
let bits: u64 = 2 | 5 << 3 | 6 << 12 | 7 << 15;
|
||||
b.extend([0, 255]);
|
||||
b.extend(&bits.to_le_bytes()[..6]);
|
||||
b.extend(bc1_block(GREEN, GREEN, [0; 4]));
|
||||
assert_eq!(decode(&b).unwrap().data(), &[0, 255, 0, 51, 0, 255, 0, 204, 0, 255, 0, 0, 0, 255, 0, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bc_textures_need_not_be_a_multiple_of_four() {
|
||||
// 5x3 is two blocks wide and one high; the right block's first column is x = 4.
|
||||
let mut b = compressed(5, 3, b"DXT1");
|
||||
b.extend(bc1_block(RED, RED, [0; 4]));
|
||||
b.extend(bc1_block(BLUE, BLUE, [0; 4]));
|
||||
let im = decode(&b).unwrap();
|
||||
assert_eq!(im.dimensions(), (5, 3));
|
||||
let px = |x: usize, y: usize| &im.data()[(y * 5 + x) * 4..(y * 5 + x + 1) * 4];
|
||||
assert_eq!((px(3, 2), px(4, 0), px(4, 2)), (&[255, 0, 0, 255][..], &[0, 0, 255, 255][..], &[0, 0, 255, 255][..]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_uncompressed_rgba_and_bgra_masks() {
|
||||
let rgba = [0xff, 0xff00, 0xff_0000, 0xff00_0000];
|
||||
let mut b = header(2, 1, DDPF_RGB | DDPF_ALPHAPIXELS, &[0; 4], 32, rgba);
|
||||
b.extend([1, 2, 3, 4, 250, 251, 252, 253]);
|
||||
let im = decode(&b).unwrap();
|
||||
assert_eq!(im.layout(), ChannelLayout::Rgba);
|
||||
assert_eq!(im.data(), &[1, 2, 3, 4, 250, 251, 252, 253]);
|
||||
|
||||
let bgra = [0xff_0000, 0xff00, 0xff, 0xff00_0000];
|
||||
let mut b = header(2, 1, DDPF_RGB | DDPF_ALPHAPIXELS, &[0; 4], 32, bgra);
|
||||
b.extend([3, 2, 1, 4, 252, 251, 250, 253]);
|
||||
assert_eq!(decode(&b).unwrap().data(), &[1, 2, 3, 4, 250, 251, 252, 253]);
|
||||
|
||||
// An alpha mask without DDPF_ALPHAPIXELS is not alpha: X8R8G8B8 opens as RGB.
|
||||
let mut b = header(1, 1, DDPF_RGB, &[0; 4], 32, bgra);
|
||||
b.extend([3, 2, 1, 0]);
|
||||
let im = decode(&b).unwrap();
|
||||
assert_eq!((im.layout(), im.data()), (ChannelLayout::Rgb, &[1u8, 2, 3][..]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_packed_masks_and_luminance() {
|
||||
// R5G6B5: each field scaled to 0..255; 3 of 31 is 25.
|
||||
let mut b = header(4, 1, DDPF_RGB, &[0; 4], 16, [0xf800, 0x07e0, 0x001f, 0]);
|
||||
for v in [RED, GREEN, BLUE, 3 << 11] {
|
||||
b.extend(v.to_le_bytes());
|
||||
}
|
||||
let im = decode(&b).unwrap();
|
||||
assert_eq!(im.layout(), ChannelLayout::Rgb);
|
||||
assert_eq!(im.data(), &[255, 0, 0, 0, 255, 0, 0, 0, 255, 25, 0, 0]);
|
||||
|
||||
// 24-bit rows are packed three bytes a pixel, without padding.
|
||||
let mut b = header(2, 2, DDPF_RGB, &[0; 4], 24, [0xff_0000, 0xff00, 0xff, 0]);
|
||||
b.extend([3, 2, 1, 6, 5, 4, 9, 8, 7, 12, 11, 10]);
|
||||
assert_eq!(decode(&b).unwrap().data(), &[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]);
|
||||
|
||||
let mut b = header(2, 1, DDPF_LUMINANCE | DDPF_ALPHAPIXELS, &[0; 4], 16, [0xff, 0, 0, 0xff00]);
|
||||
b.extend([10, 20, 30, 40]);
|
||||
let im = decode(&b).unwrap();
|
||||
assert_eq!((im.layout(), im.data()), (ChannelLayout::GrayA, &[10u8, 20, 30, 40][..]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_dx10_headers() {
|
||||
let mut b = dx10(1, 1, 28, 3, 0, 1); // R8G8B8A8_UNORM
|
||||
b.extend([1, 2, 3, 4]);
|
||||
assert_eq!(decode(&b).unwrap().data(), &[1, 2, 3, 4]);
|
||||
|
||||
let mut b = dx10(1, 1, 91, 3, 0, 1); // B8G8R8A8_UNORM_SRGB
|
||||
b.extend([3, 2, 1, 4]);
|
||||
assert_eq!(decode(&b).unwrap().data(), &[1, 2, 3, 4]);
|
||||
|
||||
let mut b = dx10(2, 2, 71, 3, 0, 1); // BC1_UNORM
|
||||
b.extend(bc1_block(BLUE, RED, INDICES_0123));
|
||||
assert_eq!(decode(&b).unwrap().data(), &[0, 0, 255, 255, 255, 0, 0, 255, 128, 0, 128, 255, 0, 0, 0, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_top_mip_level_is_read() {
|
||||
let mut b = compressed(4, 4, b"DXT1");
|
||||
b[28..32].copy_from_slice(&3u32.to_le_bytes());
|
||||
b.extend(bc1_block(RED, RED, [0; 4]));
|
||||
b.extend(bc1_block(BLUE, BLUE, [0; 4]));
|
||||
b.extend(bc1_block(GREEN, GREEN, [0; 4]));
|
||||
let im = decode(&b).unwrap();
|
||||
assert_eq!(im.dimensions(), (4, 4));
|
||||
assert!(im.data().chunks(4).all(|p| p == [255, 0, 0, 255]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_cubemaps_arrays_and_volumes() {
|
||||
let unsupported = |b: &[u8], what: &str| match decode_as(Format::Dds, b) {
|
||||
Err(CodecError::Unsupported { reason, .. }) => assert!(reason.contains(what), "{reason}"),
|
||||
other => panic!("{what}: {other:?}"),
|
||||
};
|
||||
let mut cube = compressed(4, 4, b"DXT1");
|
||||
cube[112..116].copy_from_slice(&0xfe00u32.to_le_bytes()); // DDSCAPS2_CUBEMAP and all faces
|
||||
cube.extend([0; 8 * 6]);
|
||||
unsupported(&cube, "cubemap");
|
||||
let mut volume = compressed(4, 4, b"DXT1");
|
||||
volume[8..12].copy_from_slice(&(0x1007u32 | 0x80_0000).to_le_bytes()); // DDSD_DEPTH
|
||||
volume[24..28].copy_from_slice(&4u32.to_le_bytes());
|
||||
volume[112..116].copy_from_slice(&0x20_0000u32.to_le_bytes()); // DDSCAPS2_VOLUME
|
||||
volume.extend([0; 8 * 4]);
|
||||
unsupported(&volume, "volume");
|
||||
unsupported(&dx10(4, 4, 71, 3, 0x4, 1), "cubemap");
|
||||
unsupported(&dx10(4, 4, 71, 3, 0, 6), "array");
|
||||
unsupported(&dx10(4, 4, 71, 4, 0, 1), "2D");
|
||||
unsupported(&dx10(4, 4, 98, 3, 0, 1), "98"); // BC7_UNORM
|
||||
unsupported(&compressed(4, 4, b"ATI2"), "ATI2");
|
||||
unsupported(&compressed(4, 4, b"DXT2"), "premultiplied");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_truncated_files_at_every_length_without_panicking() {
|
||||
let mut b = dx10(5, 3, 77, 3, 0, 1); // BC3_UNORM: two 16-byte blocks
|
||||
b.extend([0x5a; 32]);
|
||||
assert!(decode(&b).is_ok());
|
||||
for n in 0..b.len() {
|
||||
assert!(decode_as(Format::Dds, &b[..n]).is_err(), "decoded {n} of {} bytes", b.len());
|
||||
}
|
||||
let mut b = header(3, 2, DDPF_RGB, &[0; 4], 24, [0xff_0000, 0xff00, 0xff, 0]);
|
||||
b.extend([7; 18]);
|
||||
assert!(decode(&b).is_ok());
|
||||
assert!(decode_as(Format::Dds, &b[..b.len() - 1]).is_err(), "one byte short");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_bogus_sizes_and_pixel_formats() {
|
||||
// Huge dimensions are refused before anything is allocated.
|
||||
let mut huge = compressed(u32::MAX, u32::MAX, b"DXT1");
|
||||
huge.extend([0; 8]);
|
||||
assert!(matches!(decode_as(Format::Dds, &huge), Err(CodecError::LimitExceeded(_))));
|
||||
let opts = DecodeOptions { limits: Limits { max_alloc: 1024, ..Default::default() }, ..Default::default() };
|
||||
let mut tight = header(64, 64, DDPF_RGB, &[0; 4], 32, [0xff, 0xff00, 0xff_0000, 0]);
|
||||
tight.extend(vec![0; 64 * 64 * 4]);
|
||||
assert!(matches!(decode_as_with(Format::Dds, &tight, &opts), Err(CodecError::LimitExceeded(_))));
|
||||
assert!(decode_as(Format::Dds, &compressed(0, 4, b"DXT1")).is_err(), "zero width");
|
||||
|
||||
let mut bad_size = compressed(4, 4, b"DXT1");
|
||||
bad_size[4..8].copy_from_slice(&100u32.to_le_bytes());
|
||||
bad_size.extend([0; 8]);
|
||||
assert!(matches!(decode_as(Format::Dds, &bad_size), Err(CodecError::Malformed { .. })), "header size must be 124");
|
||||
|
||||
for (bits, masks) in [
|
||||
(12, [0xf00, 0xf0, 0xf, 0]), // not a whole number of bytes
|
||||
(16, [0xff_0000, 0xff00, 0xff, 0]), // a mask wider than the pixel
|
||||
(32, [0xf0f, 0xf0f0, 0xf_0000, 0]), // a mask with a gap
|
||||
(32, [0, 0, 0, 0]), // no colour at all
|
||||
(0, [0xff, 0xff00, 0xff_0000, 0]), // zero bits per pixel
|
||||
] {
|
||||
let mut b = header(1, 1, DDPF_RGB, &[0; 4], bits, masks);
|
||||
b.extend([0; 4]);
|
||||
assert!(decode_as(Format::Dds, &b).is_err(), "{bits} bits, masks {masks:x?}");
|
||||
}
|
||||
let mut no_format = header(1, 1, 0, &[0; 4], 32, [0xff, 0xff00, 0xff_0000, 0]);
|
||||
no_format.extend([0; 4]);
|
||||
assert!(matches!(decode_as(Format::Dds, &no_format), Err(CodecError::Unsupported { .. })));
|
||||
}
|
||||
@@ -59,6 +59,7 @@ fn default_build_formats_all_symmetric() {
|
||||
Format::OpenExr,
|
||||
Format::Hdr,
|
||||
Format::Fits,
|
||||
Format::Dpx,
|
||||
];
|
||||
for f in expected {
|
||||
assert!(caps(f).read && caps(f).write, "{f:?}");
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
//! DPX (SMPTE 268M) read and write, against fixtures packed here independently of the codec:
|
||||
//! every descriptor, bit depth and packing it reads, both byte orders, the header fields kept
|
||||
//! through a round trip, the bit-depth choice on export and the files it refuses (#1049).
|
||||
|
||||
use photocraft_codecs::*;
|
||||
|
||||
const HEADER: usize = 2048;
|
||||
const ELEMENT0: usize = 780;
|
||||
|
||||
/// A DPX file built field by field.
|
||||
#[derive(Clone)]
|
||||
struct Fixture {
|
||||
big: bool,
|
||||
width: u32,
|
||||
height: u32,
|
||||
descriptor: u8,
|
||||
bits: u8,
|
||||
packing: u16,
|
||||
encoding: u16,
|
||||
signed: bool,
|
||||
transfer: u8,
|
||||
colorimetric: u8,
|
||||
orientation: u16,
|
||||
elements: u16,
|
||||
strings: Vec<((usize, usize), &'static str)>,
|
||||
frame_position: u32,
|
||||
frame_rate: f32,
|
||||
time_code: u32,
|
||||
/// Code values, interleaved, top line first.
|
||||
codes: Vec<u16>,
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
fn new(descriptor: u8, bits: u8, packing: u16, width: u32, height: u32) -> Self {
|
||||
let channels = channels(descriptor);
|
||||
let max = (1u32 << bits) - 1;
|
||||
// Spread over the whole range, ends included.
|
||||
let n = (width * height) as usize * channels;
|
||||
let codes = (0..n)
|
||||
.map(|i| {
|
||||
if i == 0 {
|
||||
0
|
||||
} else if i == 1 {
|
||||
max as u16
|
||||
} else {
|
||||
((i as u64 * 2654435761) % (u64::from(max) + 1)) as u16
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Fixture {
|
||||
big: true,
|
||||
width,
|
||||
height,
|
||||
descriptor,
|
||||
bits,
|
||||
packing,
|
||||
encoding: 0,
|
||||
signed: false,
|
||||
transfer: 255,
|
||||
colorimetric: 255,
|
||||
orientation: 0,
|
||||
elements: 1,
|
||||
strings: Vec::new(),
|
||||
frame_position: u32::MAX,
|
||||
frame_rate: f32::from_bits(u32::MAX),
|
||||
time_code: u32::MAX,
|
||||
codes,
|
||||
}
|
||||
}
|
||||
|
||||
fn u16(&self, v: u16) -> [u8; 2] {
|
||||
if self.big { v.to_be_bytes() } else { v.to_le_bytes() }
|
||||
}
|
||||
|
||||
fn u32(&self, v: u32) -> [u8; 4] {
|
||||
if self.big { v.to_be_bytes() } else { v.to_le_bytes() }
|
||||
}
|
||||
|
||||
fn line(&self, codes: &[u16]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
match (self.bits, self.packing) {
|
||||
(8, _) => out.extend(codes.iter().map(|&c| c as u8)),
|
||||
(16, _) => out.extend(codes.iter().flat_map(|&c| self.u16(c))),
|
||||
(10, 1 | 2) => {
|
||||
let base = if self.packing == 1 { 22 } else { 20 };
|
||||
for chunk in codes.chunks(3) {
|
||||
let w = chunk.iter().enumerate().fold(0u32, |w, (i, &c)| w | (u32::from(c) << (base - 10 * i as u32)));
|
||||
out.extend(self.u32(w));
|
||||
}
|
||||
}
|
||||
(12, 1) => out.extend(codes.iter().flat_map(|&c| self.u16(c << 4))),
|
||||
_ => {
|
||||
// One bit stream, high bits first, cut into 32-bit words.
|
||||
let mut bits: Vec<bool> = codes.iter().flat_map(|&c| (0..self.bits).rev().map(move |b| (c >> b) & 1 == 1)).collect();
|
||||
bits.resize(bits.len().div_ceil(32) * 32, false);
|
||||
for word in bits.chunks(32) {
|
||||
out.extend(self.u32(word.iter().fold(0u32, |w, &b| (w << 1) | u32::from(b))));
|
||||
}
|
||||
}
|
||||
}
|
||||
out.resize(out.len().div_ceil(4) * 4, 0);
|
||||
out
|
||||
}
|
||||
|
||||
fn bytes(&self) -> Vec<u8> {
|
||||
let mut b = vec![0u8; HEADER];
|
||||
let mut put = |at: usize, v: &[u8]| b[at..at + v.len()].copy_from_slice(v);
|
||||
put(0, if self.big { b"SDPX" } else { b"XPDS" });
|
||||
put(4, &self.u32(HEADER as u32));
|
||||
put(8, b"V2.0");
|
||||
put(24, &self.u32(1664));
|
||||
put(28, &self.u32(384));
|
||||
put(768, &self.u16(self.orientation));
|
||||
put(770, &self.u16(self.elements));
|
||||
put(772, &self.u32(self.width));
|
||||
put(776, &self.u32(self.height));
|
||||
for el in 0..usize::from(self.elements.min(8)) {
|
||||
let e = ELEMENT0 + el * 72;
|
||||
put(e, &self.u32(u32::from(self.signed)));
|
||||
put(e + 20, &[self.descriptor, self.transfer, self.colorimetric, self.bits]);
|
||||
put(e + 24, &self.u16(self.packing));
|
||||
put(e + 26, &self.u16(self.encoding));
|
||||
put(e + 28, &self.u32(HEADER as u32));
|
||||
}
|
||||
for &((at, len), s) in &self.strings {
|
||||
assert!(s.len() < len);
|
||||
put(at, s.as_bytes());
|
||||
}
|
||||
put(1712, &self.u32(self.frame_position));
|
||||
put(1724, &self.u32(self.frame_rate.to_bits()));
|
||||
put(1920, &self.u32(self.time_code));
|
||||
let per_line = self.width as usize * channels(self.descriptor);
|
||||
for line in self.codes.chunks(per_line) {
|
||||
b.extend(self.line(line));
|
||||
}
|
||||
b
|
||||
}
|
||||
}
|
||||
|
||||
fn channels(descriptor: u8) -> usize {
|
||||
match descriptor {
|
||||
6 => 1,
|
||||
50 => 3,
|
||||
_ => 4,
|
||||
}
|
||||
}
|
||||
|
||||
fn layout(descriptor: u8) -> ChannelLayout {
|
||||
match descriptor {
|
||||
6 => ChannelLayout::Gray,
|
||||
50 => ChannelLayout::Rgb,
|
||||
_ => ChannelLayout::Rgba,
|
||||
}
|
||||
}
|
||||
|
||||
/// The 16-bit sample a code value opens as: `round(c × 65535 / (2^b − 1))`.
|
||||
fn expected(c: u16, bits: u8) -> u16 {
|
||||
(f64::from(c) * 65535.0 / f64::from((1u32 << bits) - 1)).round() as u16
|
||||
}
|
||||
|
||||
/// Reads the code values of a big-endian file we wrote (packing 0 at 8/16 bits, 1 at 10/12).
|
||||
fn written_codes(b: &[u8]) -> (u8, u16, Vec<u16>) {
|
||||
let be16 = |at: usize| u16::from_be_bytes([b[at], b[at + 1]]);
|
||||
let be32 = |at: usize| u32::from_be_bytes([b[at], b[at + 1], b[at + 2], b[at + 3]]);
|
||||
let (w, h) = (be32(772) as usize, be32(776) as usize);
|
||||
let nc = channels(b[ELEMENT0 + 20]);
|
||||
let (bits, packing) = (b[ELEMENT0 + 23], be16(ELEMENT0 + 24));
|
||||
let off = be32(ELEMENT0 + 28) as usize;
|
||||
let per_line = w * nc;
|
||||
let stride = match bits {
|
||||
10 => per_line.div_ceil(3) * 4,
|
||||
_ => (per_line * if bits == 8 { 1 } else { 2 }).div_ceil(4) * 4,
|
||||
};
|
||||
let mut codes = Vec::new();
|
||||
for y in 0..h {
|
||||
let line = &b[off + y * stride..];
|
||||
for i in 0..per_line {
|
||||
codes.push(match bits {
|
||||
8 => u16::from(line[i]),
|
||||
10 => ((be32_at(line, i / 3 * 4) >> (22 - 10 * (i % 3) as u32)) & 0x3FF) as u16,
|
||||
12 => u16::from_be_bytes([line[i * 2], line[i * 2 + 1]]) >> 4,
|
||||
_ => u16::from_be_bytes([line[i * 2], line[i * 2 + 1]]),
|
||||
});
|
||||
}
|
||||
}
|
||||
(bits, packing, codes)
|
||||
}
|
||||
|
||||
fn be32_at(b: &[u8], at: usize) -> u32 {
|
||||
u32::from_be_bytes([b[at], b[at + 1], b[at + 2], b[at + 3]])
|
||||
}
|
||||
|
||||
fn opts(bits: Option<DpxBits>) -> EncodeOptions {
|
||||
EncodeOptions { dpx_bits: bits, ..EncodeOptions::default() }
|
||||
}
|
||||
|
||||
const COMBOS: [(u8, u16); 7] = [(8, 0), (10, 0), (10, 1), (10, 2), (12, 0), (12, 1), (16, 0)];
|
||||
|
||||
#[test]
|
||||
fn every_descriptor_depth_and_packing_opens_with_its_model_and_values() {
|
||||
for descriptor in [6, 50, 51] {
|
||||
for (bits, packing) in COMBOS {
|
||||
// Widths that leave partly filled words at the end of a line.
|
||||
let fx = Fixture::new(descriptor, bits, packing, 5, 3);
|
||||
let img = decode(&fx.bytes()).unwrap_or_else(|e| panic!("{descriptor} {bits}/{packing}: {e}"));
|
||||
let what = format!("descriptor {descriptor}, {bits} bits, packing {packing}");
|
||||
assert_eq!(detect(&fx.bytes()), Some(Format::Dpx));
|
||||
assert_eq!((img.width(), img.height()), (5, 3), "{what}");
|
||||
assert_eq!(img.layout(), layout(descriptor), "{what}");
|
||||
assert_eq!(img.layout().has_alpha(), descriptor == 51, "{what}");
|
||||
match bits {
|
||||
8 => assert_eq!(img.as_u8().unwrap(), fx.codes.iter().map(|&c| c as u8).collect::<Vec<_>>(), "{what}"),
|
||||
16 => assert_eq!(img.to_u16_samples().unwrap(), fx.codes, "{what}"),
|
||||
_ => assert_eq!(img.to_u16_samples().unwrap(), fx.codes.iter().map(|&c| expected(c, bits)).collect::<Vec<_>>(), "{what}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn big_and_little_endian_files_open_identically() {
|
||||
for (bits, packing) in COMBOS {
|
||||
let be = Fixture::new(50, bits, packing, 4, 2);
|
||||
let le = Fixture { big: false, ..be.clone() };
|
||||
assert_eq!(&le.bytes()[..4], b"XPDS");
|
||||
assert_eq!(decode(&be.bytes()).unwrap(), decode(&le.bytes()).unwrap(), "{bits} bits, packing {packing}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ten_bit_code_value_opens_as_the_rounded_16_bit_value() {
|
||||
let mut fx = Fixture::new(6, 10, 1, 1, 1);
|
||||
fx.codes = vec![512];
|
||||
assert_eq!(decode(&fx.bytes()).unwrap().to_u16_samples().unwrap(), vec![(512.0f64 * 65535.0 / 1023.0).round() as u16]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ten_and_twelve_bit_frames_written_back_at_their_depth_keep_their_code_values() {
|
||||
for (bits, choice) in [(10, DpxBits::Bits10), (12, DpxBits::Bits12)] {
|
||||
for packing in [0, 1] {
|
||||
let fx = Fixture::new(50, bits, packing, 7, 3);
|
||||
let out = encode(&decode(&fx.bytes()).unwrap(), Format::Dpx, &opts(Some(choice))).unwrap();
|
||||
let (wbits, wpacking, codes) = written_codes(&out);
|
||||
assert_eq!((wbits, wpacking), (bits, 1));
|
||||
assert_eq!(codes, fx.codes, "{bits} bits, packing {packing}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sixteen_bit_frames_round_trip_losslessly_and_files_start_with_sdpx() {
|
||||
let fx = Fixture { big: false, ..Fixture::new(51, 16, 0, 6, 4) };
|
||||
let img = decode(&fx.bytes()).unwrap();
|
||||
let out = encode(&img, Format::Dpx, &EncodeOptions::default()).unwrap();
|
||||
assert_eq!(&out[..4], b"SDPX");
|
||||
let (bits, packing, codes) = written_codes(&out);
|
||||
assert_eq!((bits, packing, codes), (16, 0, fx.codes));
|
||||
assert_eq!(decode(&out).unwrap().to_u16_samples(), img.to_u16_samples());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn log_frames_report_their_encoding_and_keep_the_plain_mapping() {
|
||||
let mut fx = Fixture::new(50, 10, 1, 3, 2);
|
||||
(fx.transfer, fx.colorimetric) = (3, 1);
|
||||
let img = decode(&fx.bytes()).unwrap();
|
||||
assert_eq!(img.warnings, vec![DecodeWarning::DpxEncoding { transfer: 3, colorimetric: 1 }]);
|
||||
let note = img.warnings[0].to_string();
|
||||
assert!(note.contains("logarithmic (3)") && note.contains("printing density (1)"), "{note}");
|
||||
assert_eq!(img.to_u16_samples().unwrap(), fx.codes.iter().map(|&c| expected(c, 10)).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_first_of_several_image_elements_is_imported() {
|
||||
let mut fx = Fixture::new(6, 8, 0, 2, 2);
|
||||
fx.elements = 2;
|
||||
let img = decode(&fx.bytes()).unwrap();
|
||||
assert_eq!(img.warnings.iter().map(ToString::to_string).collect::<Vec<_>>(), ["only the first of 2 image elements was imported"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listed_header_fields_survive_a_round_trip() {
|
||||
let mut fx = Fixture::new(50, 10, 1, 2, 2);
|
||||
fx.strings = vec![((160, 100), "Resolve 20.1"), ((260, 200), "Reel 7"), ((460, 200), "(c) 2026 Studio"), ((136, 24), "2026:10:12:09:30:00UTC")];
|
||||
(fx.transfer, fx.colorimetric, fx.frame_position, fx.frame_rate, fx.time_code) = (3, 1, 86400, 23.976, 0x0102_0304);
|
||||
let img = decode(&fx.bytes()).unwrap();
|
||||
for (k, v) in [
|
||||
("DPX:Creator", "Resolve 20.1"),
|
||||
("DPX:Project", "Reel 7"),
|
||||
("DPX:Copyright", "(c) 2026 Studio"),
|
||||
("DPX:CreationTime", "2026:10:12:09:30:00UTC"),
|
||||
("DPX:Transfer", "3"),
|
||||
("DPX:Colorimetric", "1"),
|
||||
("DPX:FramePosition", "86400"),
|
||||
("DPX:FrameRate", "23.976"),
|
||||
("DPX:TimeCode", "01:02:03:04"),
|
||||
] {
|
||||
assert!(img.meta.text.contains(&(k.into(), v.into())), "{k} = {v} in {:?}", img.meta.text);
|
||||
}
|
||||
let out = encode(&img, Format::Dpx, &opts(Some(DpxBits::Bits10))).unwrap();
|
||||
let src = fx.bytes();
|
||||
// Field by field: the strings, the element's encoding codes, film and TV fields.
|
||||
for (at, len) in [(160, 100), (260, 200), (460, 200), (136, 24), (ELEMENT0 + 21, 2), (1712, 4), (1724, 4), (1920, 4)] {
|
||||
assert_eq!(out[at..at + len], src[at..at + len], "field at {at}");
|
||||
}
|
||||
assert_eq!(decode(&out).unwrap().meta.text, img.meta.text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chosen_bit_depth_sets_the_header_and_packing() {
|
||||
let img = Image::from_u16(5, 2, ChannelLayout::Rgb, &(0..30u16).map(|i| i * 2000).collect::<Vec<_>>()).unwrap();
|
||||
for (choice, bits, packing) in [(DpxBits::Bits8, 8, 0), (DpxBits::Bits10, 10, 1), (DpxBits::Bits12, 12, 1), (DpxBits::Bits16, 16, 0)] {
|
||||
let out = encode(&img, Format::Dpx, &opts(Some(choice))).unwrap();
|
||||
assert_eq!((out[ELEMENT0 + 23], u16::from_be_bytes([out[ELEMENT0 + 24], out[ELEMENT0 + 25]])), (bits, packing), "{choice:?}");
|
||||
let back = decode(&out).unwrap();
|
||||
assert_eq!((back.width(), back.height(), back.layout()), (5, 2, ChannelLayout::Rgb));
|
||||
}
|
||||
// Without a choice the depth follows the image.
|
||||
let gray8 = Image::from_u8(3, 1, ChannelLayout::Gray, vec![0, 128, 255]).unwrap();
|
||||
assert_eq!(encode(&gray8, Format::Dpx, &EncodeOptions::default()).unwrap()[ELEMENT0 + 23], 8);
|
||||
assert_eq!(encode(&img, Format::Dpx, &EncodeOptions::default()).unwrap()[ELEMENT0 + 23], 16);
|
||||
let float = img.convert(ChannelLayout::Rgb, SampleType::F32);
|
||||
assert_eq!(encode(&float, Format::Dpx, &EncodeOptions::default()).unwrap()[ELEMENT0 + 23], 16);
|
||||
assert!(fidelity_warnings(&float, Format::Dpx).contains(&FidelityWarning::DepthReduced { from: SampleType::F32, to: SampleType::U16 }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gray_with_alpha_is_written_as_luma_with_a_warning() {
|
||||
let img = Image::from_u8(2, 1, ChannelLayout::GrayA, vec![10, 128, 200, 255]).unwrap();
|
||||
let out = encode(&img, Format::Dpx, &EncodeOptions::default()).unwrap();
|
||||
assert_eq!(out[ELEMENT0 + 20], 6);
|
||||
assert_eq!(decode(&out).unwrap().as_u8().unwrap(), &[10, 200]);
|
||||
assert!(fidelity_warnings(&img, Format::Dpx).contains(&FidelityWarning::AlphaDiscarded));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_free_form_text_is_reported_as_dropped() {
|
||||
let mut img = Image::from_u8(1, 1, ChannelLayout::Rgb, vec![1, 2, 3]).unwrap();
|
||||
img.meta.text = vec![("DPX:Project".into(), "Reel 7".into())];
|
||||
assert!(!fidelity_warnings(&img, Format::Dpx).contains(&FidelityWarning::TextDropped));
|
||||
img.meta.text.push(("Comment".into(), "from a PNG".into()));
|
||||
assert!(fidelity_warnings(&img, Format::Dpx).contains(&FidelityWarning::TextDropped));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_orientation_is_turned_upright() {
|
||||
// Orientation 4: the stored lines are the image's columns, left to right.
|
||||
let mut fx = Fixture::new(6, 8, 0, 3, 1);
|
||||
fx.codes = vec![1, 2, 3];
|
||||
fx.orientation = 4;
|
||||
let img = decode(&fx.bytes()).unwrap();
|
||||
assert_eq!((img.width(), img.height(), img.as_u8().unwrap()), (1, 3, &[1u8, 2, 3][..]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_and_damaged_files_error_instead_of_panicking() {
|
||||
let unsupported = |bytes: Vec<u8>, what: &str| {
|
||||
assert!(matches!(decode(&bytes), Err(CodecError::Unsupported { format: Format::Dpx, .. })), "{what}");
|
||||
};
|
||||
unsupported(Fixture { descriptor: 100, ..Fixture::new(50, 8, 0, 2, 2) }.bytes(), "ABGR descriptor");
|
||||
unsupported(Fixture { encoding: 1, ..Fixture::new(50, 10, 1, 2, 2) }.bytes(), "run-length encoding");
|
||||
unsupported(Fixture { signed: true, ..Fixture::new(50, 16, 0, 2, 2) }.bytes(), "signed samples");
|
||||
let mut float = Fixture::new(50, 16, 0, 2, 2).bytes();
|
||||
float[ELEMENT0 + 23] = 32;
|
||||
unsupported(float, "32-bit float");
|
||||
let mut method_b = Fixture::new(50, 12, 1, 2, 2).bytes();
|
||||
method_b[ELEMENT0 + 24..ELEMENT0 + 26].copy_from_slice(&2u16.to_be_bytes());
|
||||
unsupported(method_b, "12-bit method B");
|
||||
|
||||
let good = Fixture::new(51, 10, 1, 9, 9).bytes();
|
||||
let mut no_elements = good.clone();
|
||||
no_elements[770..772].copy_from_slice(&0u16.to_be_bytes());
|
||||
let mut past_end = good.clone();
|
||||
past_end[ELEMENT0 + 28..ELEMENT0 + 32].copy_from_slice(&u32::MAX.wrapping_sub(1).to_be_bytes());
|
||||
let mut huge = good.clone();
|
||||
huge[772..780].copy_from_slice(&[0xFF; 8]);
|
||||
for (what, bytes) in [
|
||||
("header cut short", good[..900].to_vec()),
|
||||
("truncated element", good[..good.len() - 1].to_vec()),
|
||||
("no image elements", no_elements),
|
||||
("data offset past the end", past_end),
|
||||
("absurd size", huge),
|
||||
("magic alone", b"SDPX".to_vec()),
|
||||
] {
|
||||
assert!(decode_as(Format::Dpx, &bytes).is_err(), "{what}");
|
||||
}
|
||||
}
|
||||
@@ -164,6 +164,56 @@ fn short_channel_suffixes_decode_too() {
|
||||
assert_eq!(buf.at(3, 3), &[(cube, 1.0)]);
|
||||
}
|
||||
|
||||
/// A 4x4 layer `crypto` of one RGBA set `crypto00.<r>/<g>/<b>/<a>` with the given suffixes:
|
||||
/// rank 0 (red/green) holds `cube` at 0.6 everywhere, rank 1 (blue/alpha) holds `sphere`
|
||||
/// at 0.4 everywhere, the second (ID, coverage) pair the specification stores per set.
|
||||
fn cryptomatte_file_rgba(suffixes: [&str; 4]) -> Vec<u8> {
|
||||
let (w, h) = (4usize, 4usize);
|
||||
let [r, g, b, a] = suffixes;
|
||||
let mk = |s: &str, v: f32| AnyChannel::new(format!("crypto00.{s}").as_str(), FlatSamples::F32(vec![v; w * h]));
|
||||
let list = vec![mk(r, cryptomatte_id("cube")), mk(g, 0.6), mk(b, cryptomatte_id("sphere")), mk(a, 0.4)];
|
||||
let key = cryptomatte_key("crypto");
|
||||
let mut attrs = LayerAttributes::named("crypto");
|
||||
attrs.other.insert(text(&format!("cryptomatte/{key}/name")), AttributeValue::Text(text("crypto")));
|
||||
let layer = Layer::new(
|
||||
(w, h),
|
||||
attrs,
|
||||
Encoding { compression: Compression::Uncompressed, blocks: Blocks::ScanLines, line_order: LineOrder::Increasing },
|
||||
AnyChannels::sort(list.into_iter().collect()),
|
||||
);
|
||||
let image = exr::image::Image::from_layers(ImageAttributes::new(IntegerBounds::new((0, 0), (w, h))), vec![layer]);
|
||||
let mut out = std::io::Cursor::new(Vec::new());
|
||||
image.write().to_buffered(&mut out).expect("synthetic cryptomatte exr writes");
|
||||
out.into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blue_alpha_rank_of_a_set_decodes_too() {
|
||||
// Each RGBA set holds two ranks: (red, green) and (blue, alpha). Both must come back.
|
||||
let bytes = cryptomatte_file_rgba(["red", "green", "blue", "alpha"]);
|
||||
let found = cryptomatte_layers(&bytes, &Limits::default()).unwrap();
|
||||
assert_eq!(found.len(), 1);
|
||||
assert_eq!(found[0].channels, ["crypto00"], "one set, listed once");
|
||||
let buf = decode_cryptomatte(&bytes, "crypto", &Limits::default()).unwrap();
|
||||
let (cube, sphere) = (cryptomatte_id("cube"), cryptomatte_id("sphere"));
|
||||
for y in 0..4 {
|
||||
for x in 0..4 {
|
||||
assert_eq!(buf.at(x, y), &[(cube, 0.6), (sphere, 0.4)], "({x}, {y})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uppercase_channel_suffixes_decode_both_ranks() {
|
||||
// Blender and others write `.R/.G/.B/.A`; suffixes match regardless of case.
|
||||
for suffixes in [["R", "G", "B", "A"], ["Red", "Green", "Blue", "Alpha"], ["r", "g", "b", "a"]] {
|
||||
let bytes = cryptomatte_file_rgba(suffixes);
|
||||
let buf = decode_cryptomatte(&bytes, "crypto", &Limits::default()).unwrap();
|
||||
let (cube, sphere) = (cryptomatte_id("cube"), cryptomatte_id("sphere"));
|
||||
assert_eq!(buf.at(2, 1), &[(cube, 0.6), (sphere, 0.4)], "{suffixes:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_layer_name_is_unsupported() {
|
||||
let bytes = cryptomatte_file("crypto_asset");
|
||||
|
||||
@@ -60,7 +60,7 @@ fn truncated_files_mostly_error() {
|
||||
// Chopping off the second half must not silently succeed for formats
|
||||
// with length-checked rasters.
|
||||
for (f, bytes) in samples() {
|
||||
if matches!(f, Format::Png | Format::Pnm | Format::Fits | Format::Qoi | Format::OpenExr | Format::Tiff | Format::Bmp) {
|
||||
if matches!(f, Format::Png | Format::Pnm | Format::Fits | Format::Dpx | Format::Qoi | Format::OpenExr | Format::Tiff | Format::Bmp) {
|
||||
let r = decode_as_with(f, &bytes[..bytes.len() / 2], &tight());
|
||||
assert!(r.is_err(), "{f:?} decoded half a file");
|
||||
}
|
||||
|
||||
@@ -134,6 +134,9 @@ mod pnm {
|
||||
mod fits {
|
||||
cases!(Format::Fits);
|
||||
}
|
||||
mod dpx {
|
||||
cases!(Format::Dpx);
|
||||
}
|
||||
mod qoi {
|
||||
cases!(Format::Qoi);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
use photocraft_codecs::{ChannelLayout, CodecError, Format, Image, caps, decode, decode_as, detect, encode, from_extension};
|
||||
|
||||
const SAMPLE: &[u8] = br#"/* XPM */
|
||||
static char *icon[] = {
|
||||
"2 2 3 1",
|
||||
". c #ff0000",
|
||||
" c None",
|
||||
"+ c #00ff00",
|
||||
".+",
|
||||
" +"
|
||||
};
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn xpm3_decodes_palette_transparency_and_spaces_in_keys() {
|
||||
assert_eq!(detect(SAMPLE), Some(Format::Xpm));
|
||||
assert_eq!(from_extension("icon.XPM"), Some(Format::Xpm));
|
||||
let img = decode(SAMPLE).unwrap();
|
||||
assert_eq!(img.layout(), ChannelLayout::Rgba);
|
||||
assert_eq!(img.dimensions(), (2, 2));
|
||||
assert_eq!(img.data(), &[255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 0, 0, 0, 255, 0, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rgb_precisions_and_x11_name() {
|
||||
let xpm = br#"/* XPM */
|
||||
static char *i[] = {
|
||||
"3 1 3 1",
|
||||
"a c #f00",
|
||||
"b c #ffff00000000",
|
||||
"c c gray70",
|
||||
"abc"
|
||||
};"#;
|
||||
let img = decode(xpm).unwrap();
|
||||
assert_eq!(img.data(), &[255, 0, 0, 255, 255, 0, 0, 255, 179, 179, 179, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_truncation_unsupported_colours_and_unknown_keys() {
|
||||
for bytes in [
|
||||
&b"/* XPM */\nstatic char *i[] = { \"1 1 1 1\", \"a c None\" };"[..],
|
||||
&b"/* XPM */\nstatic char *i[] = { \"1 1 1 1\", \"a c NotAColor\", \"a\" };"[..],
|
||||
&b"/* XPM */\nstatic char *i[] = { \"1 1 1 1\", \"a c #a\xC3\xA9\", \"a\" };"[..],
|
||||
&b"/* XPM */\nstatic char *i[] = { \"1 1 1 1\", \"a c #+f0000\", \"a\" };"[..],
|
||||
&b"/* XPM */\nstatic char *i[] = { \"1 1 1 1\", \"a c black\", \"z\" };"[..],
|
||||
&b"/* XPM */\nstatic char *i[] = { \"1 1 1 1\", \"a c black\", \"aa\" };"[..],
|
||||
&b"/* XPM */\nstatic char *i[] = { \"1 1 2 1\", \"a c black\", \"a c white\", \"a\" };"[..],
|
||||
] {
|
||||
assert!(decode_as(Format::Xpm, bytes).is_err(), "{:?}", bytes);
|
||||
}
|
||||
assert!(decode_as(Format::Xpm, b"/* XPM */\n\"4294967295 4294967295 1 1\"").is_err());
|
||||
assert!(decode_as(Format::Xpm, b"/* XPM */\n\"2 1 1 1\" \"a c black\" \"aa\" /* bad").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn x11_names_and_field_priority() {
|
||||
let xpm = br#"/* XPM */
|
||||
static char *i[] = {
|
||||
"4 1 4 1",
|
||||
"a c LightGray",
|
||||
"b c DarkBlue",
|
||||
"c m black g white c light blue",
|
||||
"d m black g4 red g green",
|
||||
"abcd"
|
||||
};"#;
|
||||
let image = decode(xpm).unwrap();
|
||||
assert_eq!(image.data(), &[211, 211, 211, 255, 0, 0, 139, 255, 173, 216, 230, 255, 0, 255, 0, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn named_x11_colors_use_x11_not_css_values() {
|
||||
let bytes = br#"/* XPM */
|
||||
static char *icon[] = {
|
||||
"6 1 6 1",
|
||||
"a c gray",
|
||||
"b c maroon",
|
||||
"c c purple",
|
||||
"d c LightGoldenrodYellow",
|
||||
"e c DarkSlateGray4",
|
||||
"f c Ghost White",
|
||||
"abcdef"
|
||||
};"#;
|
||||
let image = decode(bytes).unwrap();
|
||||
assert_eq!(image.data(), &[190, 190, 190, 255, 176, 48, 96, 255, 160, 32, 240, 255, 250, 250, 210, 255, 82, 139, 139, 255, 248, 248, 255, 255,]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xpm2_decodes_crlf_comments_spaces_and_multichar_keys() {
|
||||
let bytes = b"! XPM2\r\n! exported by an icon tool\r\n2 2 3 2\r\n.a c #f00\r\n!_ c None\r\nzz c DarkBlue\r\n.a!_\r\nzz.a\r\n";
|
||||
assert_eq!(detect(bytes), Some(Format::Xpm));
|
||||
let image = decode(bytes).unwrap();
|
||||
assert_eq!(image.dimensions(), (2, 2));
|
||||
assert_eq!(image.data(), &[255, 0, 0, 255, 0, 0, 0, 0, 0, 0, 139, 255, 255, 0, 0, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xpm2_allows_exclamation_as_a_pixel_key_and_preserves_space_keys() {
|
||||
let bytes = b"! XPM2\n2 1 2 1\n! c red\n c None\n! \n";
|
||||
let image = decode(bytes).unwrap();
|
||||
assert_eq!(image.data(), &[255, 0, 0, 255, 0, 0, 0, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xpm2_malformed_inputs_return_errors() {
|
||||
for bytes in [
|
||||
&b"! XPM2\n1 1 1 1\na c black\n"[..],
|
||||
&b"! XPM2\n1 1 1 1\na c black\nb\n"[..],
|
||||
&b"! XPM2\n1 1 1 1\na c #a\xc3\xa9\na\n"[..],
|
||||
&b"! XPM2 invalid\n1 1 1 1\na c black\na\n"[..],
|
||||
&b"! XPM2\n100000 100000 1 1\na c red\na\n"[..],
|
||||
] {
|
||||
assert!(decode_as(Format::Xpm, bytes).is_err(), "{bytes:?}");
|
||||
}
|
||||
assert_ne!(detect(b"! XPM23\n1 1 1 1\na c black\na\n"), Some(Format::Xpm));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xpm_is_import_only() {
|
||||
assert!(caps(Format::Xpm).read);
|
||||
assert!(!caps(Format::Xpm).write);
|
||||
let image = Image::from_u8(1, 1, ChannelLayout::Rgba, vec![0, 0, 0, 0]).unwrap();
|
||||
assert!(matches!(encode(&image, Format::Xpm, &Default::default()), Err(CodecError::Unsupported { .. })));
|
||||
}
|
||||
+161
-15
@@ -158,10 +158,11 @@ pub fn apply_depth(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth
|
||||
map_rgb(buf, |c| {
|
||||
let (dh, ds, dl) = match &table {
|
||||
Some(t) => {
|
||||
// Range edits follow the pixel's original hue, faded out towards grey (greys have no hue).
|
||||
// Range edits follow the pixel's original hue, in full however dull the colour
|
||||
// (Photoshop's merged images, #4089). Greys stay grey: no hue to shift, no
|
||||
// saturation to scale, and range Lightness moves towards a channel they share.
|
||||
let h0 = rgb_to_hsl(c).0;
|
||||
let chroma = ((c[0].max(c[1]).max(c[2]) - c[0].min(c[1]).min(c[2])) * 4.0).min(1.0);
|
||||
(lut(&t[0], h0) * chroma, lut(&t[1], h0) * chroma, lut(&t[2], h0) * chroma)
|
||||
(lut(&t[0], h0), lut(&t[1], h0), lut(&t[2], h0))
|
||||
}
|
||||
None => (0.0, 0.0, 0.0),
|
||||
};
|
||||
@@ -248,11 +249,18 @@ fn legacy_brightness_contrast(brightness: f32, contrast: f32) -> impl Fn(f32) ->
|
||||
}
|
||||
|
||||
/// Applies an adjustment in a document of colour `mode`: [`apply_depth`], except that in Lab
|
||||
/// documents the adjustments Photoshop evaluates on the L, a and b channels do so (see
|
||||
/// [`lab_channel_adjustment`]).
|
||||
/// and CMYK documents the adjustments Photoshop evaluates on the document's own channels do so
|
||||
/// (see [`lab_channel_adjustment`] and [`cmyk_channel_adjustment`]).
|
||||
pub fn apply_in(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth: Option<SampleType>, mode: ColorMode) {
|
||||
if mode == ColorMode::Lab && lab_channel_adjustment(adj) {
|
||||
apply_lab(adj, buf);
|
||||
apply_lab(adj, buf, depth);
|
||||
} else if mode == ColorMode::Cmyk && cmyk_channel_adjustment(adj) {
|
||||
use photocraft_color::convert::{cmyk_to_rgb, rgb_to_cmyk};
|
||||
map_rgb(buf, |c| {
|
||||
let mut ink = rgb_to_cmyk(c);
|
||||
stored_channels(adj, mode, depth, &mut ink, rgb_to_gray(c));
|
||||
cmyk_to_rgb(ink)
|
||||
});
|
||||
} else {
|
||||
apply_depth(adj, buf, transfer, depth);
|
||||
}
|
||||
@@ -273,6 +281,51 @@ pub fn lab_channel_adjustment(adj: &Adjustment) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether a CMYK document evaluates `adj` on its inks rather than on the screen colour: Invert
|
||||
/// and Posterize (each ink, black included, so paper inverts to 400% ink) and Threshold (paper or
|
||||
/// 100% black ink alone). Photoshop corpus cmyk8 and cmyk16 invert, posterize-4 and threshold-128
|
||||
/// follow these rules on the files' own inks on every pixel; here the inks are the screen colour
|
||||
/// separated through the document's CMYK profile, so neutrals carry the black that profile
|
||||
/// generates rather than the file's.
|
||||
pub fn cmyk_channel_adjustment(adj: &Adjustment) -> bool {
|
||||
stored_channel_adjustment(adj)
|
||||
}
|
||||
|
||||
/// Whether [`stored_channels`] evaluates `adj`: Invert, Posterize and Threshold.
|
||||
pub fn stored_channel_adjustment(adj: &Adjustment) -> bool {
|
||||
matches!(adj, Adjustment::Invert | Adjustment::Posterize { .. } | Adjustment::Threshold { .. })
|
||||
}
|
||||
|
||||
/// Invert, Posterize or Threshold on one pixel's own channels in a Lab or CMYK document of
|
||||
/// `depth` (`None`: unknown, treated as 8-bit): `ch` holds L, a, b normalised as [`lab_norm`]
|
||||
/// gives them, or the C, M, Y, K inks (0: none); `gray` is the pixel's luma, which CMYK Threshold
|
||||
/// compares. Lab Invert turns 8-bit bytes `v` into `255 - v` (a becomes -1 - a) and mirrors a and
|
||||
/// b around neutral at 16 bits (photoshop corpus lab16 invert.psd: neutral stays neutral).
|
||||
pub fn stored_channels(adj: &Adjustment, mode: ColorMode, depth: Option<SampleType>, ch: &mut [f32], gray: f32) {
|
||||
let lab = mode == ColorMode::Lab;
|
||||
match adj {
|
||||
Adjustment::Invert => {
|
||||
let mirror = if lab && depth.is_some_and(|d| d != SampleType::U8) { 256.0 / 255.0 } else { 1.0 };
|
||||
for (i, v) in ch.iter_mut().enumerate() {
|
||||
*v = if i > 0 { mirror } else { 1.0 } - v.clamp(0.0, 1.0);
|
||||
}
|
||||
}
|
||||
Adjustment::Posterize { levels } => ch.iter_mut().for_each(|v| *v = posterize(*v, *levels)),
|
||||
Adjustment::Threshold { level } => {
|
||||
// On the 8-bit grid, like the RGB threshold.
|
||||
let t = (level * 255.0).round();
|
||||
let out = if lab {
|
||||
let white = ch.first().is_some_and(|l| (l.clamp(0.0, 1.0) * 255.0).round() >= t);
|
||||
[if white { 1.0 } else { 0.0 }, 128.0 / 255.0, 128.0 / 255.0, 0.0]
|
||||
} else {
|
||||
[0.0, 0.0, 0.0, if (gray * 255.0).round() < t { 1.0 } else { 0.0 }]
|
||||
};
|
||||
ch.iter_mut().zip(out).for_each(|(v, o)| *v = o);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Encoded sRGB → Lab normalised the way Photoshop's channels hold it: L/100, (a+128)/255,
|
||||
/// (b+128)/255.
|
||||
fn lab_norm(c: [f32; 3]) -> [f32; 3] {
|
||||
@@ -285,12 +338,16 @@ fn lab_denorm(n: [f32; 3]) -> [f32; 3] {
|
||||
lab_to_srgb([n[0] * 100.0, n[1] * 255.0 - 128.0, n[2] * 255.0 - 128.0])
|
||||
}
|
||||
|
||||
/// The Lab evaluation of a [`lab_channel_adjustment`] (no-op for any other adjustment).
|
||||
fn apply_lab(adj: &Adjustment, buf: &mut Buffer) {
|
||||
/// The Lab evaluation of a [`lab_channel_adjustment`] in a document of `depth` (no-op for any
|
||||
/// other adjustment).
|
||||
fn apply_lab(adj: &Adjustment, buf: &mut Buffer, depth: Option<SampleType>) {
|
||||
match adj {
|
||||
// n → 1 - n on every channel: L → 100 - L and a, b mirrored about neutral.
|
||||
Adjustment::Invert => map_rgb(buf, |c| lab_denorm(lab_norm(c).map(|v| 1.0 - v.clamp(0.0, 1.0)))),
|
||||
Adjustment::Posterize { levels } => map_rgb(buf, |c| lab_denorm(lab_norm(c).map(|v| posterize(v, *levels)))),
|
||||
// L → 100 - L and a, b mirrored about neutral; each channel posterized (see `stored_channels`).
|
||||
Adjustment::Invert | Adjustment::Posterize { .. } => map_rgb(buf, |c| {
|
||||
let mut n = lab_norm(c);
|
||||
stored_channels(adj, ColorMode::Lab, depth, &mut n, 0.0);
|
||||
lab_denorm(n)
|
||||
}),
|
||||
Adjustment::Threshold { level } => {
|
||||
// Split on L alone (on the 8-bit grid); the result is neutral black or white.
|
||||
let t = (level * 255.0).round();
|
||||
@@ -493,6 +550,10 @@ pub fn bayer4(x: i32, y: i32) -> f32 {
|
||||
|
||||
/// Samples a 3D LUT (`n`³ RGB triplets, red fastest) at `c`, trilinear or tetrahedral.
|
||||
pub fn lut3d_sample(lut: &[f32], n: usize, c: [f32; 3], tetrahedral: bool) -> [f32; 3] {
|
||||
// A degenerate or short table can't be sampled: leave the colour alone.
|
||||
if n < 2 || n.checked_pow(3).and_then(|c| c.checked_mul(3)).is_none_or(|len| lut.len() < len) {
|
||||
return c;
|
||||
}
|
||||
let m = (n - 1) as f32;
|
||||
let pos = c.map(|v| v.clamp(0.0, 1.0) * m);
|
||||
let i0 = pos.map(|p| (p.floor() as usize).min(n - 2));
|
||||
@@ -1377,6 +1438,37 @@ mod tone_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Photoshop's own render (merged image of psd-tools `adjustments/huesaturation_lightness_rgb.psd`,
|
||||
/// layer "Hue/Saturation 1"): a dull magenta gets the whole range edit, as a vivid one does (#4089).
|
||||
#[test]
|
||||
fn hue_ranges_apply_in_full_to_dull_colours() {
|
||||
let range = |lightness: f32, bounds: [f32; 4]| HueRange { hue: 0.0, saturation: 0.0, lightness, bounds };
|
||||
let ranges = [
|
||||
range(-11.0, [-57.0, 4.0, 82.0, 175.0]),
|
||||
range(100.0, [12.0, 45.0, 75.0, 105.0]),
|
||||
HueRange::neutral(2),
|
||||
HueRange::neutral(3),
|
||||
HueRange::neutral(4),
|
||||
range(100.0, [-117.0, -50.0, 39.0, 104.0]),
|
||||
];
|
||||
let a = Adjustment::HueSaturation { hue: 0.0, saturation: 0.0, lightness: 17.0, colorize: false, ranges };
|
||||
let measured = [([126u8, 97, 120], [148u8, 148, 148]), ([131, 103, 125], [152, 152, 152]), ([95, 69, 90], [122, 122, 122])];
|
||||
for (input, want) in measured {
|
||||
let c = input.map(|v| v as f32 / 255.0);
|
||||
let mut b = Buffer { rect: Rect::new(0, 0, 1, 1), px: vec![[c[0], c[1], c[2], 1.0]] };
|
||||
apply(&a, &mut b);
|
||||
for (i, w) in want.iter().enumerate() {
|
||||
let got = b.px[0][i] * 255.0;
|
||||
assert!((got - *w as f32).abs() <= 2.0, "{input:?} channel {i}: {got} vs {w}");
|
||||
}
|
||||
}
|
||||
// Greys have no hue, so the ranges leave them alone: only the master Lightness moves them.
|
||||
let mut b = Buffer { rect: Rect::new(0, 0, 1, 1), px: vec![[0.4, 0.4, 0.4, 1.0]] };
|
||||
apply(&a, &mut b);
|
||||
let p = b.px[0];
|
||||
assert!((p[0] - p[1]).abs() < 1e-6 && (p[1] - p[2]).abs() < 1e-6 && (p[0] - (0.4 + 0.6 * 0.17)).abs() < 1e-4, "{p:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn black_white_weights() {
|
||||
let d = [40.0, 60.0, 40.0, 60.0, 20.0, 80.0];
|
||||
@@ -1453,6 +1545,41 @@ mod tone_tests {
|
||||
assert!(!near(srgb_to_lab([rgb.px[0][0], rgb.px[0][1], rgb.px[0][2]]), got, 1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sixteen_bit_lab_invert_keeps_greys_neutral() {
|
||||
// 16-bit Lab Invert mirrors a and b around neutral (photoshop corpus lab16 invert.psd);
|
||||
// 8-bit bytes land one step off it.
|
||||
let mut b = Buffer::filled(crate::Rect::new(0, 0, 1, 1), [0.0, 0.0, 0.0, 1.0]);
|
||||
let c = lab(40.0, 0.0, 0.0);
|
||||
b.px[0] = [c[0], c[1], c[2], 1.0];
|
||||
apply_in(&Adjustment::Invert, &mut b, Transfer::Srgb, Some(SampleType::U16), ColorMode::Lab);
|
||||
let got = srgb_to_lab([b.px[0][0], b.px[0][1], b.px[0][2]]);
|
||||
assert!(near(got, [60.0, 0.0, 0.0], 0.05), "{got:?}");
|
||||
assert!(near(in_lab(&Adjustment::Invert, c), [60.0, -1.0, -1.0], 0.1));
|
||||
}
|
||||
|
||||
/// `adj` applied in a CMYK document to one encoded sRGB colour.
|
||||
fn in_cmyk(adj: &Adjustment, c: [f32; 3]) -> [f32; 3] {
|
||||
let mut b = Buffer::filled(crate::Rect::new(0, 0, 1, 1), [c[0], c[1], c[2], 1.0]);
|
||||
apply_in(adj, &mut b, Transfer::Srgb, Some(SampleType::U8), ColorMode::Cmyk);
|
||||
[b.px[0][0], b.px[0][1], b.px[0][2]]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmyk_invert_posterize_and_threshold_work_on_the_inks() {
|
||||
use photocraft_color::convert::{cmyk_to_rgb, rgb_to_cmyk};
|
||||
// Paper inverts to all four inks at 100%, not to RGB black (photoshop corpus cmyk8 invert.psd).
|
||||
assert!(near(in_cmyk(&Adjustment::Invert, [1.0; 3]), cmyk_to_rgb([1.0; 4]), 2e-3));
|
||||
let red = [0.9, 0.2, 0.1];
|
||||
assert!(near(in_cmyk(&Adjustment::Invert, red), cmyk_to_rgb(rgb_to_cmyk(red).map(|v| 1.0 - v)), 2e-3));
|
||||
let two = cmyk_to_rgb(rgb_to_cmyk(red).map(|v| if v >= 0.5 { 1.0 } else { 0.0 }));
|
||||
assert!(near(in_cmyk(&Adjustment::Posterize { levels: 2 }, red), two, 2e-3));
|
||||
// Threshold's black is the black ink alone (threshold-128.psd), its white the paper.
|
||||
let t = Adjustment::Threshold { level: 0.5 };
|
||||
assert!(near(in_cmyk(&t, [0.2; 3]), cmyk_to_rgb([0.0, 0.0, 0.0, 1.0]), 2e-3));
|
||||
assert!(near(in_cmyk(&t, [0.8; 3]), cmyk_to_rgb([0.0; 4]), 2e-3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lab_posterize_steps_l_a_and_b() {
|
||||
let got = in_lab(&Adjustment::Posterize { levels: 2 }, lab(70.0, 20.0, -30.0));
|
||||
@@ -1511,15 +1638,18 @@ mod tone_tests {
|
||||
let (mut a, mut b) = (Buffer::filled(crate::Rect::new(0, 0, 1, 1), c), Buffer::filled(crate::Rect::new(0, 0, 1, 1), c));
|
||||
apply_in(adj, &mut a, Transfer::Srgb, None, mode);
|
||||
apply_depth(adj, &mut b, Transfer::Srgb, None);
|
||||
assert_eq!(a.px == b.px, mode != ColorMode::Lab || !lab_channel_adjustment(adj), "{adj:?} in {mode:?}");
|
||||
let own = (mode == ColorMode::Lab && lab_channel_adjustment(adj)) || (mode == ColorMode::Cmyk && cmyk_channel_adjustment(adj));
|
||||
assert_eq!(a.px == b.px, !own, "{adj:?} in {mode:?}");
|
||||
}
|
||||
}
|
||||
// Transparent pixels are left alone and odd values never panic.
|
||||
let mut t = Buffer::filled(crate::Rect::new(0, 0, 2, 1), [f32::NAN, 2.0, -1.0, 0.0]);
|
||||
for adj in lab_only.iter().chain([&Adjustment::GradientMap { stops: vec![], reverse: true, dither: true }]) {
|
||||
apply_in(adj, &mut t, Transfer::Srgb, None, ColorMode::Lab);
|
||||
let mut odd = Buffer::filled(crate::Rect::new(0, 0, 1, 1), [f32::NAN, f32::INFINITY, -3.0, 1.0]);
|
||||
apply_in(adj, &mut odd, Transfer::Srgb, None, ColorMode::Lab);
|
||||
for mode in [ColorMode::Lab, ColorMode::Cmyk] {
|
||||
apply_in(adj, &mut t, Transfer::Srgb, None, mode);
|
||||
let mut odd = Buffer::filled(crate::Rect::new(0, 0, 1, 1), [f32::NAN, f32::INFINITY, -3.0, 1.0]);
|
||||
apply_in(adj, &mut odd, Transfer::Srgb, Some(SampleType::U16), mode);
|
||||
}
|
||||
}
|
||||
assert!(t.px[0][1] == 2.0);
|
||||
}
|
||||
@@ -1528,3 +1658,19 @@ mod tone_tests {
|
||||
/// Legacy Brightness/Contrast scales contrast around 128 of 255 (contrast 80 keeps 128 at 128,
|
||||
/// measured on Photoshop 27.11; a 0.5 pivot moved it to 130).
|
||||
pub const LEGACY_PIVOT: f32 = 128.0 / 255.0;
|
||||
|
||||
#[cfg(test)]
|
||||
mod lut3d_guard_tests {
|
||||
use super::lut3d_sample;
|
||||
|
||||
#[test]
|
||||
fn degenerate_or_short_tables_leave_the_colour_alone() {
|
||||
let c = [0.2, 0.4, 0.6];
|
||||
for tetra in [false, true] {
|
||||
assert_eq!(lut3d_sample(&[], 0, c, tetra), c);
|
||||
assert_eq!(lut3d_sample(&[0.0; 3], 1, c, tetra), c);
|
||||
assert_eq!(lut3d_sample(&[0.0; 3], 2, c, tetra), c);
|
||||
assert_eq!(lut3d_sample(&[0.0; 24], usize::MAX, c, tetra), c);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -246,6 +246,26 @@ fn blend_interior_effects_as_group_puts_overlays_under_fill_opacity() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interior_effects_blend_over_the_layer_mode_unless_grouped() {
|
||||
// #3996: #888888 under a black Color-mode layer with a red Overlay colour overlay. The
|
||||
// layer's mode applies to its own pixels (grey stays grey), then the overlay blends onto
|
||||
// that result and tints it; as a group the overlay of black is black, so grey remains.
|
||||
let grey = 136.0 / 255.0;
|
||||
let lift = 1.0 - 2.0 * (1.0 - grey);
|
||||
for depth in DEPTHS {
|
||||
let mut d = doc(depth, false);
|
||||
d.layers.push(solid(&d, "grey", full(&d), [grey, grey, grey, 1.0]));
|
||||
let mut l = solid(&d, "black", full(&d), [0.0, 0.0, 0.0, 1.0]);
|
||||
l.blend = BlendMode::Color;
|
||||
l.effects.items = vec![Effect::ColorOverlay { common: FxCommon::new(BlendMode::Overlay, 1.0), color: Color::rgb(1.0, 0.0, 0.0) }];
|
||||
d.layers.push(l);
|
||||
check(&d, 2, 2, [1.0, lift, lift, 1.0], "default: overlay over the Color blend");
|
||||
d.layers[1].advanced.blend_interior = true;
|
||||
check(&d, 2, 2, [grey, grey, grey, 1.0], "as group: overlay inside the Color blend");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transparency_shapes_off_spreads_effects_over_the_whole_layer() {
|
||||
for depth in DEPTHS {
|
||||
@@ -406,6 +426,26 @@ fn vector_mask_hides_effects() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The per-render vector-mask cache must key masks by content: `hiding_mask` and
|
||||
/// `effects_source` hand it temporary clones whose addresses get reused (#4426).
|
||||
#[test]
|
||||
fn vector_mask_cache_does_not_mix_up_layers() {
|
||||
use photocraft_doc::{Path, Subpath, VectorMask};
|
||||
for depth in DEPTHS {
|
||||
let mut d = doc(depth, true);
|
||||
for (name, color, x0, x1) in [("a", BLUE, -4.0, 6.0), ("b", GREEN, 6.0, 16.0)] {
|
||||
let mut l = solid(&d, name, full(&d), color);
|
||||
l.effects.items = vec![inside_stroke()];
|
||||
l.advanced.vector_mask_hides_effects = true;
|
||||
let path = Path::new(vec![Subpath::polygon(&[(x0, -4.0), (x1, -4.0), (x1, 12.0), (x0, 12.0)])]);
|
||||
l.vector_mask = Some(VectorMask::new(path));
|
||||
d.layers.push(l);
|
||||
}
|
||||
check(&d, 2, 4, BLUE, "layer A inside its own mask");
|
||||
check(&d, 9, 4, GREEN, "layer B inside its own mask");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_take_the_plain_paths() {
|
||||
let d = doc(SampleType::U8, true);
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex, Weak};
|
||||
|
||||
use photocraft_geom::{Rect, TILE_SIZE};
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_raster::{Surface, Tile};
|
||||
|
||||
struct Entry {
|
||||
@@ -15,25 +15,23 @@ struct Entry {
|
||||
bounds: Option<(u16, u16, u16, u16)>,
|
||||
}
|
||||
|
||||
fn cache() -> &'static Mutex<HashMap<usize, Entry>> {
|
||||
static C: std::sync::OnceLock<Mutex<HashMap<usize, Entry>>> = std::sync::OnceLock::new();
|
||||
C.get_or_init(Default::default)
|
||||
/// The entries, and the size past which dead ones are dropped (see [`next_sweep`]).
|
||||
struct Cache {
|
||||
entries: HashMap<usize, Entry>,
|
||||
sweep_at: usize,
|
||||
}
|
||||
|
||||
/// Pixels of `t` that differ from `dp` (tile-local half-open bounds).
|
||||
fn scan(t: &Tile, dp: &[u8]) -> Option<(u16, u16, u16, u16)> {
|
||||
let bpp = dp.len();
|
||||
let ts = TILE_SIZE as usize;
|
||||
let (mut x0, mut y0, mut x1, mut y1) = (usize::MAX, usize::MAX, 0usize, 0usize);
|
||||
for (y, row) in t.bytes().chunks_exact(ts * bpp).enumerate() {
|
||||
let Some(first) = row.chunks_exact(bpp).position(|px| px != dp) else { continue };
|
||||
let last = ts - 1 - row.chunks_exact(bpp).rev().position(|px| px != dp).unwrap_or(0);
|
||||
x0 = x0.min(first);
|
||||
x1 = x1.max(last + 1);
|
||||
y0 = y0.min(y);
|
||||
y1 = y + 1;
|
||||
}
|
||||
(x0 != usize::MAX).then_some((x0 as u16, y0 as u16, x1 as u16, y1 as u16))
|
||||
const MIN_SWEEP: usize = 1 << 16;
|
||||
|
||||
/// The size at which to sweep again after a sweep left `survivors` live entries: twice that (at
|
||||
/// least [`MIN_SWEEP`]), so a cache full of live tiles isn't swept again on every call.
|
||||
fn next_sweep(survivors: usize) -> usize {
|
||||
survivors.saturating_mul(2).max(MIN_SWEEP)
|
||||
}
|
||||
|
||||
fn cache() -> &'static Mutex<Cache> {
|
||||
static C: std::sync::OnceLock<Mutex<Cache>> = std::sync::OnceLock::new();
|
||||
C.get_or_init(|| Mutex::new(Cache { entries: HashMap::new(), sweep_at: MIN_SWEEP }))
|
||||
}
|
||||
|
||||
/// `Surface::content_bounds`, cached per tile.
|
||||
@@ -45,18 +43,19 @@ pub fn content_bounds(s: &Surface) -> Rect {
|
||||
let mut dp = vec![0u8; fmt.bytes_per_pixel()];
|
||||
photocraft_raster::encode_pixel(&fmt, &s.default_pixel(), &mut dp);
|
||||
let mut c = cache().lock().unwrap_or_else(|e| e.into_inner());
|
||||
if c.len() > 1 << 16 {
|
||||
c.retain(|_, e| e.tile.strong_count() > 0);
|
||||
if c.entries.len() > c.sweep_at {
|
||||
c.entries.retain(|_, e| e.tile.strong_count() > 0);
|
||||
c.sweep_at = next_sweep(c.entries.len());
|
||||
}
|
||||
let mut out = Rect::EMPTY;
|
||||
for (coord, t) in s.tiles() {
|
||||
let key = Arc::as_ptr(t) as usize;
|
||||
let hit = c.get(&key).filter(|e| e.tile.upgrade().is_some_and(|u| Arc::ptr_eq(&u, t)) && *e.default == *dp).map(|e| e.bounds);
|
||||
let hit = c.entries.get(&key).filter(|e| e.tile.upgrade().is_some_and(|u| Arc::ptr_eq(&u, t)) && *e.default == *dp).map(|e| e.bounds);
|
||||
let b = match hit {
|
||||
Some(b) => b,
|
||||
None => {
|
||||
let b = scan(t, &dp);
|
||||
c.insert(key, Entry { tile: Arc::downgrade(t), default: dp.clone().into_boxed_slice(), bounds: b });
|
||||
let b = t.content_bounds(&dp);
|
||||
c.entries.insert(key, Entry { tile: Arc::downgrade(t), default: dp.clone().into_boxed_slice(), bounds: b });
|
||||
b
|
||||
}
|
||||
};
|
||||
@@ -68,3 +67,25 @@ pub fn content_bounds(s: &Surface) -> Rect {
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sweeps_again_only_past_twice_the_survivors() {
|
||||
// A cache of live tiles past the minimum isn't swept on every call (it used to be).
|
||||
assert_eq!(next_sweep(0), MIN_SWEEP);
|
||||
assert_eq!(next_sweep(MIN_SWEEP / 2), MIN_SWEEP);
|
||||
assert_eq!(next_sweep(MIN_SWEEP), 2 * MIN_SWEEP);
|
||||
assert_eq!(next_sweep(usize::MAX), usize::MAX);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shared_tile_reads_the_same_bounds_at_every_coordinate() {
|
||||
let mut s = Surface::new(photocraft_color::PixelFormat::RGBA8);
|
||||
s.fill_rect(Rect::new(-300, 10, 1300, 900), &[0.2, 0.4, 0.6, 1.0]);
|
||||
assert_eq!(content_bounds(&s), Rect::new(-300, 10, 1300, 900));
|
||||
assert_eq!(content_bounds(&s), s.content_bounds(), "cached");
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,9 @@
|
||||
//! drop shadows, outer glows, outer bevel, then the layer itself (content at
|
||||
//! fill opacity) with pattern/gradient/colour overlays, satin, inner glows,
|
||||
//! inner shadows, strokes and the inner bevel on top. Exterior effects blend
|
||||
//! straight into the backdrop with their own modes; the layer and its
|
||||
//! interior effects blend with the layer's mode. Emboss and pillow emboss then
|
||||
//! straight into the backdrop with their own modes; the layer blends with its
|
||||
//! mode and the interior effects paint over the result, or blend with it as a
|
||||
//! group (Blend Interior Effects as Group, [`premerges_layer`]). Emboss and pillow emboss then
|
||||
//! shade the composited result (inside and outside halves). Layer opacity
|
||||
//! applies to the whole stack; fill opacity only to the layer's own pixels.
|
||||
//!
|
||||
@@ -94,6 +95,10 @@ impl Map {
|
||||
/// Shifted copy (bilinear), outside reads `outside`.
|
||||
fn shifted(&self, dx: f32, dy: f32, outside: f32) -> Map {
|
||||
let mut out = Map::new(self.w, self.h, 0.0);
|
||||
// A hostile distance (±1e30, inf, NaN) would overflow the i64 offsets below; no shift beyond
|
||||
// 2^30 px can land inside a map, so clamp (non-finite shifts read as none).
|
||||
let reach = |d: f32| if d.is_finite() { d.clamp(-1.0e9, 1.0e9) } else { 0.0 };
|
||||
let (dx, dy) = (reach(dx), reach(dy));
|
||||
let (fx, fy) = (dx.floor(), dy.floor());
|
||||
let (ax, ay) = (dx - fx, dy - fy);
|
||||
let s = |x: i64, y: i64| if x < 0 || y < 0 || x >= self.w as i64 || y >= self.h as i64 { outside } else { self.v[y as usize * self.w + x as usize] };
|
||||
@@ -1791,6 +1796,14 @@ fn knock_out_exterior(map: &mut Map, shape: &Map, fill_opacity: f32) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Blend Interior Effects as Group off (Photoshop's default): the layer's mode applies to its
|
||||
/// own pixels only, and the interior effects paint over the blended result. The layer is merged
|
||||
/// into the backdrop before they paint, so an Overlay colour overlay on a black Color-mode layer
|
||||
/// tints the grey beneath it (#3996). Normal-like modes give the same result either way.
|
||||
pub fn premerges_layer(layer: &Layer) -> bool {
|
||||
!layer.advanced.blend_interior && !matches!(layer.blend, BlendMode::Normal | BlendMode::PassThrough | BlendMode::Dissolve)
|
||||
}
|
||||
|
||||
/// Composites `content` (the layer's own pixels over `big`, alpha already
|
||||
/// masked, clipped layers applied) plus its effects into `backdrop`.
|
||||
pub fn composite_with_effects(layer: &Layer, content: &Buffer, backdrop: &mut Buffer, maps: &FxMaps, layer_bounds: Rect, patterns: &[Pattern]) {
|
||||
@@ -1889,6 +1902,13 @@ pub(crate) fn composite_with_effects_prepared(
|
||||
}
|
||||
};
|
||||
let mut lay = Buffer { rect: big, px: content.px.iter().zip(&shape.v).enumerate().map(|(i, (p, a))| [p[0], p[1], p[2], lay_alpha(i, p, *a)]).collect() };
|
||||
let gamma = crate::text_gamma(layer);
|
||||
let premerge = premerges_layer(layer);
|
||||
if premerge {
|
||||
for (lp, wp) in lay.px.iter_mut().zip(&work.px) {
|
||||
*lp = psblend::composite_gamma(layer.blend, *wp, *lp, 1.0, gamma);
|
||||
}
|
||||
}
|
||||
let rel = |m: Map| -> Map {
|
||||
let v = m.v.iter().zip(&shape.v).map(|(m, a)| if inside(*a) { (m / a).min(1.0) } else { 0.0 }).collect();
|
||||
Map { w: m.w, h: m.h, v }
|
||||
@@ -2051,8 +2071,7 @@ pub(crate) fn composite_with_effects_prepared(
|
||||
}
|
||||
|
||||
// Layer (with interior effects) onto the exterior result, in the layer's mode.
|
||||
let mode = if layer.blend == BlendMode::PassThrough { BlendMode::Normal } else { layer.blend };
|
||||
let gamma = crate::text_gamma(layer);
|
||||
let mode = if premerge || layer.blend == BlendMode::PassThrough { BlendMode::Normal } else { layer.blend };
|
||||
for (wp, lp) in work.px.iter_mut().zip(&lay.px) {
|
||||
if lp[3] > 0.0 {
|
||||
*wp = psblend::composite_gamma(mode, *wp, *lp, 1.0, gamma);
|
||||
@@ -3107,4 +3126,13 @@ mod tests {
|
||||
let diff: f32 = plain.v.iter().zip(&noisy.v).map(|(a, b)| (a - b).abs()).sum();
|
||||
assert!(diff > 5.0, "glow noise changes the coverage: {diff}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shifting_by_hostile_distances_does_not_overflow() {
|
||||
let m = square_shape(12);
|
||||
for d in [1.0e30, -1.0e30, f32::INFINITY, f32::NAN] {
|
||||
let s = m.shifted(d, d, 0.0);
|
||||
assert_eq!(s.v.len(), 144);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -88,6 +88,11 @@ pub fn render(doc: &Document, rect: Rect) -> Buffer {
|
||||
render_tiled(doc, rect, RENDER_TILE)
|
||||
}
|
||||
|
||||
/// Most pixels one [`render`] call will produce (2^30, a 16 GiB buffer; 32768² px). Larger rects
|
||||
/// (a hostile canvas size is up to 300000 px a side) return an empty buffer instead of aborting
|
||||
/// on allocation; callers wanting more render in pieces.
|
||||
pub const MAX_RENDER_PIXELS: u64 = 1 << 30;
|
||||
|
||||
/// [`render`] with an explicit tile size (tests check tile independence).
|
||||
pub fn render_tiled(doc: &Document, rect: Rect, tile: i32) -> Buffer {
|
||||
let patterns = pattern::PreparedPatterns::new(&doc.patterns, pattern::PREPARED_PATTERN_BYTES).in_mode(doc.pixel_format().mode);
|
||||
@@ -107,8 +112,12 @@ fn render_tiled_with(doc: &Document, rect: Rect, tile: i32, cx: &Ctx) -> Buffer
|
||||
if rect.is_empty() {
|
||||
return Buffer::transparent(rect);
|
||||
}
|
||||
// Refuse rects too large to hold: an empty buffer, not an allocation failure.
|
||||
if u64::from(rect.width()) * u64::from(rect.height()) > MAX_RENDER_PIXELS {
|
||||
return Buffer::transparent(Rect::EMPTY);
|
||||
}
|
||||
cx.set_fx_clip(rect);
|
||||
if rect.width() as i32 <= tile && rect.height() as i32 <= tile {
|
||||
if i64::from(rect.width()) <= i64::from(tile) && i64::from(rect.height()) <= i64::from(tile) {
|
||||
return photocraft_color::convert::with_cmyk_space(cmyk, || {
|
||||
let mut buf = multichannel::backdrop(doc, rect);
|
||||
in_blend_space(lab, hdr, || composite_stack_at(&doc.layers, &mut buf, cx, advanced::Scope::ROOT));
|
||||
@@ -451,18 +460,27 @@ fn render_reduced_in_bands(doc: &Document, b: Rect, w: u32, h: u32, damage: Opti
|
||||
Buffer { rect: out, px: acc }
|
||||
}
|
||||
|
||||
type VectorMaskSlot = std::sync::Arc<std::sync::OnceLock<photocraft_vector::CompiledVectorMask>>;
|
||||
|
||||
#[derive(Default)]
|
||||
struct RenderVectorMasks {
|
||||
masks: std::sync::Mutex<std::collections::HashMap<usize, std::sync::Arc<std::sync::OnceLock<photocraft_vector::CompiledVectorMask>>>>,
|
||||
/// Compiled masks by content. Callers pass temporary clones (see `advanced.rs`), so an
|
||||
/// address cannot identify a mask: a dropped clone's address is reused by the next one.
|
||||
masks: std::sync::Mutex<Vec<(photocraft_doc::VectorMask, VectorMaskSlot)>>,
|
||||
}
|
||||
|
||||
impl RenderVectorMasks {
|
||||
fn values(&self, mask: &photocraft_doc::VectorMask, rect: Rect) -> Vec<f32> {
|
||||
// Addresses identify immutable mask instances only for this render's lifetime.
|
||||
let key = std::ptr::from_ref(mask) as usize;
|
||||
let slot = {
|
||||
let mut masks = self.masks.lock().unwrap_or_else(|e| e.into_inner());
|
||||
masks.entry(key).or_default().clone()
|
||||
match masks.iter().find(|(m, _)| m == mask) {
|
||||
Some((_, slot)) => slot.clone(),
|
||||
None => {
|
||||
let slot = VectorMaskSlot::default();
|
||||
masks.push((mask.clone(), slot.clone()));
|
||||
slot
|
||||
}
|
||||
}
|
||||
};
|
||||
// Compilation is sequential. Rendering can enter Rayon and must happen after
|
||||
// initialization, so waiting tiles cannot re-enter a slot still being built.
|
||||
@@ -1103,7 +1121,7 @@ fn blend_if_weight(layer: &Layer, mode: photocraft_color::ColorMode, this: Optio
|
||||
let mut k = 1.0;
|
||||
for (side, px) in [this, under].into_iter().enumerate() {
|
||||
let Some(p) = px else { continue };
|
||||
let v = |c: usize| p[c].clamp(0.0, 1.0) * 255.0;
|
||||
let v = |c: usize| blend_if_level(p[c]);
|
||||
match mode {
|
||||
// Gray is the colour channels' luma (Rec. 601 weights), then R, G, B.
|
||||
M::Rgb => {
|
||||
@@ -1124,6 +1142,17 @@ fn blend_if_weight(layer: &Layer, mode: photocraft_color::ColorMode, this: Optio
|
||||
k
|
||||
}
|
||||
|
||||
/// A channel value on Blend If's 0..=255 scale. Compositing can leave an 8- or 16-bit level a few
|
||||
/// millionths off (a level-20 backdrop composited at partial alpha reads 19.999998), which would
|
||||
/// put it on the wrong side of a hard point at that level: a value that close to a level is that
|
||||
/// level. Values of those depths that aren't on a level are at least 1/257 away from one. The GPU
|
||||
/// compositor (`compose.wgsl` `blend_if_weight`) snaps alike.
|
||||
fn blend_if_level(x: f32) -> f32 {
|
||||
let v = x.clamp(0.0, 1.0) * 255.0;
|
||||
let r = v.round();
|
||||
if (v - r).abs() < 1e-4 { r } else { v }
|
||||
}
|
||||
|
||||
/// Apply `layer`'s Blend If to a finished composite: `out` (the backdrop with the layer drawn)
|
||||
/// is mixed back towards `before` (the backdrop without it) where the ranges hide the layer.
|
||||
/// Mixing premultiplied colour by `k` equals compositing the layer at `k` × its alpha, since
|
||||
@@ -1387,7 +1416,8 @@ fn split_parts(layer: &Layer, rect: Rect, cx: &Ctx) -> Option<(Buffer, Buffer, O
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Layer-effect map cache
|
||||
|
||||
/// Order-independent identity of a layer's pixels, masks, effects and (for groups) children.
|
||||
/// Order-independent identity of a layer's pixels, masks, effects, Advanced Blending and Blend If
|
||||
/// and (for groups) children.
|
||||
/// Pixels are identified by their copy-on-write tile pointers; cache entries pin a clone of the
|
||||
/// layer so those tiles (and their addresses) stay alive while the entry exists.
|
||||
fn layer_identity(layer: &Layer, h: &mut std::collections::hash_map::DefaultHasher) {
|
||||
@@ -1445,6 +1475,10 @@ fn layer_identity(layer: &Layer, h: &mut std::collections::hash_map::DefaultHash
|
||||
format!("{vm:?}").hash(h);
|
||||
}
|
||||
format!("{:?}", layer.effects).hash(h);
|
||||
// Transparency Shapes Layer picks the shape (content or the whole layer); a group child's
|
||||
// Blend If and knockout change the composite a styled group's shape comes from.
|
||||
layer.advanced.hash(h);
|
||||
layer.blend_if.hash(h);
|
||||
h.write_u8(0xfe);
|
||||
}
|
||||
|
||||
@@ -1527,7 +1561,8 @@ fn effect_region(layer: &Layer, rect: Rect, cx: &Ctx) -> (Rect, bool) {
|
||||
fn effect_maps(layer: &Layer, rect: Rect, cx: &Ctx) -> std::sync::Arc<effects::FxMaps> {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let (region, clipped) = effect_region(layer, rect, cx);
|
||||
if std::env::var_os("PHOTOCRAFT_FX_NOCACHE").is_some() {
|
||||
static NO_CACHE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
if *NO_CACHE.get_or_init(|| std::env::var_os("PHOTOCRAFT_FX_NOCACHE").is_some()) {
|
||||
let shape = if region.is_empty() { Vec::new() } else { effect_shape(layer, region, cx) };
|
||||
return std::sync::Arc::new(effects::build_maps_prepared(layer, shape, region, &cx.light, &texture_ctx(layer, region, cx), cx.patterns));
|
||||
}
|
||||
|
||||
@@ -344,7 +344,15 @@ fn build_mask(layer: &Layer, canvas: Rect) -> Surface {
|
||||
// mask's painted tiles when the vector mask lets them through outside the path, each grown
|
||||
// by its feather.
|
||||
let mut area = match vm.and_then(|vm| vm.path.control_bounds()) {
|
||||
Some((x0, y0, x1, y1)) => grow(Rect::new(x0.floor() as i32 - 2, y0.floor() as i32 - 2, x1.ceil() as i32 + 2, y1.ceil() as i32 + 2), sv),
|
||||
Some((x0, y0, x1, y1)) => grow(
|
||||
Rect::new(
|
||||
(x0.floor() as i32).saturating_sub(2),
|
||||
(y0.floor() as i32).saturating_sub(2),
|
||||
(x1.ceil() as i32).saturating_add(2),
|
||||
(y1.ceil() as i32).saturating_add(2),
|
||||
),
|
||||
sv,
|
||||
),
|
||||
None => Rect::EMPTY,
|
||||
};
|
||||
if let Some(m) = pixel
|
||||
@@ -761,4 +769,14 @@ mod tests {
|
||||
l.vector_mask = None;
|
||||
assert!(combined_mask(&l, canvas).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_path_coordinates_do_not_overflow() {
|
||||
let mut l = Layer::raster("l", PixelFormat::RGBA8);
|
||||
let knots = [(-1.0e30, -1.0e30), (1.0e30, 0.0), (0.0, 1.0e30)].iter().map(|&(x, y)| Knot::corner(x, y)).collect();
|
||||
let mut path = Path::default();
|
||||
path.subpaths.push(Subpath { knots, closed: true, ..Default::default() });
|
||||
l.vector_mask = Some(VectorMask::new(path));
|
||||
let _ = combined_mask(&l, Rect::new(0, 0, 8, 8));
|
||||
}
|
||||
}
|
||||
@@ -159,7 +159,7 @@ impl Tile {
|
||||
if wx == 0.0 {
|
||||
continue;
|
||||
}
|
||||
let p = self.at(x0 + dx, y0 + dy);
|
||||
let p = self.at(x0.saturating_add(dx), y0.saturating_add(dy));
|
||||
let k = wx * wy;
|
||||
for c in 0..4 {
|
||||
acc[c] += p[c] * k;
|
||||
@@ -287,4 +287,14 @@ mod tests {
|
||||
let (u, v) = rot.map(1.0, 3.0);
|
||||
assert!((u - -3.0).abs() < 1e-9 && (v - 1.0).abs() < 1e-9, "{u},{v}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sampling_at_hostile_phases_does_not_overflow() {
|
||||
let patterns = [checker()];
|
||||
let prepared = PreparedPatterns::new(&patterns, PREPARED_PATTERN_BYTES);
|
||||
let t = prepared.get(&patterns[0].id, "").unwrap();
|
||||
for u in [1.0e30, -1.0e30, f64::INFINITY, f64::NAN] {
|
||||
let _ = t.sample(u, u);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,12 +20,15 @@ pub fn downsample(s: &Surface, k: u32) -> Surface {
|
||||
let bpp = s.format().bytes_per_pixel();
|
||||
let (x0, x1) = (b.x0.div_euclid(k), b.x1.div_euclid(k) + i32::from(b.x1.rem_euclid(k) != 0));
|
||||
let (y0, y1) = (b.y0.div_euclid(k), b.y1.div_euclid(k) + i32::from(b.y1.rem_euclid(k) != 0));
|
||||
let w = (x1 - x0).max(0) as usize;
|
||||
let w = x1.saturating_sub(x0).max(0) as usize;
|
||||
let mut row = vec![0u8; w * bpp];
|
||||
for oy in y0..y1 {
|
||||
let src = s.to_interleaved(Rect::new(x0 * k, oy * k, x1 * k, oy * k + 1));
|
||||
let src = s.to_interleaved(Rect::new(x0.saturating_mul(k), oy.saturating_mul(k), x1.saturating_mul(k), oy.saturating_mul(k).saturating_add(1)));
|
||||
for (i, px) in row.chunks_exact_mut(bpp).enumerate() {
|
||||
px.copy_from_slice(&src[i * k as usize * bpp..(i * k as usize + 1) * bpp]);
|
||||
// Saturated edges (a surface at the i32 limits) can make `src` shorter than expected.
|
||||
if let Some(from) = (i * k as usize * bpp).checked_add(bpp).and_then(|end| src.get(i * k as usize * bpp..end)) {
|
||||
px.copy_from_slice(from);
|
||||
}
|
||||
}
|
||||
out.write_interleaved(Rect::new(x0, oy, x1, oy + 1), &row);
|
||||
}
|
||||
|
||||
@@ -592,6 +592,20 @@ fn render_is_tile_independent() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A Lab Invert layer in a 16-bit document keeps a grey neutral (the compositor passes the
|
||||
/// document's depth to `adjust::apply_in`).
|
||||
#[test]
|
||||
fn a_16_bit_lab_invert_layer_keeps_greys_neutral() {
|
||||
let mut d = Document::new("lab", Size::new(2, 2), ColorMode::Lab, SampleType::U16);
|
||||
let mut bg = Layer::raster("bg", d.pixel_format());
|
||||
bg.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 2, 2), &[0.25, 128.0 / 255.0, 128.0 / 255.0, 1.0]);
|
||||
d.layers.push(bg);
|
||||
d.layers.push(Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert)));
|
||||
let p = px(&d, 1, 1);
|
||||
let lab = photocraft_color::convert::srgb_to_lab([p[0], p[1], p[2]]);
|
||||
assert!((lab[0] - 75.0).abs() < 0.1 && lab[1].abs() < 0.05 && lab[2].abs() < 0.05, "{lab:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmyk_document_renders_via_rgb() {
|
||||
let mut d = Document::new("c", Size::new(2, 2), ColorMode::Cmyk, SampleType::U8);
|
||||
@@ -1499,6 +1513,22 @@ fn blend_if_underlying_layer_hides_by_the_backdrop_value() {
|
||||
assert!(close4(px(&d, 6, 1), [1.0, 1.0, 1.0, 1.0]));
|
||||
}
|
||||
|
||||
/// An 8-bit backdrop at level `k` counts as `k` for a hard Underlying black point at `k`, also
|
||||
/// over a semi-transparent backdrop, where compositing leaves the value a few millionths off the
|
||||
/// level (19.999998 for 20 before #4395); the GPU compositor agrees.
|
||||
#[test]
|
||||
fn blend_if_hard_point_includes_its_own_level() {
|
||||
for k in 0..=255u8 {
|
||||
let v = f32::from(k) / 255.0;
|
||||
let mut d = Document::new("t", Size::new(2, 1), ColorMode::Rgb, SampleType::U8);
|
||||
d.layers.push(solid_layer("under", Rect::new(0, 0, 2, 1), [v, v, v, 184.0 / 255.0]));
|
||||
let mut l = solid_layer("top", Rect::new(0, 0, 2, 1), [1.0, 0.0, 0.0, 1.0]);
|
||||
l.blend_if.set(1, [FULL, range([k, k], [255, 255])]);
|
||||
d.layers.push(l);
|
||||
assert!(close4(px(&d, 0, 0), [1.0, 0.0, 0.0, 1.0]), "level {k} is hidden by a black point at {k}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blend_if_split_point_fades_like_opacity() {
|
||||
// A split black point (Alt-drag) at 50/150 shows a value-100 layer at half strength, which
|
||||
@@ -2388,7 +2418,9 @@ fn lab_documents_evaluate_invert_threshold_and_gradient_map_on_lab() {
|
||||
};
|
||||
let near = |got: [f32; 3], want: [f32; 3], tol: f32| got.iter().zip(want).all(|(g, w)| (g - w).abs() <= tol);
|
||||
let inv = render(Adjustment::Invert);
|
||||
assert!(near(inv, [70.0, -13.0, 17.0], 1.0), "{depth:?}: {inv:?}");
|
||||
// 8-bit bytes become 255 - v (a, b one step past the mirror); 16 bits mirror them exactly.
|
||||
let want = if depth == SampleType::U8 { [70.0, -13.0, 17.0] } else { [70.0, -12.0, 18.0] };
|
||||
assert!(near(inv, want, 0.5), "{depth:?}: {inv:?}");
|
||||
let th = render(Adjustment::Threshold { level: 0.25 });
|
||||
assert!(near(th, [100.0, 0.0, 0.0], 0.5), "{depth:?}: L 30 is above level 64: {th:?}");
|
||||
let gm = render(Adjustment::GradientMap { stops: vec![(0.0, [0.0; 3]), (1.0, [1.0; 3])], reverse: false, dither: false });
|
||||
@@ -2416,10 +2448,10 @@ const MUTED: [f32; 3] = [0.40, 0.42, 0.60];
|
||||
fn lab_documents_invert_and_posterize_l_a_and_b() {
|
||||
let lab = photocraft_color::convert::srgb_to_lab(MUTED);
|
||||
for depth in SampleType::ALL {
|
||||
// Invert: every normalised channel n -> 1 - n, so L -> 100 - L and a, b -> their opposites
|
||||
// (-a - 1 on the 0..255 grid).
|
||||
// Invert: L -> 100 - L and a, b -> their opposites (-a - 1 on the 8-bit 0..255 grid).
|
||||
let inv = lab_doc_adjusted(MUTED, Adjustment::Invert, depth);
|
||||
near3(inv, [100.0 - lab[0], -lab[1] - 1.0, -lab[2] - 1.0], 1.0, &format!("{depth:?} invert"));
|
||||
let step = if depth == SampleType::U8 { 1.0 } else { 0.0 };
|
||||
near3(inv, [100.0 - lab[0], -lab[1] - step, -lab[2] - step], 1.0, &format!("{depth:?} invert"));
|
||||
// An RGB invert would land elsewhere: lightness 100 - L is the Lab rule.
|
||||
let rgb_inv = photocraft_color::convert::srgb_to_lab(MUTED.map(|v| 1.0 - v));
|
||||
assert!((rgb_inv[0] - inv[0]).abs() > 2.0, "{depth:?}: {rgb_inv:?} vs {inv:?}");
|
||||
@@ -2487,3 +2519,54 @@ fn lab_documents_gradient_map_follows_lightness() {
|
||||
adjust::apply_in(&adj, &mut grey_buf, adjust::Transfer::Srgb, None, ColorMode::Rgb);
|
||||
assert!((buf.px[0][0] - grey_buf.px[0][0]).abs() > 0.05, "{:?} {:?}", buf.px[0], grey_buf.px[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_refuses_rects_beyond_the_pixel_cap() {
|
||||
let doc = Document::new("c", Size::new(4, 4), ColorMode::Rgb, SampleType::U8);
|
||||
// 2^32 - 1 px wide: the width wraps negative as an i32, which once took the single-tile path.
|
||||
for tile in [crate::RENDER_TILE, 1 << 20] {
|
||||
let b = crate::render_tiled(&doc, Rect::new(i32::MIN, 0, i32::MAX, 1), tile);
|
||||
assert!(b.px.is_empty() && b.rect.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
// The effect-map cache key covers Transparency Shapes Layer and Blend If: both change the shape
|
||||
// the maps are built from (a layer's own, or a styled group's children composite) without
|
||||
// touching pixels, masks or effects, and stale maps kept stroking a hole the layer no longer
|
||||
// had. Each case renders with the cache warm, edits, renders again and compares with a cold
|
||||
// render. (A purge by a concurrent test only makes the warm render cold, never a false failure.)
|
||||
#[test]
|
||||
fn effect_cache_follows_transparency_shapes_layer() {
|
||||
let mut d = doc_white(32, 32);
|
||||
let mut l = solid_layer("holed", Rect::new(0, 0, 32, 32), [0.9, 0.2, 0.2, 1.0]);
|
||||
l.surface_mut().unwrap().fill_rect(Rect::new(12, 12, 20, 20), &[0.0, 0.0, 0.0, 0.0]);
|
||||
l.effects.items = vec![stroke(2.0, StrokePosition::Outside)];
|
||||
assert!(l.advanced.transparency_shapes);
|
||||
d.layers.push(l);
|
||||
let on = render(&d, d.bounds());
|
||||
d.layers[1].advanced.transparency_shapes = false;
|
||||
let warm = render(&d, d.bounds());
|
||||
purge_effect_cache();
|
||||
let cold = render(&d, d.bounds());
|
||||
assert!(on.px != cold.px, "the stroke should stop rimming the hole");
|
||||
assert!(warm.px == cold.px, "stale effect maps after turning Transparency Shapes Layer off");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effect_cache_follows_a_group_childs_blend_if() {
|
||||
let mut d = doc_white(32, 32);
|
||||
let mut child = solid_layer("bw", Rect::new(4, 4, 16, 28), [0.0, 0.0, 0.0, 1.0]);
|
||||
child.surface_mut().unwrap().fill_rect(Rect::new(16, 4, 28, 28), &[1.0, 1.0, 1.0, 1.0]);
|
||||
let mut g = Layer::group("g", vec![child]);
|
||||
g.effects.items = vec![Effect::DropShadow(Shadow { size: 2.0, spread: 0.0, ..shadow(3.0, 90.0) })];
|
||||
d.layers.push(g);
|
||||
let before = render(&d, d.bounds());
|
||||
let LayerContent::Group(grp) = &mut d.layers[1].content else { panic!("not a group") };
|
||||
// Gray › This Layer: black point at 128 hides the black half.
|
||||
grp.children[0].blend_if.set(0, [range([128, 128], [255, 255]), FULL]);
|
||||
let warm = render(&d, d.bounds());
|
||||
purge_effect_cache();
|
||||
let cold = render(&d, d.bounds());
|
||||
assert!(before.px != cold.px, "Blend If should change the group's shadow");
|
||||
assert!(warm.px == cold.px, "stale group effect maps after a child's Blend If changed");
|
||||
}
|
||||
@@ -99,6 +99,11 @@ pub struct Locks {
|
||||
}
|
||||
|
||||
impl Locks {
|
||||
/// Whether any lock is set: Photoshop then shows the lock icon on the layer's row.
|
||||
pub fn any(self) -> bool {
|
||||
self.transparency || self.pixels || self.position || self.artboard || self.all
|
||||
}
|
||||
|
||||
pub fn union(self, o: Locks) -> Locks {
|
||||
Locks {
|
||||
transparency: self.transparency || o.transparency,
|
||||
@@ -1042,6 +1047,11 @@ impl Document {
|
||||
(1..=path.len()).filter_map(|n| self.layer_at(path.get(..n)?)).fold(Locks::default(), |a, l| a.union(l.locks))
|
||||
}
|
||||
|
||||
/// Whether the layer at `path` is shown: it and every group around it are visible.
|
||||
pub fn shown_at(&self, path: &[usize]) -> bool {
|
||||
(1..=path.len()).all(|n| path.get(..n).and_then(|p| self.layer_at(p)).is_some_and(|l| l.visible))
|
||||
}
|
||||
|
||||
/// [`Self::locks_at`] for the layer `id`.
|
||||
pub fn effective_locks(&self, id: LayerId) -> Locks {
|
||||
self.path_of(id).map_or_else(Locks::default, |p| self.locks_at(&p))
|
||||
@@ -1195,6 +1205,19 @@ mod tests {
|
||||
Document::with_background("t", Size::new(100, 50), ColorMode::Rgb, SampleType::U8, Color::WHITE)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn any_lock_counts_every_lock() {
|
||||
assert!(!Locks::default().any());
|
||||
let each = [
|
||||
Locks { transparency: true, ..Locks::default() },
|
||||
Locks { pixels: true, ..Locks::default() },
|
||||
Locks { position: true, ..Locks::default() },
|
||||
Locks { artboard: true, ..Locks::default() },
|
||||
Locks { all: true, ..Locks::default() },
|
||||
];
|
||||
assert!(each.iter().all(|k| k.any()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unused_link_group_never_wraps() {
|
||||
let mut d = doc();
|
||||
|
||||
@@ -209,13 +209,18 @@ pub struct ParagraphRun {
|
||||
}
|
||||
|
||||
/// Point text grows from its anchor (text-space origin = first baseline at the anchor);
|
||||
/// paragraph (box) text wraps inside a rectangle.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
/// paragraph (box) text wraps inside a rectangle; path text runs along a curve.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub enum TextShape {
|
||||
#[default]
|
||||
Point,
|
||||
/// Text-space pixels, before the layer transform.
|
||||
Box { x: f32, y: f32, width: f32, height: f32 },
|
||||
/// Type on a path: one line laid out like point text, then each glyph is placed on `path`
|
||||
/// (text-space pixels, before the layer transform) by arc length and turned to follow the
|
||||
/// curve. `start` is the arc-length offset (px) of the text origin along the path; `flip`
|
||||
/// puts the text on the other side of the path.
|
||||
Path { path: super::vector::Subpath, start: f32, flip: bool },
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
//! Compare generated-palette and edited fixed-palette GIF exports on a 24 MP image.
|
||||
//! cargo run --release -p photocraft-engine --example bench_web_palette
|
||||
use std::time::Instant;
|
||||
|
||||
use photocraft_engine::web_cmds::{WebSettings, optimize};
|
||||
use photocraft_geom::Rect;
|
||||
use serde_json::json;
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let (w, h) = (6000usize, 4000usize);
|
||||
let mut pixels = Vec::new();
|
||||
pixels.try_reserve_exact(w * h)?;
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
pixels.push([x as f32 / w as f32, 0.5 + ((x + y) as f32 * 0.002).sin() * 0.3, y as f32 / h as f32, 1.0]);
|
||||
}
|
||||
}
|
||||
let settings = WebSettings::from_params(&json!({"format": "gif", "colors": 128, "dither": "diffusion"}), "bench")?;
|
||||
let rect = Rect::new(0, 0, w as i32, h as i32);
|
||||
let started = Instant::now();
|
||||
let original = optimize(&pixels, w, rect, &settings, None, None, 72.0, false)?;
|
||||
println!("24 MP generated palette: {:.1} ms", started.elapsed().as_secs_f64() * 1000.0);
|
||||
let mut edited = settings;
|
||||
edited.color_table = Some(original.color_table);
|
||||
edited.color_table_edits.insert(0, [0, 255, 0]);
|
||||
let started = Instant::now();
|
||||
let result = optimize(&pixels, w, rect, &edited, None, None, 72.0, false)?;
|
||||
println!("24 MP edited fixed palette: {:.1} ms ({} bytes)", started.elapsed().as_secs_f64() * 1000.0, result.bytes.len());
|
||||
Ok(())
|
||||
}
|
||||
@@ -115,10 +115,11 @@ pub fn replayable(id: &str) -> bool {
|
||||
&& (shell_view_command(id) || shell_save_command(id) || crate::commands::find(id).is_some_and(|c| c.journal)))
|
||||
}
|
||||
|
||||
/// File › Save and Save As, recorded by the desktop shell once the save has a path (#2032).
|
||||
/// The shell replays them; batch and droplets write to their destination folder instead.
|
||||
/// File › Save, Save As and Save a Copy, recorded by the desktop shell once the save has a path
|
||||
/// (#2032, #4373). The shell replays them; batch and droplets write to their destination folder
|
||||
/// instead.
|
||||
pub fn shell_save_command(id: &str) -> bool {
|
||||
matches!(id, "file.save" | "file.saveAs")
|
||||
matches!(id, "file.save" | "file.saveAs" | "file.saveACopy")
|
||||
}
|
||||
|
||||
/// View-only menu commands recorded by the desktop shell. Headless playback reports
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
use photocraft_algo::tone::{self, HdrToning, MatchColor, ShadowsHighlights};
|
||||
use photocraft_doc::{Adjustment, Document, Layer, LayerContent, Rect};
|
||||
use photocraft_raster::{Surface, from_rgba_into, to_rgba};
|
||||
use photocraft_raster::{Surface, to_rgba};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::commands::CommandSpec;
|
||||
@@ -202,17 +202,8 @@ fn process_surface(surf: &mut Surface, selection: Option<&Surface>, f: &mut dyn
|
||||
let raw = surf.read_region(r);
|
||||
let mut px: Vec<[f32; 4]> = raw.chunks_exact(n).map(|q| to_rgba(&fmt, q)).collect();
|
||||
let orig = px.clone();
|
||||
let w = r.width() as usize;
|
||||
f(&mut px, r);
|
||||
let mut out = Vec::with_capacity(raw.len());
|
||||
for (i, (a, o)) in px.iter().zip(&orig).enumerate() {
|
||||
let k = selection.map_or(1.0, |sel| sel.sample_channel(r.x0 + (i % w) as i32, r.y0 + (i / w) as i32, 0));
|
||||
let mixed: [f32; 4] = std::array::from_fn(|c| o[c] + (a[c] - o[c]) * k);
|
||||
let mut enc = [0.0f32; 8];
|
||||
let m = from_rgba_into(&fmt, mixed, &mut enc);
|
||||
out.extend_from_slice(&enc[..m]);
|
||||
}
|
||||
surf.write_region(r, &out);
|
||||
surf.write_region(r, &crate::pixels::mix_through_selection(&fmt, r, &orig, &px, selection));
|
||||
}
|
||||
|
||||
/// One history step running `f` on the active pixel layer (or targeted channel or layer mask).
|
||||
@@ -463,7 +454,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"image.adjustments.replaceColor",
|
||||
"Replace Color…",
|
||||
["Image", "Adjustments"],
|
||||
r##"{"color":json,"fuzziness":0..200=40,"hue":-180..180=0,"saturation":-100..100=0,"lightness":-100..100=0} (color: "#rrggbb", default the foreground colour)"##,
|
||||
r##"{"color":color,"fuzziness":0..200=40,"hue":-180..180=0,"saturation":-100..100=0,"lightness":-100..100=0} (color: "#rrggbb", default the foreground colour)"##,
|
||||
has_pixels,
|
||||
replace_color
|
||||
),
|
||||
|
||||
@@ -48,7 +48,7 @@ pub(crate) fn read_region(surface: &photocraft_raster::Surface, area: photocraft
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn error(purpose: &str, detail: &str) -> EngineError {
|
||||
pub(crate) fn error(purpose: &str, detail: &str) -> EngineError {
|
||||
EngineError::Other(format!("not enough memory for {purpose} ({detail}); the document was not changed"))
|
||||
}
|
||||
|
||||
|
||||
@@ -842,6 +842,23 @@ fn count_delete_group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(count_json(doc(s)?.as_ref()))
|
||||
}
|
||||
|
||||
/// A size parameter (`markerSize`, `labelSize`) clamped to `lo..=hi`: `None` when absent or null.
|
||||
/// Whole numbers sent as JSON floats (`8.0`, as UIs and recorded actions do) count; fractions,
|
||||
/// negatives and non-numbers are bad params rather than silently ignored. Clamping happens in
|
||||
/// `u64`/`f64`, so a huge value can't wrap through `as u32`.
|
||||
fn whole_size(cmd: &str, p: &Value, key: &str, lo: u32, hi: u32) -> Result<Option<u32>> {
|
||||
let v = match p.get(key) {
|
||||
None | Some(Value::Null) => return Ok(None),
|
||||
Some(v) => v,
|
||||
};
|
||||
let n = match (v.as_u64(), v.as_f64()) {
|
||||
(Some(n), _) => n.clamp(u64::from(lo), u64::from(hi)),
|
||||
(None, Some(f)) if f.is_finite() && f >= 0.0 && f.fract() == 0.0 => f.clamp(f64::from(lo), f64::from(hi)) as u64,
|
||||
_ => return Err(bad(cmd, format!("`{key}` = {v} must be a whole number ({lo}..={hi})"))),
|
||||
};
|
||||
Ok(Some(u32::try_from(n).unwrap_or(hi)))
|
||||
}
|
||||
|
||||
fn count_set_group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "count.setGroup";
|
||||
let d = doc(s)?;
|
||||
@@ -853,11 +870,11 @@ fn count_set_group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(c) = color_of(p, "color") {
|
||||
grp.color = c;
|
||||
}
|
||||
if let Some(v) = p.get("markerSize").and_then(Value::as_u64) {
|
||||
grp.marker_size = (v as u32).clamp(1, 10);
|
||||
if let Some(v) = whole_size(CMD, p, "markerSize", 1, 10)? {
|
||||
grp.marker_size = v;
|
||||
}
|
||||
if let Some(v) = p.get("labelSize").and_then(Value::as_u64) {
|
||||
grp.label_size = (v as u32).clamp(8, 72);
|
||||
if let Some(v) = whole_size(CMD, p, "labelSize", 8, 72)? {
|
||||
grp.label_size = v;
|
||||
}
|
||||
if let Some(v) = p.get("visible").and_then(Value::as_bool) {
|
||||
grp.visible = v;
|
||||
|
||||
@@ -349,3 +349,40 @@ fn now_iso_shape() {
|
||||
assert_eq!(t.len(), 20, "{t}");
|
||||
assert!(t.ends_with('Z') && &t[4..5] == "-");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn count_set_group_takes_sizes_sent_as_whole_floats() {
|
||||
// The options bar sends `{"markerSize": 8.0, "labelSize": 20.0}`; `as_u64` read neither, so
|
||||
// the fields did nothing.
|
||||
let mut s = session(100, 100, 8);
|
||||
s.execute("count.add", json!({"x": 10, "y": 10})).unwrap();
|
||||
s.execute("count.setGroup", json!({"markerSize": 8.0, "labelSize": 20.0})).unwrap();
|
||||
let g = &d(&s).measurement.count_groups[0];
|
||||
assert_eq!((g.marker_size, g.label_size), (8, 20));
|
||||
// Out-of-range whole numbers clamp, also huge ones that `as u32` would have wrapped.
|
||||
s.execute("count.setGroup", json!({"markerSize": 4_294_967_297u64, "labelSize": 1e300})).unwrap();
|
||||
let g = &d(&s).measurement.count_groups[0];
|
||||
assert_eq!((g.marker_size, g.label_size), (10, 72));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn count_set_group_rejects_sizes_that_are_not_whole_numbers() {
|
||||
let mut s = session(100, 100, 8);
|
||||
s.execute("count.add", json!({"x": 10, "y": 10})).unwrap();
|
||||
s.execute("count.setGroup", json!({"markerSize": 3, "labelSize": 12})).unwrap();
|
||||
let past = s.active().unwrap().history.past_len();
|
||||
for p in [
|
||||
json!({"markerSize": 8.5}),
|
||||
json!({"labelSize": 12.25}),
|
||||
json!({"markerSize": -2}),
|
||||
json!({"labelSize": -9.0}),
|
||||
json!({"markerSize": "8"}),
|
||||
json!({"labelSize": true}),
|
||||
] {
|
||||
let err = s.execute("count.setGroup", p.clone()).unwrap_err();
|
||||
assert!(matches!(err, EngineError::BadParams { .. }), "{p}: {err}");
|
||||
}
|
||||
let g = &d(&s).measurement.count_groups[0];
|
||||
assert_eq!((g.marker_size, g.label_size), (3, 12), "a rejected call changes nothing");
|
||||
assert_eq!(s.active().unwrap().history.past_len(), past);
|
||||
}
|
||||
+266
-13
@@ -167,6 +167,8 @@ fn class(c: &str) -> Class {
|
||||
/// The pixel and percent units of a `UntF` value.
|
||||
const PX: [u8; 4] = *b"#Pxl";
|
||||
const PCT: [u8; 4] = *b"#Prc";
|
||||
/// Points (1/72 in): what Photoshop calls "relative" units.
|
||||
const PT: [u8; 4] = *b"#Rlt";
|
||||
|
||||
fn px(v: f64) -> D {
|
||||
D::UnitFloat { unit: PX, value: v }
|
||||
@@ -937,6 +939,49 @@ fn adjustment_steps(a: &Adjustment, prefix: &str, type_desc: Option<&Descriptor>
|
||||
step(&format!("{prefix}{}", a.suffix), params)
|
||||
}
|
||||
|
||||
/// A `patternLayer` descriptor's pattern, scale, angle and link as the params of
|
||||
/// `layer.newFillLayer.pattern` / `layer.fill.pattern.set`. The pattern goes by name: ids differ
|
||||
/// between Photoshop's library and one rebuilt from the same `.pat` file. None for a pattern
|
||||
/// recorded without a name or id.
|
||||
fn pattern_params(t: &Descriptor) -> Option<Map<String, Value>> {
|
||||
let mut p = Map::new();
|
||||
if let Some(ptrn) = get_obj(t, "Ptrn") {
|
||||
let key = get_text(ptrn, "Nm ").filter(|n| !n.is_empty()).or_else(|| get_text(ptrn, "Idnt").filter(|i| !i.is_empty()))?;
|
||||
p.insert("pattern".into(), key.into());
|
||||
}
|
||||
if let Some(s) = get_num(t, "Scl ") {
|
||||
p.insert("scale".into(), s.into());
|
||||
}
|
||||
if let Some(a) = get_num(t, "Angl") {
|
||||
p.insert("angle".into(), a.into());
|
||||
}
|
||||
if let Some(l) = get_bool(t, "Algn") {
|
||||
p.insert("link".into(), l.into());
|
||||
}
|
||||
Some(p)
|
||||
}
|
||||
|
||||
/// The `patternLayer` descriptor for [`pattern_params`]' params.
|
||||
fn pattern_desc(p: &Value) -> Option<Descriptor> {
|
||||
let mut t = desc("patternLayer");
|
||||
if let Some(n) = jstr(p, "pattern") {
|
||||
t = t.with("Ptrn", D::Descriptor(desc("Ptrn").with("Nm ", text(n))));
|
||||
}
|
||||
if let Some(s) = jnum(p, "scale") {
|
||||
t = t.with("Scl ", pct(s));
|
||||
}
|
||||
if let Some(a) = jnum(p, "angle") {
|
||||
t = t.with("Angl", ang(a));
|
||||
}
|
||||
if let Some(l) = jbool(p, "link") {
|
||||
t = t.with("Algn", D::Boolean(l));
|
||||
}
|
||||
if p.get("phase").is_some() {
|
||||
return None;
|
||||
}
|
||||
Some(t)
|
||||
}
|
||||
|
||||
fn make_from(d: &Descriptor) -> Mapped {
|
||||
if let Some(nw) = get_obj(d, "Nw ") {
|
||||
// Layer via Copy / Cut (`Usng` = the selection), or a new document.
|
||||
@@ -1010,6 +1055,10 @@ fn make_from(d: &Descriptor) -> Mapped {
|
||||
Some(c) => step("layer.newFillLayer.solidColor", json!({"color": c})),
|
||||
None => none("layer.newFillLayer.solidColor"),
|
||||
},
|
||||
Some(t) if t.class_id.is("patternLayer") => match pattern_params(t) {
|
||||
Some(p) => step("layer.newFillLayer.pattern", Value::Object(p)),
|
||||
None => Mapped::Unsupported { harmless: false },
|
||||
},
|
||||
_ => Mapped::Unsupported { harmless: false },
|
||||
},
|
||||
Some("layerSection") => {
|
||||
@@ -1061,6 +1110,13 @@ fn make_to(id: &str, p: &Value) -> Option<Descriptor> {
|
||||
}
|
||||
desc("null").with("null", class_ref("contentLayer")).with("Usng", D::Descriptor(desc("contentLayer").with("Type", D::Descriptor(t))))
|
||||
}
|
||||
"layer.newFillLayer.pattern" => {
|
||||
if p.get("path").is_some() {
|
||||
return None;
|
||||
}
|
||||
let t = pattern_desc(p)?;
|
||||
desc("null").with("null", class_ref("contentLayer")).with("Usng", D::Descriptor(desc("contentLayer").with("Type", D::Descriptor(t))))
|
||||
}
|
||||
"file.newFromClipboard" => desc("null").with("Nw ", D::Descriptor(desc("Dcmn").with("preset", text("Clipboard")))),
|
||||
"file.new" => {
|
||||
let mut n = desc("Dcmn");
|
||||
@@ -1106,6 +1162,8 @@ fn set_from(d: &Descriptor) -> Mapped {
|
||||
},
|
||||
Some(D::Reference(t)) => match (ref_class(t).as_deref(), target(t)) {
|
||||
(Some("Chnl"), Target::Ordinal(o)) if o == "Trsp" => step("select.loadSelection", json!({"channel": "transparency"})),
|
||||
// ⌘-click on the targeted channel (a layer mask after `select` of `Msk`).
|
||||
(Some("Chnl"), Target::Ordinal(o)) if o == "Trgt" => step("select.loadSelection", json!({"channel": "targeted"})),
|
||||
(Some("Chnl"), Target::Name(n)) => step("select.loadSelection", json!({"channel": n})),
|
||||
_ => unsupported,
|
||||
},
|
||||
@@ -1138,6 +1196,37 @@ fn set_from(d: &Descriptor) -> Mapped {
|
||||
},
|
||||
// Quick Mask mode on (`set` of the document's quick-mask property); `clearEvent` turns it off.
|
||||
(Some("Prpr"), Target::Property(p)) if p == "QucM" => step("select.editInQuickMaskMode", json!({"on": true})),
|
||||
// Layer › New › Layer From Background (Photoshop records the New Layer dialog's values
|
||||
// as a `set` of the Background); values other than the defaults then apply to the layer.
|
||||
(Some("Lyr"), Target::Property(p)) if p == "Bckg" => {
|
||||
// On an image that opened as a normal layer there is no Background to convert;
|
||||
// Photoshop stops there, but the step's purpose (an editable layer) is met.
|
||||
// Photoshop's `set` of another layer doesn't target it: the selected layer stays.
|
||||
let mut v = vec![("layer.new.layerFromBackground", json!({"ifPresent": true, "select": false}))];
|
||||
let mut props = Map::new();
|
||||
props.insert("layer".into(), "Layer 0".into());
|
||||
if let Some(t) = get_obj(d, "T ") {
|
||||
if let Some(n) = get_text(t, "Nm ") {
|
||||
props.insert("name".into(), n.into());
|
||||
}
|
||||
if let Some(o) = get_num(t, "Opct").filter(|o| (o - 100.0).abs() > 1e-9) {
|
||||
props.insert("opacity".into(), (o / 100.0).into());
|
||||
}
|
||||
match get_enum(t, "Md ").as_deref() {
|
||||
None | Some("Nrml") => {}
|
||||
Some(m) => match blend_label(m) {
|
||||
Some(label) => {
|
||||
props.insert("blend".into(), label.into());
|
||||
}
|
||||
None => return unsupported,
|
||||
},
|
||||
}
|
||||
}
|
||||
if props.len() > 1 {
|
||||
v.push(("layer.setProps", Value::Object(props)));
|
||||
}
|
||||
steps(v)
|
||||
}
|
||||
(Some("Lyr"), _) => {
|
||||
let Some(mut p) = layer_target(r) else { return unsupported };
|
||||
let Some(t) = get_obj(d, "T ") else { return unsupported };
|
||||
@@ -1168,6 +1257,14 @@ fn set_from(d: &Descriptor) -> Mapped {
|
||||
}
|
||||
if v.is_empty() { unsupported } else { steps(v) }
|
||||
}
|
||||
// A Pattern Fill layer's pattern (double-click its thumbnail).
|
||||
(Some("contentLayer"), Target::Ordinal(o)) if o == "Trgt" => match get_obj(d, "T ") {
|
||||
Some(t) if t.class_id.is("patternLayer") => match pattern_params(t) {
|
||||
Some(p) if !p.is_empty() => step("layer.fill.pattern.set", Value::Object(p)),
|
||||
_ => unsupported,
|
||||
},
|
||||
_ => unsupported,
|
||||
},
|
||||
(Some("AdjL"), Target::Ordinal(o)) if o == "Trgt" => {
|
||||
match get_obj(d, "T ").and_then(|t| adjustment_by_class(&trimmed(&t.class_id)).map(|a| (a, t))) {
|
||||
Some((a, t)) => match (a.from)(t) {
|
||||
@@ -1190,6 +1287,7 @@ fn set_to(id: &str, p: &Value) -> Option<Descriptor> {
|
||||
"select.reselect" => sel(enm("Ordn", "Prvs")),
|
||||
"select.loadSelection" => match jstr(p, "channel")? {
|
||||
"transparency" => sel(D::Reference(vec![ReferenceItem::Enumerated { class: class("Chnl"), type_id: Id::new("Chnl"), value: Id::new("Trsp") }])),
|
||||
"targeted" => sel(D::Reference(vec![ReferenceItem::Enumerated { class: class("Chnl"), type_id: Id::new("Ordn"), value: Id::new("Trgt") }])),
|
||||
name => sel(name_ref("Chnl", name)),
|
||||
},
|
||||
"select.lasso" => {
|
||||
@@ -1240,6 +1338,16 @@ fn set_to(id: &str, p: &Value) -> Option<Descriptor> {
|
||||
}
|
||||
desc("null").with("null", layer_ref(p)).with("T ", D::Descriptor(t))
|
||||
}
|
||||
"layer.new.layerFromBackground" => {
|
||||
let bckg = D::Reference(vec![ReferenceItem::Property { class: class("Lyr "), key: Id::new("Bckg") }]);
|
||||
desc("null").with("null", bckg).with("T ", D::Descriptor(desc("Lyr ").with("Opct", pct(100.0)).with("Md ", enm("BlnM", "Nrml"))))
|
||||
}
|
||||
"layer.fill.pattern.set" => {
|
||||
if p.get("layer").is_some() || p.get("document").is_some() {
|
||||
return None;
|
||||
}
|
||||
desc("null").with("null", target_ref("contentLayer")).with("T ", D::Descriptor(pattern_desc(p)?))
|
||||
}
|
||||
"layer.setAdjustment" => {
|
||||
// The adjustment kind isn't in the params: Photoshop needs it, so only steps that
|
||||
// name one (`kind`) export.
|
||||
@@ -1260,11 +1368,24 @@ fn select_from(d: &Descriptor) -> Mapped {
|
||||
match (ref_class(r).as_deref(), target(r)) {
|
||||
(Some("Lyr"), Target::Name(n)) => {
|
||||
let mut p = json!({"layer": n});
|
||||
if get_enum(d, "selectionModifier").as_deref() == Some("addToSelection") {
|
||||
p["mode"] = json!("add");
|
||||
match get_enum(d, "selectionModifier").as_deref() {
|
||||
Some("addToSelection") => p["mode"] = json!("add"),
|
||||
Some("removeFromSelection") => p["mode"] = json!("remove"),
|
||||
_ => {}
|
||||
}
|
||||
step("layer.select", p)
|
||||
}
|
||||
// The Channels panel: the active layer's mask, or back to the composite (`RGB`, `CMYK`…).
|
||||
(Some("Chnl"), Target::Ordinal(o)) => match o.as_str() {
|
||||
"Msk" => step("channel.target", json!({"channel": "mask"})),
|
||||
"RGB" | "CMYK" | "Lab" | "Gry" => step("channel.target", json!({"channel": "composite"})),
|
||||
// Re-selecting the channel that is already targeted changes nothing.
|
||||
"Trgt" => Mapped::Unsupported { harmless: true },
|
||||
_ => Mapped::Unsupported { harmless: false },
|
||||
},
|
||||
// History › Step Backward / Step Forward.
|
||||
(Some("HstS"), Target::Ordinal(o)) if o == "Prvs" => none("edit.undo"),
|
||||
(Some("HstS"), Target::Ordinal(o)) if o == "Nxt" => none("edit.redo"),
|
||||
(Some("Lyr"), Target::Ordinal(o)) => {
|
||||
// Shift+Alt+[ / ] add the next layer to the selection instead of replacing it.
|
||||
let add = get_enum(d, "selectionModifier").as_deref() == Some("addToSelection");
|
||||
@@ -1285,14 +1406,28 @@ fn select_from(d: &Descriptor) -> Mapped {
|
||||
|
||||
fn select_to(id: &str, p: &Value) -> Option<Descriptor> {
|
||||
let ord = |o: &str| D::Reference(vec![ReferenceItem::Enumerated { class: class("Lyr "), type_id: code("Ordn"), value: code(o) }]);
|
||||
let enumerated = |cls: &str, ty: &str, v: &str| D::Reference(vec![ReferenceItem::Enumerated { class: class(cls), type_id: code(ty), value: code(v) }]);
|
||||
Some(match id {
|
||||
"layer.select" => {
|
||||
let mut d = desc("null").with("null", name_ref("Lyr ", jstr(p, "layer")?));
|
||||
if jstr(p, "mode") == Some("add") {
|
||||
d = d.with("selectionModifier", enm("selectionModifierType", "addToSelection"));
|
||||
match jstr(p, "mode") {
|
||||
None | Some("replace") => {}
|
||||
Some("add") => d = d.with("selectionModifier", enm("selectionModifierType", "addToSelection")),
|
||||
Some("remove") => d = d.with("selectionModifier", enm("selectionModifierType", "removeFromSelection")),
|
||||
Some(_) => return None,
|
||||
}
|
||||
d.with("MkVs", D::Boolean(false))
|
||||
}
|
||||
"channel.target" => {
|
||||
let v = match jstr(p, "channel") {
|
||||
Some("mask") => "Msk ",
|
||||
None | Some("composite") => "RGB ",
|
||||
Some(_) => return None,
|
||||
};
|
||||
desc("null").with("null", enumerated("Chnl", "Chnl", v)).with("MkVs", D::Boolean(false))
|
||||
}
|
||||
"edit.undo" => desc("null").with("null", enumerated("HstS", "Ordn", "Prvs")),
|
||||
"edit.redo" => desc("null").with("null", enumerated("HstS", "Ordn", "Nxt ")),
|
||||
"layer.selectAbove" => desc("null").with("null", ord("Frwr")),
|
||||
"layer.selectBelow" => desc("null").with("null", ord("Bckw")),
|
||||
"layer.addAboveToSelection" => desc("null").with("null", ord("Frwr")).with("selectionModifier", enm("selectionModifierType", "addToSelection")),
|
||||
@@ -1679,14 +1814,25 @@ fn move_from(d: &Descriptor) -> Mapped {
|
||||
let Some(mut p) = layer_target(r) else { return Mapped::Unsupported { harmless: false } };
|
||||
match d.get("T ") {
|
||||
Some(D::Descriptor(t)) => {
|
||||
let in_px = |k: &str| matches!(t.get(k), Some(D::UnitFloat { unit: PX, .. }));
|
||||
let unit = |k: &str| match t.get(k) {
|
||||
Some(D::UnitFloat { unit, .. }) => Some(*unit),
|
||||
_ => None,
|
||||
};
|
||||
let (Some(dx), Some(dy)) = (get_num(t, "Hrzn"), get_num(t, "Vrtc")) else { return Mapped::Unsupported { harmless: false } };
|
||||
if !in_px("Hrzn") || !in_px("Vrtc") {
|
||||
// Points at an unknown resolution.
|
||||
return Mapped::Unsupported { harmless: false };
|
||||
match (unit("Hrzn"), unit("Vrtc")) {
|
||||
(Some(PX), Some(PX)) => {
|
||||
p.insert("dx".into(), dx.round().into());
|
||||
p.insert("dy".into(), dy.round().into());
|
||||
}
|
||||
// A nudge recorded with the rulers in other units: points, converted to pixels
|
||||
// at the resolution of the document the action plays on.
|
||||
(Some(PT), Some(PT)) => {
|
||||
p.insert("dx".into(), dx.into());
|
||||
p.insert("dy".into(), dy.into());
|
||||
p.insert("units".into(), "pt".into());
|
||||
}
|
||||
_ => return Mapped::Unsupported { harmless: false },
|
||||
}
|
||||
p.insert("dx".into(), dx.round().into());
|
||||
p.insert("dy".into(), dy.round().into());
|
||||
step("layer.translate", Value::Object(p))
|
||||
}
|
||||
Some(D::Reference(t)) => {
|
||||
@@ -1708,7 +1854,15 @@ fn move_from(d: &Descriptor) -> Mapped {
|
||||
fn move_to(id: &str, p: &Value) -> Option<Descriptor> {
|
||||
let ord = |o: &str| D::Reference(vec![ReferenceItem::Enumerated { class: class("Lyr "), type_id: code("Ordn"), value: code(o) }]);
|
||||
let t = match id {
|
||||
"layer.translate" => D::Descriptor(desc("Ofst").with("Hrzn", px(jnum(p, "dx").unwrap_or(0.0))).with("Vrtc", px(jnum(p, "dy").unwrap_or(0.0)))),
|
||||
"layer.translate" => {
|
||||
let unit = match jstr(p, "units") {
|
||||
None | Some("px") => PX,
|
||||
Some("pt") => PT,
|
||||
Some(_) => return None,
|
||||
};
|
||||
let v = |k: &str| D::UnitFloat { unit, value: jnum(p, k).unwrap_or(0.0) };
|
||||
D::Descriptor(desc("Ofst").with("Hrzn", v("dx")).with("Vrtc", v("dy")))
|
||||
}
|
||||
"layer.arrange.bringToFront" => ord("Frnt"),
|
||||
"layer.arrange.sendToBack" => ord("Back"),
|
||||
"layer.arrange.bringForward" => ord("Nxt "),
|
||||
@@ -1941,17 +2095,59 @@ fn convert_mode_from(d: &Descriptor) -> Mapped {
|
||||
_ => Mapped::Unsupported { harmless: false },
|
||||
};
|
||||
}
|
||||
match get_enum(d, "T ").and_then(|t| lookup(MODES, &t)) {
|
||||
// The mode as an enumeration (`ClrS.Grys`), a class (`Grys`), or an object of the mode's
|
||||
// class holding its options (`BtmM {Rslt, Mthd}`, `Grys {Rt}` from Bitmap).
|
||||
let (mode, options) = match d.get("T ") {
|
||||
Some(D::Enumerated { value, .. }) => (trimmed(value), None),
|
||||
Some(D::Class(c)) => (trimmed(&c.class_id), None),
|
||||
Some(D::Descriptor(o)) => (trimmed(&o.class_id), Some(o)),
|
||||
_ => return Mapped::Unsupported { harmless: false },
|
||||
};
|
||||
if mode == "BtmM" {
|
||||
return bitmap_from(options);
|
||||
}
|
||||
// Bitmap to Grayscale records a size ratio; only 1 keeps the pixels as they are.
|
||||
if options.and_then(|o| get_num(o, "Rt ")).is_some_and(|r| (r - 1.0).abs() > 1e-9) {
|
||||
return Mapped::Unsupported { harmless: false };
|
||||
}
|
||||
match lookup(MODES, &mode) {
|
||||
Some(m) => none(&format!("image.mode.{m}")),
|
||||
None => Mapped::Unsupported { harmless: false },
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_mode_to(id: &str, _: &Value) -> Option<Descriptor> {
|
||||
const BITMAP_METHOD: &[(&str, &str)] = &[("Thrh", "threshold"), ("PtnD", "pattern"), ("DfnD", "diffusion")];
|
||||
|
||||
/// Image › Mode › Bitmap. Its Output resolution resamples the image, keeping the print size,
|
||||
/// before the conversion; at the document's own resolution that is a no-op. Halftone screens
|
||||
/// and custom patterns stay unsupported.
|
||||
fn bitmap_from(options: Option<&Descriptor>) -> Mapped {
|
||||
let Some(o) = options else { return none("image.mode.bitmap") };
|
||||
let method = match get_enum(o, "Mthd") {
|
||||
None => "diffusion",
|
||||
Some(m) => match lookup(BITMAP_METHOD, &m) {
|
||||
Some(m) => m,
|
||||
None => return Mapped::Unsupported { harmless: false },
|
||||
},
|
||||
};
|
||||
let convert = ("image.mode.bitmap", json!({"method": method}));
|
||||
match get_num(o, "Rslt") {
|
||||
Some(r) => steps(vec![("image.imageSize", json!({"resolution": r, "resample": "bicubic", "keepPrintSize": true})), convert]),
|
||||
None => steps(vec![convert]),
|
||||
}
|
||||
}
|
||||
|
||||
fn bitmap_to(p: &Value) -> Option<Descriptor> {
|
||||
let method = lookup_back(BITMAP_METHOD, jstr(p, "method").unwrap_or("diffusion"))?;
|
||||
Some(desc("null").with("T ", D::Descriptor(desc("BtmM").with("Mthd", enm("Mthd", method)))))
|
||||
}
|
||||
|
||||
fn convert_mode_to(id: &str, p: &Value) -> Option<Descriptor> {
|
||||
Some(match id {
|
||||
"image.mode.bits8" => desc("null").with("Dpth", long(8.0)),
|
||||
"image.mode.bits16" => desc("null").with("Dpth", long(16.0)),
|
||||
"image.mode.bits32" => desc("null").with("Dpth", long(32.0)),
|
||||
"image.mode.bitmap" => bitmap_to(p)?,
|
||||
_ => desc("null").with("T ", enm("ClrS", lookup_back(MODES, id.strip_prefix("image.mode.")?)?)),
|
||||
})
|
||||
}
|
||||
@@ -2065,6 +2261,10 @@ fn image_size_from(d: &Descriptor) -> Mapped {
|
||||
match lookup(INTERPOLATION, &i) {
|
||||
Some(i) => {
|
||||
p.insert("resample".into(), i.into());
|
||||
// Resample on with only the resolution changed: Photoshop keeps the print size.
|
||||
if p.contains_key("resolution") && !p.contains_key("width") && !p.contains_key("height") && !p.contains_key("percent") {
|
||||
p.insert("keepPrintSize".into(), true.into());
|
||||
}
|
||||
}
|
||||
None => return Mapped::Unsupported { harmless: false },
|
||||
}
|
||||
@@ -2257,6 +2457,48 @@ macro_rules! adjustment_event {
|
||||
};
|
||||
}
|
||||
|
||||
const RIPPLE_SIZE: &[(&str, &str)] = &[("Sml", "small"), ("Mdm", "medium"), ("Lrg", "large")];
|
||||
|
||||
/// Select and Mask (`refineSelectionEdge`). Smart Radius, decontamination and the output follow
|
||||
/// Photoshop's; the edge itself is PhotoCraft's own matting, so results differ in detail.
|
||||
const REFINE_EDGE: &[F] = &[
|
||||
F("refineEdgeBorderRadius", "radius", Kind::Px),
|
||||
F("refineEdgeAutoRadius", "smartRadius", Kind::Bool),
|
||||
F("refineEdgeSmooth", "smooth", Kind::Long),
|
||||
F("refineEdgeFeatherRadius", "feather", Kind::Px),
|
||||
F("refineEdgeBorderContrast", "contrast", Kind::Pct),
|
||||
F("refineEdgeChoke", "shiftEdge", Kind::Pct),
|
||||
F("refineEdgeDecontaminate", "decontaminate", Kind::Bool),
|
||||
F("refineEdgeDeconAmount", "amount", Kind::Pct),
|
||||
F("refineEdgeOutput", "output", Kind::Enum("refineEdgeOutput", REFINE_OUTPUT)),
|
||||
];
|
||||
const REFINE_OUTPUT: &[(&str, &str)] = &[
|
||||
("refineEdgeOutputSelection", "selection"),
|
||||
("refineEdgeOutputUserMask", "layerMask"),
|
||||
("refineEdgeOutputNewLayer", "newLayer"),
|
||||
("refineEdgeOutputNewLayerWithMask", "newLayerWithMask"),
|
||||
("refineEdgeOutputNewDocument", "newDocument"),
|
||||
("refineEdgeOutputNewDocumentWithMask", "newDocumentWithMask"),
|
||||
];
|
||||
|
||||
/// Filter Gallery effects (`GEfc` with the effect in `GEfk`): Photoshop's effect code, the
|
||||
/// command, and its fields. A stack of several effects stays unsupported.
|
||||
const GALLERY: &[(&str, &str, &[F])] = &[("Spt", "filter.gallery.spatter", &[F("SprR", "sprayRadius", Kind::Long), F("Smth", "smoothness", Kind::Long)])];
|
||||
const GALLERY_IDS: &[&str] = &["filter.gallery.spatter"];
|
||||
|
||||
fn gallery_from(d: &Descriptor) -> Mapped {
|
||||
let Some(effect) = get_enum(d, "GEfk") else { return Mapped::Unsupported { harmless: false } };
|
||||
match GALLERY.iter().find(|(code, _, _)| *code == effect).and_then(|(_, id, fields)| Some((*id, fields_from(d, fields)?))) {
|
||||
Some((id, p)) => step(id, Value::Object(p)),
|
||||
None => Mapped::Unsupported { harmless: false },
|
||||
}
|
||||
}
|
||||
|
||||
fn gallery_to(id: &str, p: &Value) -> Option<Descriptor> {
|
||||
let (code, _, fields) = GALLERY.iter().find(|(_, cmd, _)| *cmd == id)?;
|
||||
fields_to(p, fields, desc("null").with("GEfk", enm("GEft", code)))
|
||||
}
|
||||
|
||||
/// Every Photoshop event PhotoCraft maps. The order matters only for the panel label.
|
||||
const EVENTS: &[Event] = &[
|
||||
// Filters.
|
||||
@@ -2320,6 +2562,9 @@ const EVENTS: &[Event] = &[
|
||||
"filter.other.offset",
|
||||
&[F("Hrzn", "horizontal", Kind::Long), F("Vrtc", "vertical", Kind::Long), F("Fl ", "undefinedAreas", Kind::Enum("FlMd", OFFSET_FILL))]
|
||||
),
|
||||
ev!("Rple", "ripple", "Ripple", "filter.distort.ripple", &[F("Amnt", "amount", Kind::Long), F("RplS", "size", Kind::Enum("RplS", RIPPLE_SIZE))]),
|
||||
ev!("Crst", "crystallize", "Crystallize", "filter.pixelate.crystallize", &[F("ClSz", "cellSize", Kind::Long)]),
|
||||
Event { code: "GEfc", name: "filterGallery", label: "Filter Gallery", ids: GALLERY_IDS, from: gallery_from, to: gallery_to },
|
||||
// Adjustments (Image › Adjustments).
|
||||
Event {
|
||||
code: "Lvls",
|
||||
@@ -2436,6 +2681,7 @@ const EVENTS: &[Event] = &[
|
||||
"image.mode.bits8",
|
||||
"image.mode.bits16",
|
||||
"image.mode.bits32",
|
||||
"image.mode.bitmap",
|
||||
],
|
||||
from: convert_mode_from,
|
||||
to: convert_mode_to,
|
||||
@@ -2453,6 +2699,7 @@ const EVENTS: &[Event] = &[
|
||||
"layer.new.layerViaCopy",
|
||||
"layer.new.layerViaCut",
|
||||
"layer.newFillLayer.solidColor",
|
||||
"layer.newFillLayer.pattern",
|
||||
"layer.newAdjustmentLayer.levels",
|
||||
"layer.newAdjustmentLayer.curves",
|
||||
"layer.newAdjustmentLayer.hueSaturation",
|
||||
@@ -2490,6 +2737,8 @@ const EVENTS: &[Event] = &[
|
||||
"layer.renameLayer",
|
||||
"layer.setLabelColor",
|
||||
"layer.setAdjustment",
|
||||
"layer.fill.pattern.set",
|
||||
"layer.new.layerFromBackground",
|
||||
],
|
||||
from: set_from,
|
||||
to: set_to,
|
||||
@@ -2506,6 +2755,9 @@ const EVENTS: &[Event] = &[
|
||||
"layer.addBelowToSelection",
|
||||
"layer.selectTop",
|
||||
"layer.selectBottom",
|
||||
"channel.target",
|
||||
"edit.undo",
|
||||
"edit.redo",
|
||||
],
|
||||
from: select_from,
|
||||
to: select_to,
|
||||
@@ -2574,6 +2826,7 @@ const EVENTS: &[Event] = &[
|
||||
ev!("Brdr", "border", "Border", "select.modify.border", &[F("Wdth", "radius", Kind::Px)]),
|
||||
ev!("Grow", "grow", "Grow", "select.grow", &[F("Tlrn", "tolerance", Kind::Long)]),
|
||||
ev!("Smlr", "similar", "Similar", "select.similar", &[F("Tlrn", "tolerance", Kind::Long)]),
|
||||
ev!("", "refineSelectionEdge", "Select and Mask", "select.refineEdge", REFINE_EDGE),
|
||||
// Edit.
|
||||
Event { code: "Fl ", name: "fill", label: "Fill", ids: &["edit.fill", "paint.bucket"], from: fill_from, to: fill_to },
|
||||
ev!("Strk", "stroke", "Stroke", "edit.stroke", STROKE),
|
||||
|
||||
@@ -295,6 +295,26 @@ fn every_mapped_command_exports_and_imports_back() {
|
||||
("actions.conditional", json!({"if": "landscape", "then": "Wide", "else": null, "set": "S"})),
|
||||
("actions.play", json!({"action": "Other", "set": "S"})),
|
||||
(STOP_MESSAGE, json!({"message": "Hi", "allowContinue": false})),
|
||||
// The TrueGrit Texture Supply sets (Debaser, Distress Press, Halftone Zine Machine).
|
||||
("filter.distort.ripple", json!({"amount": 50, "size": "medium"})),
|
||||
("filter.pixelate.crystallize", json!({"cellSize": 5})),
|
||||
("filter.gallery.spatter", json!({"sprayRadius": 7, "smoothness": 15})),
|
||||
(
|
||||
"select.refineEdge",
|
||||
json!({"radius": 0, "smartRadius": false, "smooth": 2, "feather": 0.5, "contrast": 15, "shiftEdge": -100, "decontaminate": false, "output": "layerMask"}),
|
||||
),
|
||||
("channel.target", json!({"channel": "mask"})),
|
||||
("channel.target", json!({"channel": "composite"})),
|
||||
("edit.undo", json!({})),
|
||||
("edit.redo", json!({})),
|
||||
("layer.select", json!({"layer": "YELLOW", "mode": "remove"})),
|
||||
("layer.translate", json!({"dx": 0.36, "dy": -1.08, "units": "pt"})),
|
||||
("select.loadSelection", json!({"channel": "targeted"})),
|
||||
("layer.newFillLayer.pattern", json!({"pattern": "Debaser-Distressed-Dark", "scale": 50, "angle": 0, "link": true})),
|
||||
("layer.fill.pattern.set", json!({"pattern": "1 -R2.jpg"})),
|
||||
("image.mode.bitmap", json!({"method": "threshold"})),
|
||||
("image.imageSize", json!({"resolution": 1200, "resample": "preserveDetails", "keepPrintSize": true})),
|
||||
("layer.new.layerFromBackground", json!({"ifPresent": true, "select": false})),
|
||||
];
|
||||
for (id, params) in &cases {
|
||||
let item = export_item(id, params, "S").unwrap_or_else(|| panic!("{id} does not export"));
|
||||
@@ -409,7 +429,7 @@ fn every_mapped_command_exports_and_imports_back() {
|
||||
#[test]
|
||||
fn unsupported_shapes_become_photoshop_steps_not_errors() {
|
||||
// Image size in two different percentages, a Paint Bucket click in percent of the canvas, a
|
||||
// move in points, an unknown adjustment class.
|
||||
// move in percent, an unknown adjustment class.
|
||||
let items = vec![
|
||||
item("ImgS", "Image Size", Some(desc("null").with("Wdth", pct(200.0)).with("Hght", pct(150.0)))),
|
||||
item(
|
||||
@@ -420,10 +440,7 @@ fn unsupported_shapes_become_photoshop_steps_not_errors() {
|
||||
item(
|
||||
"move",
|
||||
"Move",
|
||||
Some(desc("null").with("null", target_ref("Lyr ")).with(
|
||||
"T ",
|
||||
D::Descriptor(desc("Ofst").with("Hrzn", D::UnitFloat { unit: *b"#Rlt", value: 3.0 }).with("Vrtc", D::UnitFloat { unit: *b"#Rlt", value: 1.0 })),
|
||||
)),
|
||||
Some(desc("null").with("null", target_ref("Lyr ")).with("T ", D::Descriptor(desc("Ofst").with("Hrzn", pct(3.0)).with("Vrtc", pct(1.0))))),
|
||||
),
|
||||
item(
|
||||
"make",
|
||||
@@ -746,3 +763,179 @@ fn remaining_events_import_in_their_photoshop_shapes() {
|
||||
let e = s.execute("image.applyImage", json!({"source": {"layer": "Nope"}})).unwrap_err().to_string();
|
||||
assert!(e.contains("Nope"), "{e}");
|
||||
}
|
||||
|
||||
fn channel_ord(value: &str) -> D {
|
||||
D::Reference(vec![ReferenceItem::Enumerated { class: class("Chnl"), type_id: Id::new("Chnl"), value: code(value) }])
|
||||
}
|
||||
|
||||
fn fsel(t: D) -> Descriptor {
|
||||
desc("null").with("null", D::Reference(vec![ReferenceItem::Property { class: class("Chnl"), key: Id::new("fsel") }])).with("T ", t)
|
||||
}
|
||||
|
||||
fn set_of(name: &str, actions: Vec<(&str, Vec<AtnItem>)>) -> String {
|
||||
let file = AtnFile {
|
||||
name: UnicodeString::new_nul(name),
|
||||
expanded: false,
|
||||
actions: actions.into_iter().map(|(n, items)| AtnAction { name: UnicodeString::new_nul(n), items, ..Default::default() }).collect(),
|
||||
warnings: vec![],
|
||||
};
|
||||
b64(&atn::write(&file).unwrap())
|
||||
}
|
||||
|
||||
/// The step shapes of the TrueGrit Texture Supply sets (Distress Press, Debaser, Transparency,
|
||||
/// Halftone Zine Machine), rebuilt here as Photoshop records them, import and play: the
|
||||
/// distressing filters land on the targeted layer mask, Remove White leaves the white
|
||||
/// transparent, nudges in points follow the resolution, and Step Backward undoes a step.
|
||||
#[test]
|
||||
fn distressing_and_remove_white_shapes_import_and_play() {
|
||||
let ord = |cls: &str, ty: &str, v: &str| D::Reference(vec![ReferenceItem::Enumerated { class: class(cls), type_id: Id::new(ty), value: code(v) }]);
|
||||
let black = D::Descriptor(desc("RGBC").with("Rd ", D::Double(0.0)).with("Grn ", D::Double(0.0)).with("Bl ", D::Double(0.0)));
|
||||
let fill = desc("null")
|
||||
.with("null", class_ref("contentLayer"))
|
||||
.with("Usng", D::Descriptor(desc("contentLayer").with("Type", D::Descriptor(desc("solidColorLayer").with("Clr ", black)))));
|
||||
let refine = desc("null")
|
||||
.with("refineEdgeBorderRadius", px(0.0))
|
||||
.with("refineEdgeBorderContrast", pct(100.0))
|
||||
.with("refineEdgeSmooth", long(2.0))
|
||||
.with("refineEdgeFeatherRadius", px(0.0))
|
||||
.with("refineEdgeChoke", pct(0.0))
|
||||
.with("refineEdgeAutoRadius", D::Boolean(false))
|
||||
.with("refineEdgeDecontaminate", D::Boolean(false))
|
||||
.with("refineEdgeOutput", enm("refineEdgeOutput", "refineEdgeOutputUserMask"));
|
||||
let distress = vec![
|
||||
item("setd", "Set", Some(fsel(channel_ord("Trsp")))),
|
||||
item("Hd ", "Hide", Some(desc("null").with("null", D::List(vec![target_ref("Lyr ")])))),
|
||||
item("Mk ", "Make", Some(fill)),
|
||||
item("slct", "Select", Some(desc("null").with("null", channel_ord("Msk")).with("MkVs", D::Boolean(false)))),
|
||||
item("refineSelectionEdge", "Select and Mask", Some(refine)),
|
||||
item("Rple", "Ripple", Some(desc("null").with("Amnt", long(50.0)).with("RplS", enm("RplS", "Sml")))),
|
||||
item("Crst", "Crystallize", Some(desc("null").with("ClSz", long(3.0)))),
|
||||
item("GEfc", "Filter Gallery", Some(desc("null").with("GEfk", enm("GEft", "Spt")).with("SprR", long(7.0)).with("Smth", long(15.0)))),
|
||||
item("slct", "Select", Some(desc("null").with("null", ord("Chnl", "Ordn", "Trgt")).with("MkVs", D::Boolean(false)))),
|
||||
item("setd", "Set", Some(fsel(ord("Chnl", "Ordn", "Trgt")))),
|
||||
];
|
||||
let bckg = D::Reference(vec![ReferenceItem::Property { class: class("Lyr "), key: Id::new("Bckg") }]);
|
||||
let remove_white = vec![
|
||||
item("CnvM", "Convert Mode", Some(desc("null").with("T ", D::Class(class("Grys"))))),
|
||||
item(
|
||||
"setd",
|
||||
"Set",
|
||||
Some(desc("null").with("null", bckg).with("T ", D::Descriptor(desc("Lyr ").with("Opct", pct(100.0)).with("Md ", enm("BlnM", "Nrml"))))),
|
||||
),
|
||||
item("setd", "Set", Some(fsel(ord("Chnl", "Ordn", "Trgt")))),
|
||||
item("Dlt ", "Delete", None),
|
||||
item("setd", "Set", Some(fsel(enm("Ordn", "None")))),
|
||||
];
|
||||
let pt = |v: f64| D::UnitFloat { unit: PT, value: v };
|
||||
let nudge = vec![item(
|
||||
"move",
|
||||
"Move",
|
||||
Some(desc("null").with("null", target_ref("Lyr ")).with("T ", D::Descriptor(desc("Ofst").with("Hrzn", pt(0.36)).with("Vrtc", pt(-0.36))))),
|
||||
)];
|
||||
let undo = vec![item("Invs", "Inverse", None), item("slct", "Select", Some(desc("null").with("null", ord("HstS", "Ordn", "Prvs"))))];
|
||||
let data = set_of("TrueGrit", vec![("Distress", distress), ("Remove White", remove_white), ("Nudge", nudge), ("Undo", undo)]);
|
||||
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 120, "height": 80, "resolution": 200})).unwrap();
|
||||
s.execute("layer.new.layer", json!({"name": "Ink"})).unwrap();
|
||||
s.execute("select.rect", json!({"x": 20, "y": 20, "width": 80, "height": 40})).unwrap();
|
||||
s.execute("edit.fill", json!({"contents": "color", "color": "#000000"})).unwrap();
|
||||
s.execute("select.deselect", json!({})).unwrap();
|
||||
let r = s.execute("actions.load", json!({"data": data})).unwrap();
|
||||
// Only the re-selection of the targeted channel stays a Photoshop step, switched off.
|
||||
let unsupported: Vec<&str> = r["unsupported"].as_array().unwrap().iter().map(|u| u["action"].as_str().unwrap()).collect();
|
||||
assert_eq!(unsupported, ["Distress"], "{r}");
|
||||
|
||||
let r = s.execute("actions.play", json!({"action": "Distress"})).unwrap();
|
||||
assert!(r.get("failed").is_none(), "{r}");
|
||||
let st = s.active().unwrap();
|
||||
let fill = st.doc.layer(st.active_layer.unwrap()).unwrap();
|
||||
let mask = &fill.mask.as_ref().expect("the fill layer is masked by the ink").surface;
|
||||
// The rectangle's straight top edge is roughened: pixels along it differ.
|
||||
let edge: Vec<f32> = (20..100).map(|x| mask.sample_channel(x, 20, 0)).collect();
|
||||
assert!(edge.iter().any(|v| *v < 0.5) && edge.iter().any(|v| *v > 0.5), "{edge:?}");
|
||||
assert!(mask.sample_channel(60, 40, 0) > 0.9 && mask.sample_channel(5, 5, 0) < 0.1);
|
||||
assert!(st.doc.selection.is_some(), "the targeted mask loads as the selection");
|
||||
assert_eq!(st.channel_view.target, crate::channel_cmds::ChannelTarget::LayerMask);
|
||||
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 40, "height": 30})).unwrap();
|
||||
s.execute("select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 10})).unwrap();
|
||||
s.execute("edit.fill", json!({"contents": "color", "color": "#202020"})).unwrap();
|
||||
s.execute("select.deselect", json!({})).unwrap();
|
||||
s.execute("actions.load", json!({"data": data})).unwrap();
|
||||
let r = s.execute("actions.play", json!({"action": "Remove White"})).unwrap();
|
||||
assert!(r.get("failed").is_none(), "{r}");
|
||||
let d = &s.active().unwrap().doc;
|
||||
assert_eq!(d.mode, photocraft_color::ColorMode::Grayscale);
|
||||
let l = d.layers.last().unwrap();
|
||||
assert!(!d.is_background(l.id), "Layer From Background");
|
||||
let surf = l.surface().unwrap();
|
||||
assert!(surf.sample_channel(2, 2, 1) < 0.01, "white is cleared");
|
||||
// #202020 is about 22 in the default (Dot Gain 20 %-compatible) working gray.
|
||||
let ink = surf.sample_channel(20, 15, 1);
|
||||
assert!((ink - (1.0 - 22.0 / 255.0)).abs() < 0.02, "the ink keeps its darkness as opacity: {ink}");
|
||||
// An image that opened as a normal layer has no Background: the step leaves it as it is.
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 40, "height": 30, "background": "transparent"})).unwrap();
|
||||
s.execute("edit.fill", json!({"contents": "white"})).unwrap();
|
||||
s.execute("actions.load", json!({"data": data})).unwrap();
|
||||
let r = s.execute("actions.play", json!({"action": "Remove White"})).unwrap();
|
||||
assert!(r.get("failed").is_none(), "{r}");
|
||||
let surf = s.active().unwrap().doc.layers.last().unwrap().surface().unwrap().clone();
|
||||
assert!(surf.sample_channel(2, 2, 1) < 0.01, "white is cleared without a Background");
|
||||
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 40, "height": 30, "resolution": 200})).unwrap();
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
s.execute("select.rect", json!({"x": 10, "y": 10, "width": 5, "height": 5})).unwrap();
|
||||
s.execute("edit.fill", json!({"contents": "color", "color": "#ff0000"})).unwrap();
|
||||
s.execute("actions.load", json!({"data": data})).unwrap();
|
||||
s.execute("select.deselect", json!({})).unwrap();
|
||||
assert!(s.execute("actions.play", json!({"action": "Nudge"})).unwrap().get("failed").is_none());
|
||||
let st = s.active().unwrap();
|
||||
let b = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().content_bounds();
|
||||
assert_eq!((b.x0, b.y0), (11, 9), "0.36 pt is 1 px at 200 ppi");
|
||||
s.execute("select.rect", json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap();
|
||||
let before = s.active().unwrap().doc.selection.clone();
|
||||
assert!(s.execute("actions.play", json!({"action": "Undo"})).unwrap().get("failed").is_none());
|
||||
assert_eq!(s.active().unwrap().doc.selection, before, "Step Backward undid the Inverse");
|
||||
}
|
||||
|
||||
/// Remove White on art in its own layer above a white Background: the Background becomes a
|
||||
/// normal layer, but the art layer stays targeted, so the white is cleared from the art.
|
||||
#[test]
|
||||
fn remove_white_clears_the_targeted_layer_not_the_converted_background() {
|
||||
let ord = |cls: &str, ty: &str, v: &str| D::Reference(vec![ReferenceItem::Enumerated { class: class(cls), type_id: Id::new(ty), value: code(v) }]);
|
||||
let bckg = D::Reference(vec![ReferenceItem::Property { class: class("Lyr "), key: Id::new("Bckg") }]);
|
||||
let steps = vec![
|
||||
item("CnvM", "Convert Mode", Some(desc("null").with("T ", D::Class(class("Grys"))))),
|
||||
item(
|
||||
"setd",
|
||||
"Set",
|
||||
Some(desc("null").with("null", bckg).with("T ", D::Descriptor(desc("Lyr ").with("Opct", pct(100.0)).with("Md ", enm("BlnM", "Nrml"))))),
|
||||
),
|
||||
item("setd", "Set", Some(fsel(ord("Chnl", "Ordn", "Trgt")))),
|
||||
item("Dlt ", "Delete", None),
|
||||
item("setd", "Set", Some(fsel(enm("Ordn", "None")))),
|
||||
];
|
||||
let data = set_of("TrueGrit", vec![("Remove White", steps)]);
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 40, "height": 30})).unwrap();
|
||||
let art = s.execute("layer.new.layer", json!({"name": "Art"})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("edit.fill", json!({"contents": "white"})).unwrap();
|
||||
s.execute("select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 10})).unwrap();
|
||||
s.execute("edit.fill", json!({"contents": "color", "color": "#000000"})).unwrap();
|
||||
s.execute("select.deselect", json!({})).unwrap();
|
||||
s.execute("actions.load", json!({"data": data})).unwrap();
|
||||
let r = s.execute("actions.play", json!({"action": "Remove White"})).unwrap();
|
||||
assert!(r.get("failed").is_none(), "{r}");
|
||||
let st = s.active().unwrap();
|
||||
assert_eq!(st.active_layer.map(|l| l.0), Some(art), "the art layer stays targeted");
|
||||
let art = st.doc.layer(photocraft_doc::LayerId(art)).unwrap().surface().unwrap();
|
||||
assert!(art.sample_channel(2, 2, 1) < 0.01, "the art's white is cleared");
|
||||
assert!(art.sample_channel(20, 15, 1) > 0.99, "the black stays");
|
||||
let bottom = st.doc.layers.first().unwrap();
|
||||
assert!(!st.doc.is_background(bottom.id), "the Background became a normal layer");
|
||||
assert!(bottom.surface().unwrap().sample_channel(2, 2, 1) > 0.99, "and kept its pixels");
|
||||
}
|
||||
@@ -391,23 +391,8 @@ fn statistics(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.ok_or_else(|| bad(cmd, format!("unknown stack mode `{mode}`")))?;
|
||||
let input = p.get("input").or_else(|| p.get("paths")).cloned().ok_or_else(|| bad(cmd, "missing \"input\" (files or a folder)"))?;
|
||||
let align = p.get("align").and_then(Value::as_bool).unwrap_or(false);
|
||||
let r = s.execute("file.scripts.loadFilesIntoStack", json!({"paths": input, "createSmartObject": !align}))?;
|
||||
let r = s.execute("file.scripts.loadFilesIntoStack", json!({"paths": input, "align": align, "createSmartObject": true}))?;
|
||||
let n = r["layers"].as_u64().unwrap_or(0);
|
||||
if align && n > 1 {
|
||||
s.execute("select.allLayers", json!({}))?;
|
||||
s.execute("edit.autoAlignLayers", json!({"projection": "auto"}))?;
|
||||
let name = s.active().map(|d| d.doc.name.clone()).unwrap_or_default();
|
||||
s.edit("Convert to Smart Object", |doc, active| {
|
||||
let layers = std::mem::take(&mut doc.layers);
|
||||
let smart = crate::smart_cmds::layers_to_smart(doc, &name, layers)?;
|
||||
*active = Some(smart.id);
|
||||
doc.layers = vec![smart];
|
||||
Ok(())
|
||||
})?;
|
||||
if let Some(st) = s.active_mut() {
|
||||
st.selected_layers = st.active_layer.into_iter().collect();
|
||||
}
|
||||
}
|
||||
let id = s.active().and_then(|d| d.doc.layers.first().map(|l| l.id)).ok_or(EngineError::NoDocument)?;
|
||||
s.select_layer(id)?;
|
||||
s.execute(&format!("layer.smartObjects.stackMode.{}", sm.id()), json!({"layer": id.0}))?;
|
||||
|
||||
@@ -371,3 +371,45 @@ fn a_failed_step_inside_a_called_action_fails_scripts_and_batch() {
|
||||
s.actions.list.push(Action { name: "Loop".into(), steps: vec![("file.automate.batch".into(), inner)], disabled: Default::default(), ..Default::default() });
|
||||
batch(&mut s, "Loop");
|
||||
}
|
||||
|
||||
/// #4671: Load Files into Stack's Attempt to Automatically Align Source Images runs Auto-Align
|
||||
/// Layers on the loaded layers, then (with Create Smart Object) puts them in one smart object;
|
||||
/// without the smart object they stay loose layers, selected.
|
||||
#[test]
|
||||
fn load_files_into_stack_aligns_before_the_smart_object() {
|
||||
let dir = tmp("stack-align");
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 96, "height": 72, "background": "#606060"})).unwrap();
|
||||
s.edit("texture", |doc, active| {
|
||||
let surf = doc.layer_mut(active.ok_or(EngineError::NoDocument)?).and_then(Layer::surface_mut).ok_or(EngineError::NoDocument)?;
|
||||
for k in 0..40 {
|
||||
let (x, y, v) = ((k * 37) % 88, (k * 23) % 66, (k % 7) as f32 / 6.0);
|
||||
surf.fill_rect(photocraft_geom::Rect::new(x, y, x + 3 + k % 5, y + 2 + k % 4), &[v, 1.0 - v, 0.5, 1.0]);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let files: Vec<String> = (0..2)
|
||||
.map(|i| {
|
||||
let path = format!("{dir}/img{i}.png");
|
||||
crate::file_cmds::save_doc(&s.active().unwrap().doc, &path, None).unwrap();
|
||||
path
|
||||
})
|
||||
.collect();
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("file.scripts.loadFilesIntoStack", json!({"paths": files, "align": true})).unwrap();
|
||||
assert_eq!(r["layers"], 2);
|
||||
let st = s.active().unwrap();
|
||||
assert_eq!(st.doc.layers.len(), 2, "loose layers");
|
||||
assert!(st.doc.layers.iter().all(|l| matches!(l.content, LayerContent::Raster(_))));
|
||||
assert_eq!(st.selected_layers().len(), 2, "Auto-Align ran on them all");
|
||||
let mut s = Session::new();
|
||||
s.execute("file.scripts.loadFilesIntoStack", json!({"paths": files, "align": true, "createSmartObject": true})).unwrap();
|
||||
let d = &s.active().unwrap().doc;
|
||||
assert_eq!(d.layers.len(), 1);
|
||||
let LayerContent::Smart(_) = &d.layers[0].content else { panic!("not a smart object") };
|
||||
// A single file has nothing to align with: it just loads.
|
||||
let mut s = Session::new();
|
||||
s.execute("file.scripts.loadFilesIntoStack", json!({"paths": [files[0].clone()], "align": true})).unwrap();
|
||||
assert_eq!(s.active().unwrap().doc.layers.len(), 1);
|
||||
}
|
||||
@@ -10,7 +10,7 @@ use photocraft_doc::LayerContent;
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_paint::mixer::{MixerSettings, apply_mixer_stroke};
|
||||
use photocraft_paint::replace::{Limits, ReplaceMode, ReplaceSettings, Sampling, apply_color_replacement, color_replacement_effect};
|
||||
use photocraft_paint::{BrushPreset, BrushSettings, GrayTile, Stroke, StrokePoint, StrokeRenderer, TipShape, render_stroke};
|
||||
use photocraft_paint::{BrushPreset, BrushSettings, GrayTile, PresetCaptures, Stroke, StrokePoint, StrokeRenderer, TipShape, render_stroke};
|
||||
use photocraft_raster::Surface;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -177,7 +177,7 @@ fn find_preset<'a>(s: &'a Session, name: &str, cmd: &str) -> Result<&'a BrushPre
|
||||
pub fn resolve_brush(s: &Session, p: &Value, cmd: &str) -> Result<BrushSettings> {
|
||||
let mut b = s.tools.brush.clone();
|
||||
if let Some(name) = p.get("preset").and_then(Value::as_str) {
|
||||
b = find_preset(s, name, cmd)?.brush.clone().picked_over(&s.tools.brush);
|
||||
b = find_preset(s, name, cmd)?.picked_over(&s.tools.brush);
|
||||
}
|
||||
if let Some(patch) = p.get("brush").filter(|v| v.is_object()) {
|
||||
b = merge_brush(&b, patch, cmd)?;
|
||||
@@ -204,6 +204,7 @@ pub fn resolve_brush(s: &Session, p: &Value, cmd: &str) -> Result<BrushSettings>
|
||||
b.smoothing.amount = v.clamp(0.0, 1.0);
|
||||
}
|
||||
b.color = color(p.get("color"), s.tools.foreground);
|
||||
b.color_cmyk = s.tools.picked_cmyk([b.color[0], b.color[1], b.color[2]]).map(|k| ([b.color[0], b.color[1], b.color[2]], k));
|
||||
b.background = s.tools.background;
|
||||
b.erase = flag(p, "erase", false);
|
||||
b.seed = match p.get("seed").and_then(Value::as_u64) {
|
||||
@@ -367,6 +368,7 @@ fn block_brush(b: BrushSettings, p: &Value) -> Result<BrushSettings> {
|
||||
pressure_opacity: false,
|
||||
spacing: 0.25,
|
||||
color: b.color,
|
||||
color_cmyk: b.color_cmyk,
|
||||
background: b.background,
|
||||
erase: b.erase,
|
||||
smoothing: b.smoothing,
|
||||
@@ -435,17 +437,19 @@ pub struct LiveStroke {
|
||||
base: (photocraft_doc::DocId, u64),
|
||||
}
|
||||
|
||||
/// A finished live stroke offered to its own commit ([`Session::offer_live_stroke`]): when the
|
||||
/// committing `paint.stroke` / `paint.pencil` matches it (same command, document revision, params,
|
||||
/// seed and points), the command takes these pixels instead of rendering the stroke again.
|
||||
/// A finished live stroke offered to its own commit ([`Session::offer_live_stroke`],
|
||||
/// [`Session::offer_live_retouch`]): when the committing command matches it (same command,
|
||||
/// document revision, params, seed and points), it takes these pixels instead of rendering the
|
||||
/// stroke again.
|
||||
pub struct LiveCommit {
|
||||
cmd: String,
|
||||
base: (photocraft_doc::DocId, u64),
|
||||
params: Value,
|
||||
seed: u64,
|
||||
points: Vec<StrokePoint>,
|
||||
surface: Surface,
|
||||
bounds: Rect,
|
||||
pub(crate) cmd: String,
|
||||
pub(crate) base: (photocraft_doc::DocId, u64),
|
||||
pub(crate) params: Value,
|
||||
/// The Brush's and Pencil's seed; the retouching tools have none.
|
||||
pub(crate) seed: Option<u64>,
|
||||
pub(crate) points: Vec<StrokePoint>,
|
||||
pub(crate) surface: Surface,
|
||||
pub(crate) bounds: Rect,
|
||||
}
|
||||
|
||||
impl LiveCommit {
|
||||
@@ -459,11 +463,11 @@ impl LiveCommit {
|
||||
p
|
||||
}
|
||||
|
||||
fn matches(&self, s: &Session, cmd: &str, p: &Value, pts: &[StrokePoint]) -> bool {
|
||||
pub(crate) fn matches(&self, s: &Session, cmd: &str, p: &Value, pts: &[StrokePoint]) -> bool {
|
||||
let base = s.active().map(|st| (st.doc.id, st.revision));
|
||||
self.cmd == cmd
|
||||
&& base == Some(self.base)
|
||||
&& p.get("seed").and_then(Value::as_u64) == Some(self.seed)
|
||||
&& p.get("seed").and_then(Value::as_u64) == self.seed
|
||||
&& self.points == pts
|
||||
&& Self::settings(p) == Self::settings(&self.params)
|
||||
}
|
||||
@@ -529,7 +533,7 @@ impl LiveStroke {
|
||||
pub fn into_commit(mut self) -> Result<LiveCommit> {
|
||||
let bounds = self.bounds();
|
||||
let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut self.doc), self.layer, &self.params)?;
|
||||
Ok(LiveCommit { cmd: self.cmd, base: self.base, params: self.params, seed: self.seed, points: self.points, surface: surf.clone(), bounds })
|
||||
Ok(LiveCommit { cmd: self.cmd, base: self.base, params: self.params, seed: Some(self.seed), points: self.points, surface: surf.clone(), bounds })
|
||||
}
|
||||
|
||||
/// Everything the stroke has touched so far.
|
||||
@@ -757,11 +761,16 @@ fn upsert(s: &mut Session, preset: BrushPreset) {
|
||||
fn presets_save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "brush.presets.save";
|
||||
let name = name_param(p, cmd)?;
|
||||
let brush = match p.get("brush").filter(|v| v.is_object()) {
|
||||
let mut brush = match p.get("brush").filter(|v| v.is_object()) {
|
||||
Some(patch) => merge_brush(&s.tools.brush, patch, cmd)?,
|
||||
None => s.tools.brush.clone(),
|
||||
};
|
||||
upsert(s, BrushPreset { name: name.clone(), brush, builtin: false, group: String::new(), folder: Vec::new() });
|
||||
// The New Brush Preset dialog's boxes; without them the preset captures what it always did.
|
||||
let captures = PresetCaptures { size: flag(p, "captureSize", true), tool_settings: flag(p, "toolSettings", true), color: flag(p, "color", false) };
|
||||
if captures.color {
|
||||
brush.color = s.tools.foreground;
|
||||
}
|
||||
upsert(s, BrushPreset { name: name.clone(), brush, builtin: false, group: String::new(), folder: Vec::new(), captures });
|
||||
// The saved brush is the current brush now (Photoshop selects the brush it just saved).
|
||||
s.tools.current_preset = Some(name.clone());
|
||||
Ok(json!({ "name": name, "count": s.tools.presets.len() }))
|
||||
@@ -877,7 +886,7 @@ fn define_from_selection(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let tip = GrayTile::from_f32(tw, th, &data);
|
||||
let brush = BrushSettings { tip: TipShape::Sampled(tip), size: tw.max(th) as f32, spacing: 0.25, pressure_size: false, ..BrushSettings::default() };
|
||||
upsert(s, BrushPreset { name: name.clone(), brush: brush.clone(), builtin: false, group: String::new(), folder: Vec::new() });
|
||||
upsert(s, BrushPreset { name: name.clone(), brush: brush.clone(), builtin: false, group: String::new(), folder: Vec::new(), captures: Default::default() });
|
||||
s.tools.brush = brush;
|
||||
s.tools.current_preset = Some(name.clone());
|
||||
Ok(json!({ "name": name, "width": tw, "height": th }))
|
||||
@@ -887,8 +896,11 @@ pub(crate) fn set_brush(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "tools.setBrush";
|
||||
let mut b = s.tools.brush.clone();
|
||||
let mut picked = None;
|
||||
let mut picked_color = None;
|
||||
if let Some(name) = p.get("preset").and_then(Value::as_str) {
|
||||
b = find_preset(s, name, cmd)?.brush.clone().picked_over(&s.tools.brush);
|
||||
let preset = find_preset(s, name, cmd)?;
|
||||
b = preset.picked_over(&s.tools.brush);
|
||||
picked_color = preset.captures.color.then_some(preset.brush.color);
|
||||
picked = Some(name.to_string());
|
||||
}
|
||||
let reset = flag(p, "reset", false);
|
||||
@@ -917,6 +929,10 @@ pub(crate) fn set_brush(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.tools.current_preset = None;
|
||||
} else if let Some(name) = picked {
|
||||
s.tools.current_preset = Some(name);
|
||||
// A preset saved with Include Color brings its colour as the foreground (Photoshop).
|
||||
if let Some(c) = picked_color {
|
||||
s.tools.foreground = c;
|
||||
}
|
||||
}
|
||||
// The tool's brush always paints with full tips: load a library preset's from the store.
|
||||
s.load_brush_tips(&mut b).map_err(|e| bad(cmd, e))?;
|
||||
@@ -1037,7 +1053,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
true
|
||||
),
|
||||
spec!("brush.presets.list", "List Brush Presets", r##"{"full":bool=false}"##, always, presets_list, false),
|
||||
spec!("brush.presets.save", "Save Brush Preset", r##"{"name":string,"brush":{…BrushSettings}?=current brush}"##, always, presets_save, true),
|
||||
spec!(
|
||||
"brush.presets.save",
|
||||
"Save Brush Preset",
|
||||
r##"{"name":string,"brush":{…BrushSettings}?=current brush,"captureSize":bool=true (false: picking it keeps the tool's size),"toolSettings":bool=true (false: picking it keeps the tool's mode, opacity, flow and pressure buttons),"color":bool=false (picking it sets the foreground to the current foreground)}"##,
|
||||
always,
|
||||
presets_save,
|
||||
true
|
||||
),
|
||||
spec!(
|
||||
"brush.presets.update",
|
||||
"Update Brush Preset",
|
||||
|
||||
@@ -191,7 +191,7 @@ fn live_color_replacement_matches_the_commit() {
|
||||
}
|
||||
}
|
||||
assert!(b.rgba(10, 20)[1] > 0.5 || sampling == "backgroundSwatch", "{depth}-bit {sampling}: recoloured");
|
||||
assert!(worst <= 2.0 / 255.0, "{depth}-bit {sampling}: live and commit differ by {worst}");
|
||||
assert!(worst <= 2.0 / 255.0 + 1e-6, "{depth}-bit {sampling}: live and commit differ by {worst}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -237,6 +237,45 @@ fn presets_save_load_delete_round_trip() {
|
||||
assert!(s.execute("brush.presets.save", json!({})).is_err());
|
||||
}
|
||||
|
||||
/// The New Brush Preset dialog's boxes (#4490): a preset saved without Capture Brush Size or
|
||||
/// Include Tool Settings leaves the tool's size, mode, opacity, flow and pressure buttons as they
|
||||
/// are when picked; one saved with Include Color brings its colour back as the foreground. A preset
|
||||
/// saved without the boxes picks as it always did.
|
||||
#[test]
|
||||
fn presets_save_captures_size_tool_settings_and_color_as_asked() {
|
||||
let mut s = session(10, 10);
|
||||
s.execute("tools.setBrush", json!({"size": 40, "opacity": 0.5, "flow": 0.25, "mode": "Multiply", "pressureSize": false})).unwrap();
|
||||
s.tools.foreground = [1.0, 0.0, 0.0, 1.0];
|
||||
s.execute("brush.presets.save", json!({"name": "Legacy"})).unwrap();
|
||||
s.execute("brush.presets.save", json!({"name": "Bare", "captureSize": false, "toolSettings": false})).unwrap();
|
||||
s.execute("brush.presets.save", json!({"name": "Red", "color": true})).unwrap();
|
||||
let caps = |n: &str| s.tools.presets.iter().find(|p| p.name == n).unwrap().captures.clone();
|
||||
assert!(caps("Legacy").is_default(), "no boxes, the captures of any other preset");
|
||||
assert_eq!(caps("Bare"), PresetCaptures { size: false, tool_settings: false, color: false });
|
||||
assert!(caps("Red").color);
|
||||
|
||||
// Change the tool, then pick each.
|
||||
let other = json!({"size": 9, "opacity": 1.0, "flow": 1.0, "mode": "Normal", "pressureSize": true});
|
||||
s.execute("tools.setBrush", other.clone()).unwrap();
|
||||
s.tools.foreground = [0.0, 0.0, 1.0, 1.0];
|
||||
s.execute("tools.setBrush", json!({"preset": "Legacy"})).unwrap();
|
||||
let b = &s.tools.brush;
|
||||
assert_eq!((b.size, b.opacity, b.flow, b.mode, b.pressure_size), (40.0, 0.5, 0.25, photocraft_color::BlendMode::Multiply, false), "everything it saved");
|
||||
assert_eq!(s.tools.foreground, [0.0, 0.0, 1.0, 1.0], "a preset without Include Color leaves the foreground");
|
||||
|
||||
s.execute("tools.setBrush", other.clone()).unwrap();
|
||||
s.execute("tools.setBrush", json!({"preset": "Bare"})).unwrap();
|
||||
let b = &s.tools.brush;
|
||||
assert_eq!((b.size, b.opacity, b.flow, b.mode, b.pressure_size), (9.0, 1.0, 1.0, photocraft_color::BlendMode::Normal, true), "the tool's own");
|
||||
assert_eq!(s.tools.current_preset.as_deref(), Some("Bare"));
|
||||
|
||||
s.execute("tools.setBrush", json!({"preset": "Red"})).unwrap();
|
||||
assert_eq!(s.tools.foreground, [1.0, 0.0, 0.0, 1.0], "Include Color sets the foreground");
|
||||
// A stroke naming the preset takes the same brush.
|
||||
let r = resolve_brush(&s, &json!({"preset": "Bare"}), "paint.brush").unwrap();
|
||||
assert_eq!(r.size, s.tools.brush.size);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presets_save_appends_at_the_end_and_update_overwrites_in_place() {
|
||||
let mut s = session(10, 10);
|
||||
@@ -744,10 +783,11 @@ fn out_of_range_scatter_is_rejected_and_the_maximum_still_paints() {
|
||||
let d: Vec<i64> = r["damage"].as_array().unwrap().iter().map(|v| v.as_i64().unwrap()).collect();
|
||||
assert!(d[0].abs() < 1000 && d[1].abs() < 1000 && d[2] > 0 && d[3] > 0, "{r}");
|
||||
}
|
||||
// Control: 200 % scatter paints where it did before the cap.
|
||||
// Control: 200 % scatter paints where it did before the cap (the soft default tip reaches a
|
||||
// little past its radius).
|
||||
let mut s = new();
|
||||
s.execute("tools.setBrush", json!({ "brush": scatter(2.0) })).unwrap();
|
||||
assert_eq!(s.execute("paint.stroke", stroke).unwrap(), json!({"damage": [17, 14, 23, 23]}));
|
||||
assert_eq!(s.execute("paint.stroke", stroke).unwrap(), json!({"damage": [15, 12, 27, 27]}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -883,3 +923,21 @@ fn clear_mode_erases_with_the_brush_or_pencil() {
|
||||
assert_eq!(s.active().unwrap().history.undo_label(), Some(label), "{cmd}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #4344: a foreground picked as CMYK 100/0/0/0 is painted into a CMYK document as those inks by
|
||||
/// the Brush and the Pencil, not as whatever its sRGB stand-in converts back to (100/28/0/17).
|
||||
#[test]
|
||||
fn brush_and_pencil_paint_a_cmyk_pick_with_the_picked_inks() {
|
||||
let cyan = [1.0f32, 0.0, 0.0, 0.0];
|
||||
for cmd in ["paint.stroke", "paint.pencil"] {
|
||||
let mut s = session(16, 16);
|
||||
s.execute("image.mode.cmyk", json!({})).unwrap();
|
||||
let rgb = photocraft_color::convert::cmyk_to_rgb(cyan).map(|c| c.clamp(0.0, 1.0));
|
||||
s.execute("tools.setColors", json!({"foreground": rgb, "foregroundCmyk": cyan})).unwrap();
|
||||
s.execute(cmd, json!({"points": [[8.0, 8.0]], "size": 6.0, "hardness": 1.0})).unwrap();
|
||||
let st = s.active().unwrap();
|
||||
let surf = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap();
|
||||
let got: Vec<f32> = surf.read_region(Rect::new(8, 8, 9, 9)).into_iter().take(4).collect();
|
||||
assert!(got.iter().zip(cyan).all(|(a, b)| (a - b).abs() < 0.02), "{cmd}: {got:?}");
|
||||
}
|
||||
}
|
||||
@@ -486,7 +486,14 @@ mod tests {
|
||||
fn with_groups() -> Session {
|
||||
let mut s = Session::new();
|
||||
for (n, g) in [("A1", "Alpha"), ("A2", "Alpha"), ("A3", "Alpha"), ("B1", "Beta"), ("B2", "Beta")] {
|
||||
s.tools.presets.push(BrushPreset { name: n.into(), brush: Default::default(), builtin: false, group: g.into(), folder: Vec::new() });
|
||||
s.tools.presets.push(BrushPreset {
|
||||
name: n.into(),
|
||||
brush: Default::default(),
|
||||
builtin: false,
|
||||
group: g.into(),
|
||||
folder: Vec::new(),
|
||||
captures: Default::default(),
|
||||
});
|
||||
}
|
||||
s
|
||||
}
|
||||
@@ -591,7 +598,14 @@ mod tests {
|
||||
let mut s = Session::new();
|
||||
for (n, f) in [("S0", &[][..]), ("I1", &["Ink"][..]), ("F1", &["Ink", "Fine"][..]), ("I2", &["Ink"][..]), ("D1", &["Dry"][..])] {
|
||||
let folder = f.iter().map(|x| x.to_string()).collect();
|
||||
s.tools.presets.push(BrushPreset { name: n.into(), brush: Default::default(), builtin: false, group: "Set".into(), folder });
|
||||
s.tools.presets.push(BrushPreset {
|
||||
name: n.into(),
|
||||
brush: Default::default(),
|
||||
builtin: false,
|
||||
group: "Set".into(),
|
||||
folder,
|
||||
captures: Default::default(),
|
||||
});
|
||||
}
|
||||
s
|
||||
}
|
||||
@@ -691,7 +705,14 @@ mod tests {
|
||||
let mut s = with_folders();
|
||||
for (n, f) in [("O1", &[][..]), ("B1", &["Box"][..])] {
|
||||
let folder = f.iter().map(|x| x.to_string()).collect();
|
||||
s.tools.presets.push(BrushPreset { name: n.into(), brush: Default::default(), builtin: false, group: "Other".into(), folder });
|
||||
s.tools.presets.push(BrushPreset {
|
||||
name: n.into(),
|
||||
brush: Default::default(),
|
||||
builtin: false,
|
||||
group: "Other".into(),
|
||||
folder,
|
||||
captures: Default::default(),
|
||||
});
|
||||
}
|
||||
s
|
||||
}
|
||||
@@ -724,8 +745,22 @@ mod tests {
|
||||
let mut s = with_two_groups();
|
||||
// A chain 31 deep under Set/Deep.
|
||||
let deep: Vec<String> = std::iter::once("Deep".to_string()).chain((0..30).map(|i| format!("d{i}"))).collect();
|
||||
s.tools.presets.push(BrushPreset { name: "Z".into(), brush: Default::default(), builtin: false, group: "Set".into(), folder: deep.clone() });
|
||||
s.tools.presets.push(BrushPreset { name: "Y".into(), brush: Default::default(), builtin: false, group: "Other".into(), folder: vec!["Dry".into()] });
|
||||
s.tools.presets.push(BrushPreset {
|
||||
name: "Z".into(),
|
||||
brush: Default::default(),
|
||||
builtin: false,
|
||||
group: "Set".into(),
|
||||
folder: deep.clone(),
|
||||
captures: Default::default(),
|
||||
});
|
||||
s.tools.presets.push(BrushPreset {
|
||||
name: "Y".into(),
|
||||
brush: Default::default(),
|
||||
builtin: false,
|
||||
group: "Other".into(),
|
||||
folder: vec!["Dry".into()],
|
||||
captures: Default::default(),
|
||||
});
|
||||
let before = s.tools.presets.clone();
|
||||
let deep_json = json!(deep);
|
||||
for p in [
|
||||
@@ -758,7 +793,14 @@ mod tests {
|
||||
s.execute("brush.presets.moveGroup", json!({"group": "Set", "folder": ["Ink", "Fine"], "into": {"group": "Set"}})).unwrap();
|
||||
assert_eq!(folder_of(&s, "F1"), ["Fine"]);
|
||||
// An ungrouped preset's group can't move.
|
||||
s.tools.presets.push(BrushPreset { name: "U".into(), brush: Default::default(), builtin: false, group: String::new(), folder: Vec::new() });
|
||||
s.tools.presets.push(BrushPreset {
|
||||
name: "U".into(),
|
||||
brush: Default::default(),
|
||||
builtin: false,
|
||||
group: String::new(),
|
||||
folder: Vec::new(),
|
||||
captures: Default::default(),
|
||||
});
|
||||
assert!(s.execute("brush.presets.moveGroup", json!({"group": "", "into": {"group": "Set"}})).is_err());
|
||||
}
|
||||
}
|
||||
@@ -73,7 +73,7 @@ pub(crate) fn copy(s: &mut Session) -> Result<Option<Value>> {
|
||||
if bounds.is_empty() {
|
||||
return Err(EngineError::Other("Could not copy: the selected area is empty".into()));
|
||||
}
|
||||
s.clipboard = Some(Clip { surface, bounds, layers: None });
|
||||
s.clipboard = Some(Clip { surface, bounds, layers: None, resolution: Some(doc.resolution_dpi) });
|
||||
Ok(Some(json!({"bounds": [bounds.x0, bounds.y0, bounds.width(), bounds.height()], "channel": k})))
|
||||
}
|
||||
|
||||
|
||||
@@ -194,8 +194,8 @@ pub fn channel_format(doc: &Document) -> PixelFormat {
|
||||
PixelFormat::new(ColorMode::Grayscale, doc.depth, false)
|
||||
}
|
||||
|
||||
/// Next free "Alpha N" name.
|
||||
fn next_alpha_name(doc: &Document, base: &str) -> String {
|
||||
/// Next free "`base` N" name (Alpha 1, Spot Color 2, …).
|
||||
pub fn next_alpha_name(doc: &Document, base: &str) -> String {
|
||||
// `channels.len() + 1` candidates always include a free one.
|
||||
(1..=doc.channels.len() + 1).map(|n| format!("{base} {n}")).find(|n| !doc.channels.iter().any(|c| &c.name == n)).unwrap_or_else(|| base.to_string())
|
||||
}
|
||||
@@ -897,6 +897,20 @@ fn load_selection(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if p.get("channel").is_none() {
|
||||
return Err(bad(cmd, "missing `channel`"));
|
||||
}
|
||||
// `"targeted"`: the Channels panel's target, as a Photoshop action's "load the target
|
||||
// channel" step records it.
|
||||
let mut targeted = p.clone();
|
||||
if p.get("channel").and_then(Value::as_str) == Some("targeted") {
|
||||
let st = s.active().ok_or(EngineError::NoDocument)?;
|
||||
targeted["channel"] = match st.channel_view.target {
|
||||
ChannelTarget::Composite => json!("composite"),
|
||||
ChannelTarget::Color(k) => json!({ "color": k }),
|
||||
ChannelTarget::Alpha(i) => json!(i),
|
||||
ChannelTarget::LayerMask => json!("mask"),
|
||||
ChannelTarget::VectorMask => json!("vectorMask"),
|
||||
};
|
||||
}
|
||||
let p = &targeted;
|
||||
// Composite and colour channels load their luminosity / values, as ⌘-clicking them does.
|
||||
let plane = source(s, p, cmd, true)?.into_iter().next().ok_or_else(|| bad(cmd, "the channel has no pixels"))?;
|
||||
let doc = &s.active().ok_or(EngineError::NoDocument)?.doc;
|
||||
@@ -1226,7 +1240,12 @@ fn target(s: &mut Session, t: ChannelTarget) {
|
||||
let v = &mut st.channel_view;
|
||||
v.target = t;
|
||||
match t {
|
||||
ChannelTarget::Composite | ChannelTarget::LayerMask | ChannelTarget::VectorMask => v.color_hidden.clear(),
|
||||
// Like clicking RGB in Photoshop: the alpha overlays go away with their eyes.
|
||||
ChannelTarget::Composite => {
|
||||
v.color_hidden.clear();
|
||||
v.alpha_visible.clear();
|
||||
}
|
||||
ChannelTarget::LayerMask | ChannelTarget::VectorMask => v.color_hidden.clear(),
|
||||
ChannelTarget::Color(k) => {
|
||||
v.color_hidden = (0..colors).map(|c| c != k).collect();
|
||||
v.alpha_visible.clear();
|
||||
@@ -1717,7 +1736,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Load Selection…",
|
||||
["Select"],
|
||||
None,
|
||||
r##"{"channel":index|name|"composite"|"red|green|blue|…"|"transparency"|"mask"|"vectorMask"|"quickMask"|"selection","layer":id?,"document":index?,"invert":bool=false,"operation":"new|add|subtract|intersect"="new"}"##,
|
||||
r##"{"channel":index|name|"composite"|"red|green|blue|…"|"transparency"|"mask"|"vectorMask"|"quickMask"|"selection"|"targeted" (the Channels panel's target),"layer":id?,"document":index?,"invert":bool=false,"operation":"new|add|subtract|intersect"="new"}"##,
|
||||
has_doc,
|
||||
load_selection
|
||||
),
|
||||
|
||||
@@ -218,6 +218,20 @@ fn deleting_a_channel_remaps_all_surviving_eyes_and_targets() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn targeting_the_composite_hides_the_alpha_overlay() {
|
||||
for (cmd, params) in [("channel.target", json!({"channel": "composite"})), ("channel.target.composite", json!({}))] {
|
||||
let mut s = session();
|
||||
s.execute("channel.new", json!({})).unwrap();
|
||||
assert!(s.active().unwrap().channel_view.alpha_shown(0));
|
||||
s.execute(cmd, params).unwrap();
|
||||
let st = s.active().unwrap();
|
||||
assert_eq!(st.channel_view.visible_colors(3), 3, "{cmd}");
|
||||
assert!(!st.channel_view.alpha_shown(0), "{cmd}: the alpha eye turns off");
|
||||
assert!(st.channel_view.is_plain(&st.doc), "{cmd}: no overlay left on the image (#4538)");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_colour_channel_can_hide_while_alpha_stays() {
|
||||
let mut s = session();
|
||||
@@ -533,6 +547,18 @@ fn apply_image_hand_computed() {
|
||||
assert!(s.execute("image.applyImage", json!({"blending": "hue"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_image_subtract_layer_from_itself_is_mid_grey() {
|
||||
// Frequency separation: Subtract, Scale 2, Offset 128, in the flat form the dialog sends.
|
||||
let mut s = session();
|
||||
s.execute("edit.fill", json!({"color": "#c08040"})).unwrap();
|
||||
s.execute("layer.new.layer", json!({"name": "High"})).unwrap();
|
||||
s.execute("edit.fill", json!({"color": "#406080"})).unwrap();
|
||||
s.execute("image.applyImage", json!({"sourceLayer": "High", "blending": "subtract", "scale": 2.0, "offset": 128})).unwrap();
|
||||
let p = px(&s, 3, 3);
|
||||
assert!(p[..3].iter().all(|v| (v - 128.0 / 255.0).abs() < 0.003), "{p:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_image_onto_transparent_layer() {
|
||||
for depth in [8, 16, 32] {
|
||||
|
||||
@@ -133,7 +133,8 @@ impl Default for ColorSettings {
|
||||
Self {
|
||||
working_rgb: Builtin::Srgb.id().into(),
|
||||
working_cmyk: Builtin::CoatedCmyk.id().into(),
|
||||
working_gray: Builtin::SGray.id().into(),
|
||||
// Photoshop's default working gray is Dot Gain 20 %.
|
||||
working_gray: Builtin::DotGain20Compat.id().into(),
|
||||
policy_rgb: Policy::Preserve,
|
||||
policy_cmyk: Policy::Preserve,
|
||||
policy_gray: Policy::Preserve,
|
||||
@@ -570,20 +571,41 @@ pub fn convert_surface(s: &Surface, from: ColorMode, to: PixelFormat, t: &Transf
|
||||
let mut dpo = vec![0.0f32; ds];
|
||||
t.convert_f32(&dp, ss, &mut dpo, ds, true);
|
||||
let mut out = Surface::with_default(to, &dpo);
|
||||
// Convert all tiles in one parallel pass into a staging buffer, then store them.
|
||||
let tiles: Vec<_> = s.tiles().map(|(c, t)| (*c, t.clone())).collect();
|
||||
// Convert every distinct tile once, in one parallel pass into a staging buffer, then store
|
||||
// them. Coordinates that share a tile (a filled background) share the converted one (#4531).
|
||||
let mut distinct: Vec<std::sync::Arc<photocraft_raster::Tile>> = Vec::new();
|
||||
let mut slot: std::collections::HashMap<*const photocraft_raster::Tile, usize> = std::collections::HashMap::new();
|
||||
let tiles: Vec<(photocraft_geom::TileCoord, usize)> = s
|
||||
.tiles()
|
||||
.map(|(c, tile)| {
|
||||
let i = *slot.entry(std::sync::Arc::as_ptr(tile)).or_insert_with(|| {
|
||||
distinct.push(tile.clone());
|
||||
distinct.len() - 1
|
||||
});
|
||||
(*c, i)
|
||||
})
|
||||
.collect();
|
||||
let tile_px = (photocraft_geom::TILE_SIZE * photocraft_geom::TILE_SIZE) as usize;
|
||||
let dst_len = tile_px * to.bytes_per_pixel();
|
||||
let mut staging = vec![0u8; dst_len * tiles.len()];
|
||||
let jobs: Vec<(&[u8], &mut [u8])> = tiles.iter().map(|(_, t)| t.bytes()).zip(staging.chunks_mut(dst_len.max(1))).collect();
|
||||
let mut staging = vec![0u8; dst_len * distinct.len()];
|
||||
let jobs: Vec<(&[u8], &mut [u8])> = distinct.iter().map(|t| t.bytes()).zip(staging.chunks_mut(dst_len.max(1))).collect();
|
||||
let kind = match sf.sample {
|
||||
SampleType::U8 => SampleKind::U8,
|
||||
SampleType::U16 => SampleKind::U16,
|
||||
SampleType::F32 => SampleKind::F32,
|
||||
};
|
||||
t.convert_bytes_many(kind, jobs, ss, ds, true);
|
||||
for ((c, _), bytes) in tiles.iter().zip(staging.chunks(dst_len.max(1))) {
|
||||
out.tile_mut(*c).bytes_mut().copy_from_slice(bytes);
|
||||
let mut converted: Vec<Option<std::sync::Arc<photocraft_raster::Tile>>> = vec![None; distinct.len()];
|
||||
for (c, i) in tiles {
|
||||
if let Some(Some(tile)) = converted.get(i) {
|
||||
out.put_tiles([(c, tile.clone())]);
|
||||
continue;
|
||||
}
|
||||
let Some(bytes) = staging.chunks(dst_len.max(1)).nth(i) else { continue };
|
||||
out.tile_mut(c).bytes_mut().copy_from_slice(bytes);
|
||||
if let Some(slot) = converted.get_mut(i) {
|
||||
*slot = out.tile(c).cloned();
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -1062,7 +1084,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"edit.assignProfile",
|
||||
"Assign Profile…",
|
||||
["Edit"],
|
||||
r##"{"profile":"working|srgb|display-p3|adobe-rgb-compat|prophoto-compat|linear-srgb|rec2020|gray-gamma-2.2|sgray|lab-d50|coated-cmyk|none" (or a path to an .icc file)}"##,
|
||||
r##"{"profile":"working|srgb|display-p3|adobe-rgb-compat|prophoto-compat|linear-srgb|rec2020|gray-gamma-2.2|sgray|dot-gain-20-compat|lab-d50|coated-cmyk|none" (or a path to an .icc file)}"##,
|
||||
has_doc,
|
||||
assign_profile,
|
||||
true
|
||||
@@ -1080,7 +1102,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"edit.colorSettings",
|
||||
"Color Settings…",
|
||||
["Edit"],
|
||||
r##"{"workingRgb":"srgb|display-p3|adobe-rgb-compat|prophoto-compat|linear-srgb|rec2020","workingCmyk":"coated-cmyk","workingGray":"sgray|gray-gamma-2.2","policyRgb":"preserve|convert|off","policyCmyk":"preserve|convert|off","policyGray":"preserve|convert|off","askOnMismatch":bool=true,"askOnPaste":bool=true,"askOnMissing":bool=false,"intent":"relative|perceptual|saturation|absolute","blendTextGamma":1.0..2.2|bool=1.45,"bpc":bool=true,"dither":bool=true,"monitorProfile":"auto|srgb|display-p3|adobe-rgb-compat|prophoto-compat|rec2020","reset":bool=false} (working spaces and the monitor profile also accept .icc paths; monitor `auto` = the main display's profile when the platform provides it, else sRGB; the reply's `monitorStatus` says which profile is in use and why: source auto|manual|fallback, reason)"##,
|
||||
r##"{"workingRgb":"srgb|display-p3|adobe-rgb-compat|prophoto-compat|linear-srgb|rec2020","workingCmyk":"coated-cmyk","workingGray":"dot-gain-20-compat|sgray|gray-gamma-2.2","policyRgb":"preserve|convert|off","policyCmyk":"preserve|convert|off","policyGray":"preserve|convert|off","askOnMismatch":bool=true,"askOnPaste":bool=true,"askOnMissing":bool=false,"intent":"relative|perceptual|saturation|absolute","blendTextGamma":1.0..2.2|bool=1.45,"bpc":bool=true,"dither":bool=true,"monitorProfile":"auto|srgb|display-p3|adobe-rgb-compat|prophoto-compat|rec2020","reset":bool=false} (working spaces and the monitor profile also accept .icc paths; monitor `auto` = the main display's profile when the platform provides it, else sRGB; the reply's `monitorStatus` says which profile is in use and why: source auto|manual|fallback, reason)"##,
|
||||
always,
|
||||
color_settings,
|
||||
true,
|
||||
@@ -1124,6 +1146,29 @@ mod tests {
|
||||
use super::*;
|
||||
use photocraft_geom::Rect;
|
||||
|
||||
/// Image › Mode to CMYK keeps a filled background's shared tile shared (#4531): one
|
||||
/// converted tile for the whole grid, the same colour everywhere.
|
||||
#[test]
|
||||
fn mode_conversion_keeps_shared_tiles_shared() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 2048, "height": 1024, "background": "white"})).unwrap();
|
||||
let distinct = |s: &Session| {
|
||||
let doc = &s.active().unwrap().doc;
|
||||
let surf = doc.layers[0].surface().unwrap();
|
||||
let mut seen: Vec<_> = surf.tiles().map(|(_, t)| std::sync::Arc::as_ptr(t)).collect();
|
||||
seen.sort();
|
||||
seen.dedup();
|
||||
(seen.len(), surf.tile_count())
|
||||
};
|
||||
let (before, count) = distinct(&s);
|
||||
s.execute("image.mode.cmyk", json!({})).unwrap();
|
||||
let (after, count_after) = distinct(&s);
|
||||
assert_eq!((after, count_after), (before, count), "{before} distinct of {count} before");
|
||||
let doc = &s.active().unwrap().doc;
|
||||
let surf = doc.layers[0].surface().unwrap();
|
||||
assert_eq!(surf.pixel(5, 5), surf.pixel(2000, 1000), "the same converted white everywhere");
|
||||
}
|
||||
|
||||
fn session(mode: &str, depth: u32) -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 64, "height": 32, "mode": mode, "depth": depth})).unwrap();
|
||||
@@ -1280,7 +1325,10 @@ mod tests {
|
||||
let t = s.color.display_transform(doc(&s)).unwrap();
|
||||
let mut o = [0.0f32; 3];
|
||||
t.eval(&[0.5, 0.5, 0.5], &mut o);
|
||||
assert!(o.iter().all(|v| (v - 0.5).abs() < 2e-3), "sGray displays as sRGB gray: {o:?}");
|
||||
// The default working gray (Dot Gain 20 %-compatible, gamma 1.732) shows gray 0.5 as the
|
||||
// sRGB value of linear 0.5^1.732, as Photoshop does: lighter than sGray's 0.5.
|
||||
let want = photocraft_color::convert::linear_to_srgb(0.5f32.powf(1.732));
|
||||
assert!(o.iter().all(|v| (v - want).abs() < 2e-3), "Dot Gain 20 % gray displays as {want}: {o:?}");
|
||||
let s = session("cmyk", 8);
|
||||
assert_eq!(composite_profile(doc(&s)).description, "sRGB IEC61966-2.1");
|
||||
}
|
||||
|
||||
@@ -652,7 +652,7 @@ fn build() -> Vec<CommandSpec> {
|
||||
"Select Layer",
|
||||
[],
|
||||
None,
|
||||
r##"{"layer":id,"mode":"replace|toggle|range|add"="replace"} (toggle = ⌘-click, range = ⇧-click)"##,
|
||||
r##"{"layer":id,"mode":"replace|toggle|range|add|remove"="replace"} (toggle = ⌘-click, range = ⇧-click, remove = deselect it, keeping at least one)"##,
|
||||
has_doc,
|
||||
crate::layer_multi_cmds::select
|
||||
),
|
||||
@@ -922,7 +922,7 @@ fn build() -> Vec<CommandSpec> {
|
||||
"Move Layer",
|
||||
[],
|
||||
None,
|
||||
r##"{"layer":id?,"dx":i32,"dy":i32,"target":"pixels"|"mask"="pixels"} (no layer: every selected layer; linked layers follow; target mask: a layer whose mask is unlinked moves that mask alone, a linked mask moves with the layer)"##,
|
||||
r##"{"layer":id?,"dx":i32,"dy":i32,"units":"px"|"pt"="px" (pt: points, whole pixels at the document's resolution),"target":"pixels"|"mask"="pixels"} (no layer: every selected layer; linked layers follow; target mask: a layer whose mask is unlinked moves that mask alone, a linked mask moves with the layer)"##,
|
||||
has_layer,
|
||||
crate::layer_multi_cmds::translate
|
||||
),
|
||||
|
||||
@@ -319,13 +319,21 @@ fn restore_last(s: &mut Session) -> Result<Value> {
|
||||
}
|
||||
|
||||
/// Comps that refer to deleted layers (Photoshop's warning triangle). `"clear": true` drops
|
||||
/// those stale states (Clear Layer Comp Warning) as one history step.
|
||||
/// those stale states as one history step: from `"comp"` only when given (Clear Layer Comp
|
||||
/// Warning), else from every comp (Clear All Layer Comp Warnings). `"comp"` also limits the
|
||||
/// report to that comp; an unknown comp is an error and changes nothing.
|
||||
fn update_warnings(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "layerComp.updateWarnings";
|
||||
let d = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let only = match p.get("comp") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(_) => Some(comp_param(&d.doc, p, cmd)?),
|
||||
};
|
||||
let warnings: Vec<Value> = d
|
||||
.doc
|
||||
.layer_comps
|
||||
.iter()
|
||||
.filter(|c| only.is_none_or(|id| c.id == id))
|
||||
.filter_map(|c| {
|
||||
let missing = c.missing_layers(&d.doc);
|
||||
(!missing.is_empty()).then(|| json!({"comp": c.id, "name": c.name, "missingLayers": missing.iter().map(|l| l.0).collect::<Vec<_>>()}))
|
||||
@@ -333,9 +341,10 @@ fn update_warnings(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.collect();
|
||||
let n = warnings.len();
|
||||
if flag(p, "clear", false) && n > 0 {
|
||||
s.edit("Clear Layer Comp Warnings", |doc, _| {
|
||||
let label = if only.is_some() { "Clear Layer Comp Warning" } else { "Clear All Layer Comp Warnings" };
|
||||
s.edit(label, |doc, _| {
|
||||
let live: std::collections::HashSet<LayerId> = doc.walk().into_iter().map(|(_, _, l)| l.id).collect();
|
||||
for c in &mut doc.layer_comps {
|
||||
for c in doc.layer_comps.iter_mut().filter(|c| only.is_none_or(|id| c.id == id)) {
|
||||
c.states.retain(|st| live.contains(&st.layer));
|
||||
}
|
||||
Ok(())
|
||||
@@ -434,7 +443,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"layerComp.updateWarnings",
|
||||
"Layer Comp Warnings",
|
||||
&[],
|
||||
r##"{"clear":bool=false (drop states of deleted layers)} → {warnings:[{comp,name,missingLayers}],count}"##,
|
||||
r##"{"comp":id|name? (default: every comp; limits the report and the clear to that comp),"clear":bool=false (drop states of deleted layers)} → {warnings:[{comp,name,missingLayers}],count}"##,
|
||||
has_doc,
|
||||
update_warnings
|
||||
),
|
||||
|
||||
@@ -304,3 +304,46 @@ fn unreachable_comp_positions_leave_the_layer_in_place() {
|
||||
assert_eq!(pos(&s, b), Some((31, 22)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_one_comps_warning_leaves_the_others() {
|
||||
// Regression for #4197: the per-comp warning icon cleared every comp's warnings.
|
||||
let (mut s, a, _) = session(8);
|
||||
let c1 = s.execute("layerComp.new", json!({"name": "One"})).unwrap()["comp"].as_u64().unwrap();
|
||||
let c2 = s.execute("layerComp.new", json!({"name": "Two"})).unwrap()["comp"].as_u64().unwrap();
|
||||
s.edit("del", |d, _| {
|
||||
d.remove(a);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
// Reporting can be limited to one comp too.
|
||||
let w = s.execute("layerComp.updateWarnings", json!({"comp": c2})).unwrap();
|
||||
assert_eq!(w["count"], 1);
|
||||
assert_eq!(w["warnings"][0]["comp"], c2);
|
||||
s.execute("layerComp.updateWarnings", json!({"clear": true, "comp": c1})).unwrap();
|
||||
assert!(doc(&s).comp(c1 as u32).unwrap().missing_layers(doc(&s)).is_empty());
|
||||
assert_eq!(doc(&s).comp(c2 as u32).unwrap().missing_layers(doc(&s)), vec![a]);
|
||||
let w = s.execute("layerComp.updateWarnings", json!({})).unwrap();
|
||||
assert_eq!(w["count"], 1);
|
||||
assert_eq!(w["warnings"][0]["comp"], c2);
|
||||
// Without "comp" every comp is cleared (Clear All Layer Comp Warnings).
|
||||
s.execute("layerComp.updateWarnings", json!({"clear": true})).unwrap();
|
||||
assert_eq!(s.execute("layerComp.updateWarnings", json!({})).unwrap()["count"], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_warnings_of_an_unknown_comp_is_an_error() {
|
||||
let (mut s, a, _) = session(8);
|
||||
let c1 = s.execute("layerComp.new", json!({})).unwrap()["comp"].as_u64().unwrap();
|
||||
s.edit("del", |d, _| {
|
||||
d.remove(a);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let before = doc(&s).layer_comps.clone();
|
||||
for bad in [json!(c1 + 99), json!("nope"), json!(-1), json!(1.5), json!(true), json!([c1])] {
|
||||
assert!(s.execute("layerComp.updateWarnings", json!({"clear": true, "comp": bad})).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(doc(&s).layer_comps, before);
|
||||
assert_eq!(s.execute("layerComp.updateWarnings", json!({})).unwrap()["count"], 1);
|
||||
}
|
||||
@@ -80,8 +80,11 @@ fn srgb_is_the_identity_fast_path() {
|
||||
assert!(s.color.canvas_lut(&d, 9).unwrap().is_none(), "no GPU LUT either");
|
||||
assert!(close(shown(&s, &d), [204.0, 127.5, 76.5], 0.6));
|
||||
}
|
||||
// Untagged gray (sGray), CMYK (shown in sRGB) and Lab composites are sRGB too.
|
||||
let g = Document::with_background("g", Size::new(4, 4), ColorMode::Grayscale, SampleType::U8, Color::gray(0.3));
|
||||
// sGray-tagged gray and CMYK (shown in sRGB) composites are sRGB too; untagged gray takes the
|
||||
// default working gray (Dot Gain 20 %-compatible), which is not.
|
||||
let mut g = Document::with_background("g", Size::new(4, 4), ColorMode::Grayscale, SampleType::U8, Color::gray(0.3));
|
||||
assert!(!s.color.canvas_display(&g).unwrap().is_identity());
|
||||
g.icc_profile = Some(Builtin::SGray.profile().to_bytes());
|
||||
assert!(s.color.canvas_display(&g).unwrap().is_identity());
|
||||
assert!(s.color.canvas_display(&cmyk_doc(None, [0.1, 0.5, 0.5, 0.0])).unwrap().is_identity());
|
||||
// Cached: the same Arc for the same profiles.
|
||||
@@ -195,7 +198,8 @@ fn gray_exr_round_trip_is_linear() {
|
||||
let mut s = Session::new();
|
||||
// An sGray document exported to EXR is linearised (#2386); re-opened it is tagged linear gray
|
||||
// and displays as before.
|
||||
let d = Document::with_background("t", Size::new(8, 8), ColorMode::Grayscale, SampleType::U8, Color::gray(128.0 / 255.0));
|
||||
let mut d = Document::with_background("t", Size::new(8, 8), ColorMode::Grayscale, SampleType::U8, Color::gray(128.0 / 255.0));
|
||||
d.icc_profile = Some(Builtin::SGray.profile().to_bytes());
|
||||
let r = photocraft_io::export(&d, "x.exr", &photocraft_io::ExportOptions::default()).unwrap();
|
||||
let back = photocraft_io::import("x.exr", &r.bytes).unwrap().document;
|
||||
let icc = back.icc_profile.clone().expect("tagged");
|
||||
|
||||
Loaded 100 of 417 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user